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:
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()