crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit a7cc5c0783914d5eb90051bf09decaadaa30ae94
parent 2d908a908c5b8cf0488d917156fedcb6b5df452c
Author: Michael Camilleri <[email protected]>
Date:   Fri,  5 Jun 2026 16:36:18 +0900

Unify completion detection into one provenance-tagged event

PuzzleView has two competing completion observers driven by a single
edit. A local keystroke mutates game.squares, which both flips the
derived game.completionState (observed by one onChange) and publishes
localCompletionEvent (observed by another). Since a local edit trips
both, the two handlers race on a shared hasSolved latch: the observed
handler, declared first, runs first, latches hasSolved, and takes the
silent onComplete(false) path — so the local handler's onComplete(true),
the only path that fires the win push, bails on its `guard !hasSolved`.
The observed path was meant only for a collaborator's remote solve, but
it can't tell a local solve apart from a remote one.

PlayerSession now owns a single completionEvent carrying both the
completion state and an origin (.local / .observed). Local input emits
.local inline; a remote merge into the shared Game is reconciled into
.observed via an observation on game.completionState, armed after init so
the restored initial state never fires and deduped against a solve the
local path already announced. PuzzleView collapses to one onChange that
switches on (origin, state) — no second signal, no ordering coupling.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/Models/PlayerSession.swift | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
MCrossmate/Views/PuzzleView.swift | 36++++++++++++++----------------------
MTests/Unit/PlayerSessionNavigationTests.swift | 28++++++++++++++++++++++++++--
3 files changed, 105 insertions(+), 39 deletions(-)

diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift @@ -45,18 +45,32 @@ final class PlayerSession { @ObservationIgnored private var lastPublishedCursorTrack: PlayerSelection? - /// 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. + /// The single completion signal the UI reacts to, carrying both *what* + /// completion state the grid reached and *who* drove it there. Unlike + /// `Game.completionState` — derived state that flips for local and remote + /// edits alike — this is an event: repeated failed attempts on an + /// already-full wrong grid still get a fresh sequence, and a local solve is + /// distinguishable from a collaborator's. `PlayerSession` is the sole owner: + /// local input emits `.local` inline, while a remote merge into the shared + /// `Game` is reconciled into `.observed` (see `observeRemoteCompletion`). A + /// single owner means the view has one handler and no ordering coupling. struct CompletionEvent: Equatable { let sequence: Int let state: Game.CompletionState + let origin: Origin + + /// Who drove the grid to its completion state: this player's own input, + /// or a merge of a collaborator's edits. + enum Origin: Equatable { + case local + case observed + } } - var localCompletionEvent: CompletionEvent? + var completionEvent: CompletionEvent? @ObservationIgnored - private var localCompletionEventSequence = 0 + private var completionEventSequence = 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 @@ -114,6 +128,8 @@ final class PlayerSession { hasWord(at: selectedRow, col: selectedCol, direction: direction.opposite) { direction = direction.opposite } + + observeRemoteCompletion() } // MARK: - Selection @@ -273,17 +289,17 @@ final class PlayerSession { let cell = puzzle.cells[selectedRow][selectedCol] guard !cell.isBlock else { return } mutator.revealCells([cell]) - publishTerminalCompletionState() + publishLocalCompletion() } func revealCurrentWord() { mutator.revealCells(currentWordCells()) - publishTerminalCompletionState() + publishLocalCompletion() } func revealPuzzle() { mutator.revealCells(puzzle.cells.flatMap { $0 }) - publishTerminalCompletionState() + publishLocalCompletion() } func clearCurrentWord() { @@ -373,7 +389,7 @@ final class PlayerSession { guard !cell.isBlock else { return } mutator.setLetter(letter, atRow: inputRow, atCol: inputCol, pencil: isPencilMode, direction: direction) let completionState = game.completionState - publishTerminalCompletionState(completionState) + publishLocalCompletion(completionState) if completionState != .solved { advance() } @@ -419,7 +435,7 @@ final class PlayerSession { rebusBuffer = "" mutator.setLetter(value, atRow: inputRow, atCol: inputCol, pencil: isPencilMode, direction: direction) let completionState = game.completionState - publishTerminalCompletionState(completionState) + publishLocalCompletion(completionState) if completionState != .solved { advance() } @@ -476,16 +492,50 @@ final class PlayerSession { direction == .across ? (0, 1) : (1, 0) } - private func publishTerminalCompletionState(_ state: Game.CompletionState? = nil) { + private func publishLocalCompletion(_ state: Game.CompletionState? = nil) { let state = state ?? game.completionState guard state != .incomplete else { return } - localCompletionEventSequence += 1 - localCompletionEvent = CompletionEvent( - sequence: localCompletionEventSequence, - state: state + emitCompletion(state, origin: .local) + } + + private func emitCompletion(_ state: Game.CompletionState, origin: CompletionEvent.Origin) { + completionEventSequence += 1 + completionEvent = CompletionEvent( + sequence: completionEventSequence, + state: state, + origin: origin ) } + /// Watches the shared `Game`'s completion state for a transition this + /// player didn't drive — a collaborator's merged edits completing the grid + /// — and reconciles it into a single `.observed` completion event. Armed + /// once at the end of `init` (so the game's already-restored initial state + /// never fires) and re-armed after every change, since + /// `withObservationTracking` is one-shot. Only a remote *solve* surfaces: a + /// collaborator's wrong fill must not interrupt the local solver, and a + /// solve the local input path already announced is not re-emitted. + private func observeRemoteCompletion() { + withObservationTracking { + _ = game.completionState + } onChange: { [weak self] in + // `onChange` fires synchronously at willSet, before the new value is + // readable; hop to the main actor to read the settled state and re-arm. + Task { @MainActor [weak self] in + guard let self else { return } + self.reconcileRemoteCompletion() + self.observeRemoteCompletion() + } + } + } + + private func reconcileRemoteCompletion() { + let state = game.completionState + guard state == .solved else { return } + guard completionEvent?.state != .solved else { return } + emitCompletion(state, origin: .observed) + } + private func advance() { let (dr, dc) = step(for: direction) let r = selectedRow + dr diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -130,8 +130,7 @@ struct PuzzleView: View { session: session, roster: roster, hasSolved: $hasSolved, - onLocalCompletionStateChanged: handleLocalCompletionState, - onObservedCompletionStateChanged: handleObservedCompletionState, + onCompletionEvent: handleCompletionEvent, onSolvedOnAppear: { onComplete?(false) } @@ -252,13 +251,16 @@ struct PuzzleView: View { } } - private func handleLocalCompletionState(_ newValue: Game.CompletionState) { - switch newValue { - case .incomplete: + private func handleCompletionEvent(_ event: PlayerSession.CompletionEvent) { + switch (event.origin, event.state) { + case (_, .incomplete): break - case .filledWithErrors: + case (.observed, .filledWithErrors): + // A collaborator's wrong entry must not interrupt the local solver. + break + case (.local, .filledWithErrors): showErrorsAlert = true - case .solved: + case (.local, .solved): guard !hasSolved else { return } hasSolved = true if session.isPencilMode { @@ -267,14 +269,8 @@ struct PuzzleView: View { Task { @MainActor in onComplete?(true) } - } - } - - private func handleObservedCompletionState(_ newValue: Game.CompletionState) { - switch newValue { - case .incomplete, .filledWithErrors: - break - case .solved: + case (.observed, .solved): + guard !hasSolved else { return } hasSolved = true onComplete?(false) } @@ -1009,8 +1005,7 @@ private struct PuzzleLifecycleModifier: ViewModifier { let session: PlayerSession let roster: PlayerRoster @Binding var hasSolved: Bool - let onLocalCompletionStateChanged: (Game.CompletionState) -> Void - let onObservedCompletionStateChanged: (Game.CompletionState) -> Void + let onCompletionEvent: (PlayerSession.CompletionEvent) -> Void let onSolvedOnAppear: () -> Void func body(content: Content) -> some View { @@ -1024,12 +1019,9 @@ private struct PuzzleLifecycleModifier: ViewModifier { onSolvedOnAppear() } } - .onChange(of: session.game.completionState) { _, newValue in - onObservedCompletionStateChanged(newValue) - } - .onChange(of: session.localCompletionEvent) { _, newValue in + .onChange(of: session.completionEvent) { _, newValue in guard let newValue else { return } - onLocalCompletionStateChanged(newValue.state) + onCompletionEvent(newValue) } } } diff --git a/Tests/Unit/PlayerSessionNavigationTests.swift b/Tests/Unit/PlayerSessionNavigationTests.swift @@ -114,18 +114,42 @@ struct PlayerSessionNavigationTests { session.select(row: row, col: col) session.enter(letter) } - let firstEvent = try #require(session.localCompletionEvent) + let firstEvent = try #require(session.completionEvent) session.select(row: 2, col: 2) session.enter("Y") - let secondEvent = try #require(session.localCompletionEvent) + let secondEvent = try #require(session.completionEvent) #expect(session.game.completionState == .filledWithErrors) #expect(firstEvent.state == .filledWithErrors) + #expect(firstEvent.origin == .local) #expect(secondEvent.state == .filledWithErrors) + #expect(secondEvent.origin == .local) #expect(secondEvent.sequence == firstEvent.sequence + 1) } + @Test("Solving with local input publishes a local solved completion event") + func localSolvePublishesLocalSolvedEvent() throws { + let session = try makeNavigationSession() + + let solution = [ + (0, 0, "A"), (0, 1, "B"), (0, 2, "C"), + (1, 0, "D"), (1, 2, "E"), + (2, 0, "F"), (2, 1, "G"), (2, 2, "H") + ] + for (row, col, letter) in solution { + session.select(row: row, col: col) + session.enter(letter) + } + + let event = try #require(session.completionEvent) + #expect(session.game.completionState == .solved) + // The win-push path keys on (.local, .solved); a self-solve must land + // there rather than the silent observed path. + #expect(event.state == .solved) + #expect(event.origin == .local) + } + @Test("Backspace from first down moves to final across end") func backspaceFromFirstDownMovesToFinalAcrossEnd() throws { let session = try makeNavigationSession()