crossmate

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

commit 2d34ca07102fba746333386ad2a76eb94095fb0d
parent a36745c9b42abd96d15ce219e0f22f1013420c87
Author: Michael Camilleri <[email protected]>
Date:   Tue,  2 Jun 2026 14:02:15 +0900

Keep a completed puzzle's authorship correct

Completion is a one-shot latch on the Game record; the grid is a
per-cell wall-clock LWW merge of every Moves record, sealed to the
solution once completedAt is set (GameStore.restore -> sealToSolution).
Prior to this commit, the seal ran against the unbounded merge, so a
collaborator's winning letter that hadn't yet reached this device's
durable merge left the cell empty at seal time: sealToSolution then
filled it from the puzzle solution with letterAuthorID =
merged?.authorID (i.e. nil). The finished puzzle showed its final square
untinted, and a phantom colourless 'Player' held one letter in the
Success Panel scoreboard — even though the letter on screen was correct.

This commit makes two changes:

1. GridStateMerger.merge gains a notAfter cutoff, and restore now seals
   a completed game against merge(values, notAfter: completedAt). A
   letter typed before the win still merges in — carrying its author —
   whenever it arrives, but any edit stamped after the latch is ignored,
   so nothing can re-open or rewrite a finished puzzle. Once the winning
   edit lands durably it re-attributes the square to its real author
   instead of leaving an authorless seal. The CellEntity cache still
   mirrors the raw merge.

2. Both scoreboards (SuccessPanel, PuzzleView) drop a nil-author cell
   from the tally instead of bucketing it as 'Player'/'Unattributed'. A
   nil author key only arises with remote players present
   (normalizedAuthorID), so it is always an authorless sealed square
   that belongs to no one — not a participant worth a row.

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

Diffstat:
MCrossmate/Persistence/GameStore.swift | 23+++++++++++++----------
MCrossmate/Sync/GridStateMerger.swift | 12+++++++++---
MCrossmate/Views/PuzzleView.swift | 7++++++-
MCrossmate/Views/SuccessPanel.swift | 8++++++++
MTests/Unit/GridStateMergerTests.swift | 42++++++++++++++++++++++++++++++++++++++++++
5 files changed, 78 insertions(+), 14 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -1377,16 +1377,19 @@ final class GameStore { 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) + // definition, the solution. The merge is watermarked at `completedAt`: + // a collaborator's letter typed just before the win still merges in + // when it reaches us afterward (carrying its author), but anything + // stamped after the latch is ignored — nothing re-opens or rewrites a + // finished puzzle. Whatever's still empty at the cutoff is sealed to + // the solution so a completed game always renders solved, independent + // of merge drift (a late clear, an edit that reached a peer over + // engagement but never synced — see the realtime/durable decoupling). + // Input is separately locked (`GameMutator.isCompleted`); the + // CellEntity cache still mirrors the raw (un-watermarked) merge. + if let completedAt = entity.completedAt { + let sealedGrid = GridStateMerger.merge(values, notAfter: completedAt) + sealToSolution(game: game, mergedGrid: sealedGrid) if updateCache { updateCellCache(for: entity, from: grid) } diff --git a/Crossmate/Sync/GridStateMerger.swift b/Crossmate/Sync/GridStateMerger.swift @@ -9,9 +9,14 @@ import Foundation /// same-letter rewrites can hand off authorship without losing it. enum GridStateMerger { - static func merge(_ moves: [MovesValue]) -> GridState { + /// `notAfter` caps which writes are eligible: cells stamped later than the + /// cutoff are ignored. Used to freeze a completed game at its winning + /// instant — edits that predate the latch still merge in (a collaborator's + /// letter typed just before the win that reaches us afterward), but nothing + /// stamped after completion can reopen or rewrite the finished grid. + static func merge(_ moves: [MovesValue], notAfter cutoff: Date? = nil) -> GridState { var grid: GridState = [:] - for (position, winner) in winners(moves) { + for (position, winner) in winners(moves, notAfter: cutoff) { grid[position] = GridCell( letter: winner.cell.letter, markKind: winner.cell.markKind, @@ -44,10 +49,11 @@ enum GridStateMerger { var writerAuthorID: String } - private static func winners(_ moves: [MovesValue]) -> [GridPosition: Winner] { + private static func winners(_ moves: [MovesValue], notAfter cutoff: Date? = nil) -> [GridPosition: Winner] { var winners: [GridPosition: Winner] = [:] for view in moves { for (position, cell) in view.cells { + if let cutoff, cell.updatedAt > cutoff { continue } let candidate = Winner( cell: cell, writerAuthorID: view.authorID, diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -668,7 +668,12 @@ private struct PuzzleScoreboard: View { return Score(authorID: authorID, name: entry.name, color: entry.color, filledCount: count) } if authorID == nil { - return Score(authorID: nil, name: "Unattributed", color: nil, filledCount: count) + // A `nil` author key only arises with remote players present + // (see `normalizedAuthorID`): an authorless square, e.g. a cell + // sealed to the solution at completion before its author's + // letter arrived. It belongs to no player, so drop it rather + // than tallying an "Unattributed" entry. + return nil } return Score(authorID: authorID, name: "Player", color: nil, filledCount: count) } diff --git a/Crossmate/Views/SuccessPanel.swift b/Crossmate/Views/SuccessPanel.swift @@ -98,6 +98,14 @@ struct SuccessPanel: View { if authorID == nil && !hasRemotePlayers { return Contribution(authorID: nil, name: preferences.name, color: preferences.color, count: count) } + if authorID == nil { + // A `nil` author key only arises with remote players present + // (see `normalizedAuthorID`): an authorless square, e.g. a cell + // sealed to the solution at completion before its author's + // letter arrived. It belongs to no player, so drop it rather + // than surfacing a phantom "Player". + return nil + } return Contribution(authorID: authorID, name: "Player", color: nil, count: count) } diff --git a/Tests/Unit/GridStateMergerTests.swift b/Tests/Unit/GridStateMergerTests.swift @@ -174,6 +174,48 @@ struct GridStateMergerTests { #expect(cell?.letter == "") #expect(cell != nil) } + + @Test("notAfter keeps the latest write at or before the cutoff") + func cutoffKeepsPreLatchWinner() { + let cutoff = Date(timeIntervalSince1970: 100) + let preLatch = view( + author: "alice", + device: "d1", + cells: [(0, 0, "A", Date(timeIntervalSince1970: 100))] + ) + let postLatch = view( + author: "bob", + device: "d2", + cells: [(0, 0, "B", Date(timeIntervalSince1970: 101))] + ) + let grid = GridStateMerger.merge([preLatch, postLatch], notAfter: cutoff) + // Bob's later write would win an unbounded LWW, but it is stamped + // after the cutoff, so Alice's at-cutoff write survives. + #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "A") + #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "alice") + } + + @Test("notAfter drops a cell whose only write is after the cutoff") + func cutoffDropsPostLatchOnlyCell() { + let cutoff = Date(timeIntervalSince1970: 100) + let postLatch = view( + author: "bob", + device: "d2", + cells: [(3, 4, "Z", Date(timeIntervalSince1970: 200))] + ) + let grid = GridStateMerger.merge([postLatch], notAfter: cutoff) + #expect(grid[GridPosition(row: 3, col: 4)] == nil) + } + + @Test("A nil cutoff merges every write") + func nilCutoffIsUnbounded() { + let v = view( + author: "alice", + device: "d1", + cells: [(0, 0, "A", Date(timeIntervalSince1970: 9_999))] + ) + #expect(GridStateMerger.merge([v], notAfter: nil) == GridStateMerger.merge([v])) + } } @Suite("MovesCodec round-trip")