commit 4ca111dd76b777ecb578085c9766f1d7affa6809
parent 34685d68a58e23489cf2c229a6f8378897e56a1c
Author: Michael Camilleri <[email protected]>
Date: Fri, 5 Jun 2026 08:20:01 +0900
Show wrong-fill alert only for local attempts
Prior to this commit, a fully-filled wrong puzzle stayed in
.filledWithErrors when the user overwrote one wrong letter with another,
so PuzzleView's completion-state observer had no transition to react to.
The 'Not Quite Right' alert only appeared when the final empty square
was filled.
PlayerSession now publishes a local completion event with a fresh
sequence number whenever local input reaches a terminal completion
state. PuzzleView uses that event for the wrong-fill alert, while
observed .filledWithErrors transitions are ignored so a collaborator's
wrong entry does not interrupt the local user. Remote .solved still
flows through the observed completion-state path.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
3 files changed, 45 insertions(+), 15 deletions(-)
diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift
@@ -45,11 +45,18 @@ final class PlayerSession {
@ObservationIgnored
private var lastPublishedCursorTrack: PlayerSelection?
- /// Optional sink fired after local mutations that can fill or solve the
- /// puzzle. This keeps completion UI out of SwiftUI's render-time body
- /// evaluation path.
+ /// Published after local mutations that can fill or solve the puzzle.
+ /// Unlike `Game.completionState`, this is an event: repeated failed
+ /// attempts on an already-full wrong grid still get a fresh sequence.
+ struct CompletionEvent: Equatable {
+ let sequence: Int
+ let state: Game.CompletionState
+ }
+
+ var localCompletionEvent: CompletionEvent?
+
@ObservationIgnored
- var onCompletionStateChanged: ((Game.CompletionState) -> Void)?
+ private var localCompletionEventSequence = 0
/// Rebus mode lets the player type a multi-character value into a single
/// cell (e.g. "STAR" or "♥"). While active, keyboard input accumulates in
@@ -472,7 +479,11 @@ final class PlayerSession {
private func publishTerminalCompletionState(_ state: Game.CompletionState? = nil) {
let state = state ?? game.completionState
guard state != .incomplete else { return }
- onCompletionStateChanged?(state)
+ localCompletionEventSequence += 1
+ localCompletionEvent = CompletionEvent(
+ sequence: localCompletionEventSequence,
+ state: state
+ )
}
private func advance() {
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -272,10 +272,8 @@ struct PuzzleView: View {
private func handleObservedCompletionState(_ newValue: Game.CompletionState) {
switch newValue {
- case .incomplete:
+ case .incomplete, .filledWithErrors:
break
- case .filledWithErrors:
- showErrorsAlert = true
case .solved:
hasSolved = true
onComplete?(false)
@@ -1029,13 +1027,9 @@ private struct PuzzleLifecycleModifier: ViewModifier {
.onChange(of: session.game.completionState) { _, newValue in
onObservedCompletionStateChanged(newValue)
}
- .onAppear {
- session.onCompletionStateChanged = { newValue in
- onLocalCompletionStateChanged(newValue)
- }
- }
- .onDisappear {
- session.onCompletionStateChanged = nil
+ .onChange(of: session.localCompletionEvent) { _, newValue in
+ guard let newValue else { return }
+ onLocalCompletionStateChanged(newValue.state)
}
}
}
diff --git a/Tests/Unit/PlayerSessionNavigationTests.swift b/Tests/Unit/PlayerSessionNavigationTests.swift
@@ -101,6 +101,31 @@ struct PlayerSessionNavigationTests {
#expect(session.currentClue()?.number == firstAcross.number)
}
+ @Test("Overwriting a full wrong grid publishes a fresh error completion event")
+ func overwriteFullWrongGridPublishesFreshErrorEvent() throws {
+ let session = try makeNavigationSession()
+
+ let letters = [
+ (0, 0, "A"), (0, 1, "B"), (0, 2, "C"),
+ (1, 0, "D"), (1, 2, "E"),
+ (2, 0, "F"), (2, 1, "G"), (2, 2, "Z")
+ ]
+ for (row, col, letter) in letters {
+ session.select(row: row, col: col)
+ session.enter(letter)
+ }
+ let firstEvent = try #require(session.localCompletionEvent)
+
+ session.select(row: 2, col: 2)
+ session.enter("Y")
+ let secondEvent = try #require(session.localCompletionEvent)
+
+ #expect(session.game.completionState == .filledWithErrors)
+ #expect(firstEvent.state == .filledWithErrors)
+ #expect(secondEvent.state == .filledWithErrors)
+ #expect(secondEvent.sequence == firstEvent.sequence + 1)
+ }
+
@Test("Backspace from first down moves to final across end")
func backspaceFromFirstDownMovesToFinalAcrossEnd() throws {
let session = try makeNavigationSession()