commit f825da95cdabf750217d4cac1719479421f84258
parent 2d9c2e4f2ace8ea774d95d16b1e7fbef43972a88
Author: Michael Camilleri <[email protected]>
Date: Tue, 2 Jun 2026 00:19:22 +0900
Make a completed game terminal and read-only
Completion is a latched, synced fact (Game.completedAt), but the grid is
a per-device last-writer-wins merge of every device's Moves record. The
two can diverge after completion — a late clear, clock skew, or an edit
that reached a peer live but never synced — so a 'finished' puzzle could
open with a hole. Worse, input was gated on the live completionState (==
.solved derived from the merged grid), not on the latch, so a divergent
grid read as incomplete: the keyboard stayed up, the user could type
into a completed game and 're-solve' it.
A completed game is now terminal. GameMutator gains an isCompleted latch
(set from completedAt at construction, flipped when a win latches
mid-session); every mutating entry point — setLetter, clearLetter, the
shared applyBulk behind check/reveal/clear, and undo/redo — no-ops while
it's set, so nothing changes the grid, not even in memory. GameStore's
restore seals the displayed grid to the puzzle solution for a completed
game: cells the merge already resolved to an accepted answer keep their
author and mark, while holes and stray post-completion letters are
repaired to the canonical solution, so a finished puzzle always renders
solved regardless of merge drift. PuzzleView's isSolved keys off the
latch, so the finish panel shows, the keyboard hides, and the controls
disable even when the local merge is divergent.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
6 files changed, 275 insertions(+), 7 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -89,6 +89,7 @@
9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0BF60C84D92A9024AC1A53FC /* Media.xcassets */; };
978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */; };
98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; };
+ 9AACF424992AE45FD7937064 /* GameStoreCompletionLockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */; };
9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61E2582D94B1E6EC67136 /* XDFileType.swift */; };
9EC3CD7BAD8A4B9F1B3A5C97 /* ReplayControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A05DD7C0602C2BFAF2EF2F /* ReplayControllerTests.swift */; };
A133A4B4A0C95AF8708BD7E6 /* PushClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */; };
@@ -191,6 +192,7 @@
0BF60C84D92A9024AC1A53FC /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; };
0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; };
0CE803B2DF05DDF457B27FE2 /* CompactSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactSlider.swift; sourceTree = "<group>"; };
+ 0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreCompletionLockTests.swift; sourceTree = "<group>"; };
0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMonitor.swift; sourceTree = "<group>"; };
0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckResult.swift; sourceTree = "<group>"; };
11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisher.swift; sourceTree = "<group>"; };
@@ -384,6 +386,7 @@
978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */,
60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */,
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */,
+ 0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */,
09EA25E2AE98ACD029EC0129 /* GameStoreContributingDevicesTests.swift */,
122BC1863D12DE06388D5DA7 /* GameStoreMergedAuthorCellsTests.swift */,
9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */,
@@ -730,6 +733,7 @@
712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */,
85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */,
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */,
+ 9AACF424992AE45FD7937064 /* GameStoreCompletionLockTests.swift in Sources */,
4D7BF839BA71E1BF0AE9BCE9 /* GameStoreContributingDevicesTests.swift in Sources */,
262A9CE8C3CB93869190CFF1 /* GameStoreMergedAuthorCellsTests.swift in Sources */,
2C5A15054CCCBF9FD626AFBB /* GameStorePushAddressTests.swift in Sources */,
diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift
@@ -40,6 +40,16 @@ final class GameMutator {
/// read-only banner.
var isAccessRevoked: Bool
+ /// Set to `true` once the game is completed (won or resigned). A completed
+ /// game is terminal and read-only: every mutating entry point below becomes
+ /// a no-op, so the grid can't be edited or "re-solved" after the fact. Set
+ /// at construction from `completedAt` and flipped live when completion
+ /// latches mid-session. Unlike `isAccessRevoked` (which only suppresses the
+ /// durable/sync emit) this also blocks the in-memory mutation, so no letter
+ /// even appears. The gate keys off this latched fact, not the live
+ /// `completionState`, so a grid that drifted after completion stays locked.
+ var isCompleted: Bool
+
init(
game: Game,
gameID: UUID,
@@ -50,7 +60,8 @@ final class GameMutator {
onLocalCellEditBatch: (@MainActor ([RealtimeCellEdit]) -> Void)? = nil,
isOwned: Bool = true,
isShared: Bool = false,
- isAccessRevoked: Bool = false
+ isAccessRevoked: Bool = false,
+ isCompleted: Bool = false
) {
self.game = game
self.gameID = gameID
@@ -62,17 +73,20 @@ final class GameMutator {
self.isOwned = isOwned
self.isShared = isShared
self.isAccessRevoked = isAccessRevoked
+ self.isCompleted = isCompleted
}
// MARK: - Single-cell mutations
func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool, direction: Puzzle.Direction? = nil) {
+ guard !isCompleted else { return }
let before = cellState(atRow: row, atCol: col)
game.setLetter(letter, atRow: row, atCol: col, pencil: pencil, authorID: authorIDProvider?())
emitMove(atRow: row, atCol: col, journalKind: kind(.input, ifChangedFrom: before, atRow: row, atCol: col), direction: direction)
}
func clearLetter(atRow row: Int, atCol col: Int, direction: Puzzle.Direction? = nil) {
+ guard !isCompleted else { return }
let before = cellState(atRow: row, atCol: col)
game.clearLetter(atRow: row, atCol: col)
emitMove(atRow: row, atCol: col, journalKind: kind(.input, ifChangedFrom: before, atRow: row, atCol: col), direction: direction)
@@ -103,6 +117,10 @@ final class GameMutator {
kind: JournalKind,
_ mutate: ([Puzzle.Cell]) -> Void
) {
+ // A completed game is read-only. `resignGame` reveals through a freshly
+ // loaded mutator whose `isCompleted` is still false (it sets
+ // `completedAt` only afterwards), so its reveal is unaffected.
+ guard !isCompleted else { return }
let applicable = cells.filter { !$0.isBlock }
guard !applicable.isEmpty else { return }
let before = applicable.map { cellState(atRow: $0.row, atCol: $0.col) }
@@ -135,12 +153,12 @@ final class GameMutator {
/// is cheap (a derivation pass over the in-memory journal) and drives the
/// enabled state of the undo/redo controls.
var canUndo: Bool {
- guard !isAccessRevoked, let movesJournal else { return false }
+ guard !isAccessRevoked, !isCompleted, let movesJournal else { return false }
return movesJournal.canUndo(gameID: gameID)
}
var canRedo: Bool {
- guard !isAccessRevoked, let movesJournal else { return false }
+ guard !isAccessRevoked, !isCompleted, let movesJournal else { return false }
return movesJournal.canRedo(gameID: gameID)
}
@@ -155,7 +173,7 @@ final class GameMutator {
/// `clear` (no single target) or when nothing was undone.
@discardableResult
func undo() -> CursorLanding? {
- guard !isAccessRevoked, let movesJournal else { return nil }
+ guard !isAccessRevoked, !isCompleted, let movesJournal else { return nil }
while let plan = movesJournal.planUndo(gameID: gameID) {
if applyRestores(plan.restores, kind: .undo) { return cursorTarget(for: plan) }
movesJournal.markUndoConsumed(stepID: plan.stepID, gameID: gameID)
@@ -166,7 +184,7 @@ final class GameMutator {
/// Re-applies the most recently undone move. Mirror of `undo()`.
@discardableResult
func redo() -> CursorLanding? {
- guard !isAccessRevoked, let movesJournal else { return nil }
+ guard !isAccessRevoked, !isCompleted, let movesJournal else { return nil }
while let plan = movesJournal.planRedo(gameID: gameID) {
if applyRestores(plan.restores, kind: .redo) { return cursorTarget(for: plan) }
movesJournal.markRedoConsumed(stepID: plan.stepID, gameID: gameID)
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -675,6 +675,11 @@ final class GameStore {
entity.completedBy = authorID
entity.hasPendingSave = true
try context.save()
+ // Lock the open session immediately so no further input lands on the
+ // now-terminal game (the view also reflects this via `isSolved`).
+ if currentEntity?.id == id {
+ currentMutator?.isCompleted = true
+ }
if let ckName = entity.ckRecordName {
onGameUpdated(ckName)
}
@@ -1295,6 +1300,23 @@ final class GameStore {
let values: [MovesValue] = movesEntities.compactMap { Self.movesValue(from: $0) }
let grid = GridStateMerger.merge(values)
+ // A completed game (won or resigned) is terminal; its grid is, by
+ // definition, the solution. The live merge can drift after completion
+ // — a late clear, clock skew, or an edit that reached a peer over
+ // engagement but never synced (see the realtime/durable decoupling) —
+ // which would otherwise leave a hole or a stray letter in a "finished"
+ // puzzle. Seal the display to the solution so a completed game always
+ // renders solved, independent of merge drift. Input is separately
+ // locked (`GameMutator.isCompleted`), so nothing re-opens the grid; the
+ // CellEntity cache still mirrors the raw merge.
+ if entity.completedAt != nil {
+ sealToSolution(game: game, mergedGrid: grid)
+ if updateCache {
+ updateCellCache(for: entity, from: grid)
+ }
+ return
+ }
+
// The local device's own row. Used to decide whether a buffered edit
// (flagged by `Square.enqueuedAt`) has landed durably yet: once the
// flush writes it, this row carries the cell with `updatedAt` equal
@@ -1337,6 +1359,50 @@ final class GameStore {
}
}
+ /// Populates `game.squares` from the puzzle solution for a completed
+ /// (terminal) game. A cell the merge already resolved to an accepted answer
+ /// keeps that entry, its author, and its mark; a hole or a stray
+ /// post-completion letter is overwritten with the canonical solution and a
+ /// clean mark. Cells with no known solution fall back to the merged value.
+ /// The result is always `.solved`, so the finish presentation is shown.
+ private func sealToSolution(game: Game, mergedGrid: GridState) {
+ for r in 0..<game.puzzle.height {
+ for c in 0..<game.puzzle.width {
+ let cell = game.puzzle.cells[r][c]
+ guard !cell.isBlock else { continue }
+ game.squares[r][c].enqueuedAt = nil
+ let merged = mergedGrid[GridPosition(row: r, col: c)]
+ if let merged, cell.accepts(merged.letter) {
+ // Correctly filled — preserve who filled it and its mark
+ // (a stale wrong-mark on a correct letter is contradictory,
+ // so force it off).
+ game.squares[r][c].entry = merged.letter
+ game.squares[r][c].mark = decodeMark(
+ kind: merged.markKind,
+ checkedRight: merged.checkedRight,
+ checkedWrong: false
+ )
+ game.squares[r][c].letterAuthorID = merged.authorID
+ } else if let solution = cell.solution {
+ // Hole or stray post-completion letter — seal to solution.
+ game.squares[r][c].entry = solution
+ game.squares[r][c].mark = .none
+ game.squares[r][c].letterAuthorID = merged?.authorID
+ } else if let merged {
+ // No known solution — show whatever the merge resolved.
+ game.squares[r][c].entry = merged.letter
+ game.squares[r][c].mark = decodeMark(
+ kind: merged.markKind,
+ checkedRight: merged.checkedRight,
+ checkedWrong: merged.checkedWrong
+ )
+ game.squares[r][c].letterAuthorID = merged.authorID
+ }
+ }
+ }
+ game.recomputeCompletionCache()
+ }
+
private func updateCellCache(for gameEntity: GameEntity, from grid: GridState) {
Self.applyCellCache(to: gameEntity, from: grid, in: context)
saveContext("updateCellCache")
@@ -1488,7 +1554,8 @@ final class GameStore {
},
isOwned: entity.databaseScope == 0,
isShared: entity.ckShareRecordName != nil || entity.databaseScope == 1,
- isAccessRevoked: entity.isAccessRevoked
+ isAccessRevoked: entity.isAccessRevoked,
+ isCompleted: entity.completedAt != nil
)
}
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -80,7 +80,10 @@ struct PuzzleView: View {
return TitleParts(title: title, subtitle: subtitle)
}
- private var isSolved: Bool { hasSolved }
+ // Latched completion counts as solved for the read-only presentation
+ // (hides the keyboard, shows the finish panel, disables the controls) even
+ // when the locally merged grid drifted and no longer reads `.solved`.
+ private var isSolved: Bool { hasSolved || session.mutator.isCompleted }
/// Whether a sticky, input-blocking announcement (currently only
/// access revocation) is showing for this game. Greys out the custom
diff --git a/Tests/Unit/GameMutatorTests.swift b/Tests/Unit/GameMutatorTests.swift
@@ -42,6 +42,35 @@ struct GameMutatorTests {
#expect(game.squares[0][0].letterAuthorID == nil)
}
+ @Test("A completed mutator rejects every mutation")
+ func completedMutatorIsReadOnly() throws {
+ let (game, _, _, persistence) = try makeTestGame()
+ let mutator = GameMutator(
+ game: game,
+ gameID: UUID(),
+ movesUpdater: nil,
+ movesJournal: MovesJournal(persistence: persistence),
+ isCompleted: true
+ )
+
+ // Single-cell input is a no-op.
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
+ #expect(game.squares[0][0].entry == "")
+
+ // Bulk help/clear gestures are no-ops too.
+ mutator.revealCells([game.puzzle.cells[0][0]])
+ #expect(game.squares[0][0].entry == "")
+ mutator.checkCells([game.puzzle.cells[0][2]])
+ mutator.clearCells([game.puzzle.cells[0][2]])
+ #expect(game.squares[0][2].entry == "")
+
+ // Undo/redo are disabled and inert.
+ #expect(mutator.canUndo == false)
+ #expect(mutator.canRedo == false)
+ #expect(mutator.undo() == nil)
+ #expect(mutator.redo() == nil)
+ }
+
// MARK: - Bulk mutations
@Test("checkCells marks wrong entries via mutator")
diff --git a/Tests/Unit/GameStoreCompletionLockTests.swift b/Tests/Unit/GameStoreCompletionLockTests.swift
@@ -0,0 +1,147 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+/// Completion makes a game terminal. Opening it seals the displayed grid to the
+/// puzzle solution — so post-completion merge drift (a late clear, clock skew,
+/// or an edit that reached a peer over engagement but never synced) can't leave
+/// a hole or stray letter in a "finished" puzzle — and the returned mutator is
+/// locked against further input.
+@Suite("GameStore completion lock", .isolatedNotificationState)
+@MainActor
+struct GameStoreCompletionLockTests {
+
+ private static let authorID = "alice"
+ private static let puzzleSource = """
+ Title: Test Puzzle
+ Author: Test
+
+
+ AB
+ CD
+
+
+ A1. Across 1 ~ AB
+ A3. Across 3 ~ CD
+ D1. Down 1 ~ AC
+ D2. Down 2 ~ BD
+ """
+
+ private func makeGame(
+ completed: Bool,
+ in ctx: NSManagedObjectContext
+ ) throws -> (GameEntity, UUID) {
+ let xd = try XD.parse(Self.puzzleSource)
+ let puzzle = Puzzle(xd: xd)
+ let gameID = UUID()
+ let entity = GameEntity(context: ctx)
+ entity.id = gameID
+ entity.title = "Test"
+ entity.puzzleSource = Self.puzzleSource
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ if completed { entity.completedAt = Date() }
+ entity.ckRecordName = "game-\(gameID.uuidString)"
+ entity.populateCachedSummaryFields(from: puzzle)
+ try ctx.save()
+ return (entity, gameID)
+ }
+
+ /// Writes a single local-device Moves row carrying `cells` (position → letter).
+ private func writeMoves(
+ for entity: GameEntity,
+ gameID: UUID,
+ cells: [GridPosition: String],
+ in ctx: NSManagedObjectContext
+ ) throws {
+ let row = MovesEntity(context: ctx)
+ row.game = entity
+ row.authorID = Self.authorID
+ row.deviceID = "device-a"
+ row.ckRecordName = RecordSerializer.recordName(
+ forMovesInGame: gameID,
+ authorID: Self.authorID,
+ deviceID: "device-a"
+ )
+ let now = Date()
+ var encoded: [GridPosition: TimestampedCell] = [:]
+ for (pos, letter) in cells {
+ encoded[pos] = TimestampedCell(
+ letter: letter, markKind: 0, checkedRight: false,
+ checkedWrong: false, updatedAt: now, authorID: Self.authorID
+ )
+ }
+ row.cells = try MovesCodec.encode(encoded)
+ row.updatedAt = now
+ try ctx.save()
+ }
+
+ @Test("A completed game seals a hole and a stray wrong letter to the solution")
+ func sealsToSolution() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let ctx = persistence.viewContext
+ let (entity, gameID) = try makeGame(completed: true, in: ctx)
+
+ // (0,0) correct, (0,1) a stray wrong letter, (1,0) correct, (1,1) a
+ // hole — the post-completion drift the seal has to repair.
+ try writeMoves(
+ for: entity, gameID: gameID,
+ cells: [
+ GridPosition(row: 0, col: 0): "A",
+ GridPosition(row: 0, col: 1): "Z",
+ GridPosition(row: 1, col: 0): "C"
+ ],
+ in: ctx
+ )
+
+ let (game, _) = try store.loadGame(id: gameID)
+
+ #expect(game.squares[0][0].entry == "A") // correct — kept
+ #expect(game.squares[0][1].entry == "B") // stray — overwritten
+ #expect(game.squares[1][0].entry == "C") // correct — kept
+ #expect(game.squares[1][1].entry == "D") // hole — filled
+ #expect(game.completionState == .solved)
+ }
+
+ @Test("Loading a completed game returns a locked mutator that rejects input")
+ func loadedMutatorIsLocked() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let ctx = persistence.viewContext
+ let (entity, gameID) = try makeGame(completed: true, in: ctx)
+ try writeMoves(
+ for: entity, gameID: gameID,
+ cells: [GridPosition(row: 0, col: 0): "A"],
+ in: ctx
+ )
+
+ let (game, mutator) = try store.loadGame(id: gameID)
+ #expect(mutator.isCompleted)
+
+ mutator.setLetter("Z", atRow: 1, atCol: 1, pencil: false)
+ #expect(game.squares[1][1].entry == "D") // seal stands; input rejected
+ }
+
+ @Test("An incomplete game is not sealed — an untouched hole stays empty")
+ func incompleteGameNotSealed() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let ctx = persistence.viewContext
+ let (entity, gameID) = try makeGame(completed: false, in: ctx)
+ try writeMoves(
+ for: entity, gameID: gameID,
+ cells: [GridPosition(row: 0, col: 0): "A"],
+ in: ctx
+ )
+
+ let (game, mutator) = try store.loadGame(id: gameID)
+
+ #expect(game.squares[0][0].entry == "A")
+ #expect(game.squares[1][1].entry == "") // untouched hole stays empty
+ #expect(mutator.isCompleted == false)
+ #expect(game.completionState != .solved)
+ }
+}