crossmate

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

commit 9f341acb78b1cf9f03644e39fb8569c99cd2184a
parent 688f4c0b03dce53a7150fef51f1ee994cd7f15d6
Author: Michael Camilleri <[email protected]>
Date:   Mon, 15 Jun 2026 11:39:15 +0900

Stop counting checked peer letters as session-summary fills

A collaborator who opened a mostly-filled puzzle was reported as having
filled the whole grid: a returning or just-joined user could see 'Alice
filled 100 letters' when Alice had only run a check over letters the
user themselves had entered earlier. The count was an artefact of the
check, not work Alice did.

A check carries the cell's letter — so that a pencilled entry inks when
checked — but preserves the letter's original author in cellAuthorID,
separate from who performed the gesture. Both session-summary surfaces
ignored that distinction and treated any letter present in an author's
journal or Moves row as theirs: the sender-side pause push
(SessionPushPlanner.tally) and the in-app catch-up banner
(GameStore.movesSnapshot). Checking a peer's letters therefore wrote
those letters into the checker's own records and was then read back as
the checker filling them.

This commit attributes a fill to an author only when the winning
letter's 'cellAuthorID' is that author, in both places. Checking a peer's
letter no longer counts as the checker's fill, while checking one's own
letter still does, since its author is unchanged — so the fix neither
over- nor under-counts. Letter authorship already survives checks and
same-letter rewrites, so the rule needs no new state.

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

Diffstat:
MCrossmate/Persistence/GameStore.swift | 13++++++++++++-
MCrossmate/Services/SessionCoordinator.swift | 1+
MCrossmate/Services/SessionPushPlanner.swift | 25+++++++++++++++++++------
MTests/Unit/SessionPushPlannerTests.swift | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
4 files changed, 120 insertions(+), 28 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -1201,6 +1201,11 @@ final class GameStore { /// author's cells would flip ownership and a CellEntity-based diff would /// falsely count a "clear" the author never made. The Moves row is the /// authoritative log of what each device wrote. + /// + /// `filled` is restricted to cells whose preserved per-cell `authorID` is + /// `authorID`: a check writes a peer's letter into the checker's row, so a + /// row that merely holds someone else's checked letter must not count as + /// this author's fill. func movesSnapshot( for gameID: UUID, by authorID: String, @@ -1233,7 +1238,13 @@ final class GameStore { for (position, cell) in grid { if cell.letter.isEmpty { cleared.insert(position) - } else { + } else if cell.authorID == authorID { + // A check carries the cell's letter into the checker's Moves + // row but preserves the letter's original `authorID`. Attribute + // a fill only when the winning letter is this author's, so + // checking a peer's letter isn't miscounted as this author + // filling it. A non-empty cell authored by someone else is + // neither this author's fill nor their clear. filled.insert(position) } } diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift @@ -334,6 +334,7 @@ final class SessionCoordinator { let addressees = SessionPushPlanner.sessionEndAddressees( recipients: recipients, journalEntries: journalEntries, + selfAuthorID: localAuthorID, playerName: preferences.name, puzzleTitle: plan.title, diagnostics: diagnostics diff --git a/Crossmate/Services/SessionPushPlanner.swift b/Crossmate/Services/SessionPushPlanner.swift @@ -72,6 +72,7 @@ enum SessionPushPlanner { static func sessionEndAddressees( recipients: [PushRecipient], journalEntries: [JournalValue], + selfAuthorID: String?, playerName: String, puzzleTitle: String, diagnostics: PushPayload.Diagnostics? = nil @@ -98,7 +99,11 @@ enum SessionPushPlanner { let cutoff = [recipient.readThrough, recipient.notifiedThrough] .compactMap { $0 } .max() - let counts = tally(history: history, since: cutoff ?? .distantPast) + let counts = tally( + history: history, + since: cutoff ?? .distantPast, + selfAuthorID: selfAuthorID + ) let body = PuzzleNotificationText.pauseBody( playerName: playerName, puzzleTitle: puzzleTitle, @@ -140,11 +145,17 @@ enum SessionPushPlanner { /// history. Letter `fills` / `clears` are net-per-cell (the cell's final /// letter vs. the letter the recipient last saw at `cutoff`); `checks` / /// `reveals` are gesture counts (distinct batches). A reveal-touched cell is - /// attributed to `reveals`, never the letter counts; a check changes only - /// marks, so it can't shift a letter count. + /// attributed to `reveals`, never the letter counts. + /// + /// A check carries the cell's letter (so a checked pencil entry inks) but + /// preserves the letter's *original* author in `cellAuthorID`. So a fill is + /// only this author's when the winning letter's `cellAuthorID` is + /// `selfAuthorID`: checking a peer's letter mustn't read as this author + /// filling it, while checking one's own letter still counts. private static func tally( history: [GridPosition: [JournalValue]], - since cutoff: Date + since cutoff: Date, + selfAuthorID: String? ) -> SessionCounts { var counts = SessionCounts() var checkBatches: Set<String> = [] @@ -169,8 +180,10 @@ enum SessionPushPlanner { } let before = entries.last(where: { $0.timestamp <= cutoff })?.state.letter ?? "" - let after = entries.last?.state.letter ?? "" - if !after.isEmpty, after != before { + let afterEntry = entries.last + let after = afterEntry?.state.letter ?? "" + let authoredBySelf = afterEntry?.state.cellAuthorID == selfAuthorID + if !after.isEmpty, after != before, authoredBySelf { counts.fills += 1 } else if after.isEmpty, !before.isEmpty { counts.clears += 1 diff --git a/Tests/Unit/SessionPushPlannerTests.swift b/Tests/Unit/SessionPushPlannerTests.swift @@ -14,13 +14,14 @@ struct SessionPushPlannerTests { row: Int = 0, col: Int = 0, kind: JournalKind = .input, - batch: UUID? = nil + batch: UUID? = nil, + cellAuthorID: String = "author" ) -> JournalValue { JournalValue( seq: seq, timestamp: timestamp, position: GridPosition(row: row, col: col), - state: JournalCellState(letter: letter, mark: .none, cellAuthorID: "author"), + state: JournalCellState(letter: letter, mark: .none, cellAuthorID: cellAuthorID), actingAuthorID: "author", kind: kind, targetSeq: nil, @@ -57,7 +58,8 @@ struct SessionPushPlannerTests { let addressees = SessionPushPlanner.sessionEndAddressees( recipients: [seenEverything], journalEntries: entries, - playerName: "Bunny", + selfAuthorID: "author", + playerName: "Alice", puzzleTitle: "Tuesday" ) @@ -65,7 +67,7 @@ struct SessionPushPlannerTests { #expect(addressees[0].payload == PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 0))) #expect(addressees[0].payload?.marksUnread == false) - #expect(addressees[0].body == "Bunny stopped solving the puzzle 'Tuesday'.") + #expect(addressees[0].body == "Alice stopped solving the puzzle 'Tuesday'.") } @Test("A behind recipient gets net fills/clears and a badge-marking payload") @@ -83,7 +85,8 @@ struct SessionPushPlannerTests { let addressees = SessionPushPlanner.sessionEndAddressees( recipients: [behind], journalEntries: entries, - playerName: "Bunny", + selfAuthorID: "author", + playerName: "Alice", puzzleTitle: "Tuesday" ) @@ -92,7 +95,7 @@ struct SessionPushPlannerTests { == PushPayload(event: .pause(fills: 1, clears: 1, checks: 0, reveals: 0))) #expect(addressees[0].payload?.marksUnread == true) #expect(addressees[0].body - == "Bunny filled 1 letter and cleared 1 letter in the puzzle 'Tuesday'") + == "Alice filled 1 letter and cleared 1 letter in the puzzle 'Tuesday'") } @Test("A move already notified isn't re-counted, even if the recipient never read it") @@ -109,7 +112,8 @@ struct SessionPushPlannerTests { let firstPause = SessionPushPlanner.sessionEndAddressees( recipients: [recipient("addr", readThrough: staleReadAt)], journalEntries: entries, - playerName: "Bunny", + selfAuthorID: "author", + playerName: "Alice", puzzleTitle: "Tuesday" ) #expect(firstPause[0].payload @@ -120,13 +124,14 @@ struct SessionPushPlannerTests { let secondPause = SessionPushPlanner.sessionEndAddressees( recipients: [recipient("addr", readThrough: staleReadAt, notifiedThrough: edit)], journalEntries: entries, - playerName: "Bunny", + selfAuthorID: "author", + playerName: "Alice", puzzleTitle: "Tuesday" ) #expect(secondPause.count == 1) #expect(secondPause[0].payload == PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 0))) - #expect(secondPause[0].body == "Bunny stopped solving the puzzle 'Tuesday'.") + #expect(secondPause[0].body == "Alice stopped solving the puzzle 'Tuesday'.") } @Test("A present-but-backgrounded recipient still gets a summary (lease ≠ watermark)") @@ -145,14 +150,15 @@ struct SessionPushPlannerTests { let addressees = SessionPushPlanner.sessionEndAddressees( recipients: [recipient("addr", readThrough: watermarkBeforeEdit)], journalEntries: entries, - playerName: "Bunny", + selfAuthorID: "author", + playerName: "Alice", puzzleTitle: "Tuesday" ) #expect(addressees.count == 1) #expect(addressees[0].payload == PushPayload(event: .pause(fills: 1, clears: 0, checks: 0, reveals: 0))) - #expect(addressees[0].body == "Bunny filled 1 letter in the puzzle 'Tuesday'") + #expect(addressees[0].body == "Alice filled 1 letter in the puzzle 'Tuesday'") } @Test("A whole-grid check reports one check gesture, not a wall of letter churn") @@ -172,13 +178,68 @@ struct SessionPushPlannerTests { let addressees = SessionPushPlanner.sessionEndAddressees( recipients: [behind], journalEntries: entries, - playerName: "Bunny", + selfAuthorID: "author", + playerName: "Alice", puzzleTitle: "Tuesday" ) #expect(addressees[0].payload == PushPayload(event: .pause(fills: 0, clears: 0, checks: 1, reveals: 0))) - #expect(addressees[0].body == "Bunny ran 1 check in the puzzle 'Tuesday'") + #expect(addressees[0].body == "Alice ran 1 check in the puzzle 'Tuesday'") + } + + @Test("Checking a peer's letter is a check gesture, not a fill by the checker") + func checkingPeerLetterIsNotAFill() { + // Regression: a peer filled these cells; in *this* author's journal they + // appear only as check entries that carry the peer's letter but preserve + // the peer's `cellAuthorID`. The whole-grid check of an already-filled + // puzzle must read as one check gesture, not a wall of phantom fills. + let edit = Date(timeIntervalSince1970: 1_000) + let batch = UUID() + let entries = [ + entry("P", at: edit, seq: 1, col: 0, kind: .check, batch: batch, cellAuthorID: "peer"), + entry("Q", at: edit, seq: 2, col: 1, kind: .check, batch: batch, cellAuthorID: "peer") + ] + let behind = recipient("addr-peercheck", readThrough: edit.addingTimeInterval(-60)) + + let addressees = SessionPushPlanner.sessionEndAddressees( + recipients: [behind], + journalEntries: entries, + selfAuthorID: "author", + playerName: "Alice", + puzzleTitle: "Tuesday" + ) + + #expect(addressees[0].payload + == PushPayload(event: .pause(fills: 0, clears: 0, checks: 1, reveals: 0))) + #expect(addressees[0].body == "Alice ran 1 check in the puzzle 'Tuesday'") + } + + @Test("Filling then checking one's own cell still counts as a fill") + func checkingOwnFillStillCounts() { + // The flip side of the peer-check fix: a cell this author filled in the + // window and then checked (inking a pencil entry) keeps `cellAuthorID` + // as theirs, so it must not be dropped from the fill count. + let edit = Date(timeIntervalSince1970: 1_000) + let batch = UUID() + let entries = [ + entry("A", at: edit, seq: 1, col: 0), + entry("A", at: edit.addingTimeInterval(1), seq: 2, col: 0, kind: .check, batch: batch) + ] + let behind = recipient("addr-owncheck", readThrough: edit.addingTimeInterval(-60)) + + let addressees = SessionPushPlanner.sessionEndAddressees( + recipients: [behind], + journalEntries: entries, + selfAuthorID: "author", + playerName: "Alice", + puzzleTitle: "Tuesday" + ) + + #expect(addressees[0].payload + == PushPayload(event: .pause(fills: 1, clears: 0, checks: 1, reveals: 0))) + #expect(addressees[0].body + == "Alice filled 1 letter and ran 1 check in the puzzle 'Tuesday'") } @Test("A reveal is attributed to reveals, never counted as a fill") @@ -194,13 +255,14 @@ struct SessionPushPlannerTests { let addressees = SessionPushPlanner.sessionEndAddressees( recipients: [behind], journalEntries: entries, - playerName: "Bunny", + selfAuthorID: "author", + playerName: "Alice", puzzleTitle: "Tuesday" ) #expect(addressees[0].payload == PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 1))) - #expect(addressees[0].body == "Bunny ran 1 reveal in the puzzle 'Tuesday'") + #expect(addressees[0].body == "Alice ran 1 reveal in the puzzle 'Tuesday'") } @Test("All four tallies combine into one sentence") @@ -226,14 +288,15 @@ struct SessionPushPlannerTests { let addressees = SessionPushPlanner.sessionEndAddressees( recipients: [behind], journalEntries: entries, - playerName: "Bunny", + selfAuthorID: "author", + playerName: "Alice", puzzleTitle: "Tuesday" ) #expect(addressees[0].payload == PushPayload(event: .pause(fills: 1, clears: 1, checks: 1, reveals: 1))) #expect(addressees[0].body - == "Bunny filled 1 letter, cleared 1 letter and ran 1 check and 1 reveal in the puzzle 'Tuesday'") + == "Alice filled 1 letter, cleared 1 letter and ran 1 check and 1 reveal in the puzzle 'Tuesday'") } @Test("A cell typed then deleted within the window nets to nothing") @@ -248,7 +311,8 @@ struct SessionPushPlannerTests { let addressees = SessionPushPlanner.sessionEndAddressees( recipients: [behind], journalEntries: entries, - playerName: "Bunny", + selfAuthorID: "author", + playerName: "Alice", puzzleTitle: "Tuesday" ) @@ -273,7 +337,8 @@ struct SessionPushPlannerTests { let addressees = SessionPushPlanner.sessionEndAddressees( recipients: [recipientA, recipientB], journalEntries: entries, - playerName: "Bunny", + selfAuthorID: "author", + playerName: "Alice", puzzleTitle: "Tuesday", diagnostics: base ) @@ -296,7 +361,8 @@ struct SessionPushPlannerTests { let addressees = SessionPushPlanner.sessionEndAddressees( recipients: [recipient(nil, readThrough: nil)], journalEntries: [entry("X", at: edit, seq: 1, col: 0)], - playerName: "Bunny", + selfAuthorID: "author", + playerName: "Alice", puzzleTitle: "Tuesday" ) @@ -333,7 +399,8 @@ struct SessionPushPlannerTests { let addressees = SessionPushPlanner.sessionEndAddressees( recipients: [caughtUp, behind], journalEntries: entries, - playerName: "Bunny", + selfAuthorID: "author", + playerName: "Alice", puzzleTitle: "Tuesday" )