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