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