crossmate

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

commit 1bac07b8b12cdb3019a1914418944a54b5768d73
parent 5b8c9d9ec1fbf4a5299c023e769d85503d345128
Author: Michael Camilleri <[email protected]>
Date:   Sun, 28 Jun 2026 22:46:27 +0900

Ignore empty check cells in recent changes

Checking a puzzle could make empty cells look like peer changes when the
user next opened the game. The check path skipped empty cells in the live
grid, but GameMutator still emitted a move for every non-block target, so
Check Puzzle uploaded fresh empty cells that the peer-change ledger could
later read as clears.

This commit limits check and clear bulk emissions to cells whose state
actually changed, while keeping reveal's existing already-correct emission
behaviour. It also makes PeerChangeLedger ignore first-seen empty cells so
old or malformed empty move entries cannot seed highlightable clears.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Persistence/GameMutator.swift | 4+++-
MCrossmate/Sync/PeerChangeLedger.swift | 1+
MTests/Unit/GameMutatorTests.swift | 22++++++++++++++++++++++
MTests/Unit/PeerChangeLedgerTests.swift | 10+++++++++-
4 files changed, 35 insertions(+), 2 deletions(-)

diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift @@ -144,11 +144,13 @@ final class GameMutator { let batch = UUID() collectingBroadcast { for (cell, priorState) in zip(applicable, before) { + let journalKind = self.kind(kind, ifChangedFrom: priorState, atRow: cell.row, atCol: cell.col) + if journalKind == nil, kind != .reveal { continue } emitMove( atRow: cell.row, atCol: cell.col, beforeState: priorState, - journalKind: self.kind(kind, ifChangedFrom: priorState, atRow: cell.row, atCol: cell.col), + journalKind: journalKind, batchID: batch ) } diff --git a/Crossmate/Sync/PeerChangeLedger.swift b/Crossmate/Sync/PeerChangeLedger.swift @@ -46,6 +46,7 @@ enum PeerChangeLedger { for (position, provenance) in current { let letter = provenance.cell.letter if let existing = recorded[position], existing.letter == letter { continue } + if recorded[position] == nil, letter.isEmpty { continue } let writer = provenance.cell.authorID ?? provenance.writerAuthorID result.append( PeerChange( diff --git a/Tests/Unit/GameMutatorTests.swift b/Tests/Unit/GameMutatorTests.swift @@ -106,6 +106,28 @@ struct GameMutatorTests { #expect(game.squares[0][0].mark == .pencil(checked: .wrong)) } + @Test("checking an empty puzzle emits no empty cell moves") + func checkingEmptyPuzzleEmitsNoEmptyCellMoves() async throws { + let (game, capture, updater, persistence) = try makeMutatorWithUpdater(actingAuthorID: "bob") + let mutator = makeMutator( + game: game, + updater: updater, + gameID: capture.gameID, + actingAuthorID: "bob" + ) + + mutator.checkCells(game.puzzle.cells.flatMap { $0 }) + await updater.flush() + + #expect(await capture.collector.allGameIDs.isEmpty) + #expect(try cellFromMoves( + persistence: persistence, + gameID: capture.gameID, + row: 0, + col: 0 + ) == nil) + } + @Test("revealCells sets entry to solution and marks revealed") func revealCellsSetsAnswer() throws { let (game, mutator, _, _) = try makeTestGame() diff --git a/Tests/Unit/PeerChangeLedgerTests.swift b/Tests/Unit/PeerChangeLedgerTests.swift @@ -33,17 +33,25 @@ struct PeerChangeLedgerTests { private let p2 = GridPosition(row: 0, col: 1) private let p3 = GridPosition(row: 1, col: 0) - @Test("A first build seeds every current cell at .distantPast") + @Test("A first build seeds every non-empty current cell at .distantPast") func seedingRecordsDistantPast() { let current = [ p1: prov("A", author: "bob", writer: "bob", at: t(10)), p2: prov("B", author: "bob", writer: "bob", at: t(20)), + p3: prov("", author: nil, writer: "bob", at: t(30)), ] let upserts = PeerChangeLedger.upserts(current: current, recorded: [:], seeding: true) #expect(upserts.count == 2) #expect(upserts.allSatisfy { $0.changedAt == .distantPast }) } + @Test("A first-seen empty cell records nothing") + func firstSeenEmptyCellIgnored() { + let current = [p1: prov("", author: nil, writer: "bob", at: t(40))] + let upserts = PeerChangeLedger.upserts(current: current, recorded: [:], seeding: false) + #expect(upserts.isEmpty) + } + @Test("After the seed, a genuine letter change records the move's real timestamp") func letterChangeRecordsRealTimestamp() { let recorded = [p1: PeerChange(position: p1, letter: "A", authorID: "bob", changedAt: .distantPast)]