commit 7f4f7af5371b706d291687e9b55d845c01f872af
parent e23dfeb33591059f139f4c566ebd497ebafde29f
Author: Michael Camilleri <[email protected]>
Date: Tue, 9 Jun 2026 19:38:58 +0900
Window session-end pushes on a per-peer notified-through mark
A pause push tallies what each recipient has not seen using only their
synced readAt. That cursor never advances while the recipient stays
backgrounded, so two pauses with no authored move between them re-tally
the same totals and ship identical bodies.
Track, per peer, the latest authored move we have already notified them
about (PlayerEntity.notifiedThrough) and window the tally on the later
of that and readAt. A session that adds no new move now tallies to zero
and reads as "stopped solving" rather than repeating the previous
summary. The push is not suppressed: a play is always sent before a
pause, so an empty pause is genuine presence, not a duplicate.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
5 files changed, 119 insertions(+), 10 deletions(-)
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -43,6 +43,7 @@
<attribute name="ckSystemFields" optional="YES" attributeType="Binary"/>
<attribute name="lastMovesSnapshotData" optional="YES" attributeType="Binary"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
+ <attribute name="notifiedThrough" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="pushAddress" optional="YES" attributeType="String"/>
<attribute name="readAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="selCol" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -1329,6 +1329,37 @@ final class GameStore {
fetchPlayerEntity(gameID: gameID, authorID: authorID)?.updatedAt
}
+ /// Sender-local "notified through" watermark for `(gameID, authorID)` —
+ /// the latest authored move we've told this recipient about via a pause,
+ /// or `nil` if we never have. Paired with `Player.readAt` to window the
+ /// next session-end diff (see `SessionPushPlanner.sessionEndAddressees`).
+ func notifiedThrough(for gameID: UUID, by authorID: String) -> Date? {
+ fetchPlayerEntity(gameID: gameID, authorID: authorID)?.notifiedThrough
+ }
+
+ /// Advances the notified-through watermark for each peer in `authorIDs` to
+ /// `through`, the latest move the pause we just sent them covered. Purely
+ /// local bookkeeping: it stamps no `updatedAt` and enqueues no push, so it
+ /// never rides a CloudKit Player record — `RecordSerializer.playerRecord`
+ /// deliberately omits the field. Monotonic; a backward `through` (clock
+ /// wobble) is ignored. Rows are expected to exist already, since we only
+ /// notify recipients read out of `pushPlan`.
+ func recordNotified(gameID: UUID, authorIDs: [String], through: Date) {
+ guard !authorIDs.isEmpty else { return }
+ var didChange = false
+ for authorID in authorIDs {
+ guard let player = fetchPlayerEntity(gameID: gameID, authorID: authorID) else {
+ continue
+ }
+ if let current = player.notifiedThrough, current >= through { continue }
+ player.notifiedThrough = through
+ didChange = true
+ }
+ if didChange {
+ saveContext("recordNotified")
+ }
+ }
+
/// Merged-across-devices author cells for `(gameID, authorID)`. Returns
/// each touched grid position's winning `TimestampedCell` after the
/// usual LWW merge across the author's devices, including cleared cells
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -1256,6 +1256,18 @@ final class AppServices {
// Peers have now been told the session ended, so a fresh "play" is
// allowed again (see `publishSessionBeginPush`).
sessionAnnouncements.noteEndAnnounced(gameID)
+ // Advance each addressed recipient's notified-through watermark to the
+ // latest move this pause reported. A later pause windows its counts to
+ // the later of this and the recipient's readAt, so a bounce that adds
+ // no new move re-tallies to zero ("stopped solving") instead of
+ // repeating the same summary. Recipients we couldn't address (no push
+ // capability) keep their old watermark and catch up when reachable.
+ if let notifiedThrough = journalEntries.map(\.timestamp).max() {
+ let addressed = plan.recipients
+ .filter { $0.pushAddress != nil }
+ .map(\.authorID)
+ store.recordNotified(gameID: gameID, authorIDs: addressed, through: notifiedThrough)
+ }
}
private func publishCompletionPush(gameID: UUID, resigned: Bool) async {
@@ -1340,6 +1352,13 @@ final class AppServices {
struct PushRecipient: Sendable, Equatable {
let authorID: String
let readAt: Date?
+ /// Sender-local watermark: the latest authored move we've already told
+ /// this recipient about via a previous pause. The session-end tally
+ /// windows on the later of this and `readAt`, so we never re-report a
+ /// move the recipient has already seen *or* already been notified of.
+ /// `nil` when we've never paused to them. Never synced — see
+ /// `PlayerEntity.notifiedThrough`.
+ let notifiedThrough: Date?
/// The recipient's per-(account, game) push capability, read off their
/// Player record. `nil` when they haven't published one yet (older
/// build, or not-yet-synced) — such a recipient can't be addressed and
@@ -1371,7 +1390,7 @@ final class AppServices {
gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
gReq.fetchLimit = 1
guard let game = try? ctx.fetch(gReq).first else { return .empty }
- var byAuthor: [String: (Date?, String?)] = [:]
+ var byAuthor: [String: (readAt: Date?, notifiedThrough: Date?, pushAddress: String?)] = [:]
let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
pReq.predicate = NSPredicate(format: "game == %@", game)
for p in (try? ctx.fetch(pReq)) ?? [] {
@@ -1380,10 +1399,15 @@ final class AppServices {
!a.isEmpty
else { continue }
if let authorID, a == authorID { continue }
- byAuthor[a] = (p.readAt, p.pushAddress)
+ byAuthor[a] = (p.readAt, p.notifiedThrough, p.pushAddress)
}
let recipients = byAuthor.map {
- PushRecipient(authorID: $0.key, readAt: $0.value.0, pushAddress: $0.value.1)
+ PushRecipient(
+ authorID: $0.key,
+ readAt: $0.value.readAt,
+ notifiedThrough: $0.value.notifiedThrough,
+ pushAddress: $0.value.pushAddress
+ )
}
return PushPlan(
recipients: recipients,
diff --git a/Crossmate/Services/SessionPushPlanner.swift b/Crossmate/Services/SessionPushPlanner.swift
@@ -74,7 +74,16 @@ enum SessionPushPlanner {
return recipients.compactMap { recipient -> PushClient.Addressee? in
guard let address = recipient.pushAddress else { return nil }
- let counts = tally(history: history, since: recipient.readAt ?? .distantPast)
+ // Window on the later of what the recipient has read in-app and
+ // what we've already notified them about. `readAt` alone misses a
+ // recipient who stayed backgrounded across two of our pauses — it
+ // never advances, so the second pause would re-tally the same
+ // moves. The watermark closes that: a session that added nothing
+ // new tallies to zero ("stopped solving") rather than repeating.
+ let cutoff = [recipient.readAt, recipient.notifiedThrough]
+ .compactMap { $0 }
+ .max()
+ let counts = tally(history: history, since: cutoff ?? .distantPast)
let body = PuzzleNotificationText.pauseBody(
playerName: playerName,
puzzleTitle: puzzleTitle,
@@ -83,11 +92,12 @@ enum SessionPushPlanner {
checks: counts.checks,
reveals: counts.reveals
)
- // Stamp the exact cutoff this recipient's diff used. `nil` readAt
- // (no cursor yet) is preserved as nil — itself diagnostic, since it
- // means the count covered the author's entire history.
+ // Stamp the exact cutoff this recipient's diff used. `nil` (no
+ // cursor and never notified) is preserved as nil — itself
+ // diagnostic, since it means the count covered the author's
+ // entire history.
var perRecipient = diagnostics
- perRecipient?.recipientReadAt = recipient.readAt
+ perRecipient?.recipientReadAt = cutoff
return PushClient.Addressee(
address: address,
body: body,
diff --git a/Tests/Unit/SessionPushPlannerTests.swift b/Tests/Unit/SessionPushPlannerTests.swift
@@ -70,8 +70,17 @@ struct SessionPushPlannerTests {
)
}
- private func recipient(_ address: String?, readAt: Date?) -> AppServices.PushRecipient {
- AppServices.PushRecipient(authorID: "peer", readAt: readAt, pushAddress: address)
+ private func recipient(
+ _ address: String?,
+ readAt: Date?,
+ notifiedThrough: Date? = nil
+ ) -> AppServices.PushRecipient {
+ AppServices.PushRecipient(
+ authorID: "peer",
+ readAt: readAt,
+ notifiedThrough: notifiedThrough,
+ pushAddress: address
+ )
}
@Test("A caught-up recipient is still addressed, with a presence-only payload")
@@ -124,6 +133,40 @@ struct SessionPushPlannerTests {
== "Bunny 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")
+ func notifiedThroughWindowsOutPriorMoves() {
+ // The bounce case: the recipient stayed backgrounded (readAt stuck far
+ // in the past), but we already paused to them once covering `edit`. The
+ // second pause must not re-report that fill — it tallies to zero and
+ // reads as a presence-only "stopped solving", not a duplicate summary.
+ let edit = Date(timeIntervalSince1970: 1_000)
+ let entries = [entry("X", at: edit, seq: 1, col: 0)]
+ let staleReadAt = edit.addingTimeInterval(-3_600)
+
+ // First pause (never notified): the fill is news to the recipient.
+ let firstPause = SessionPushPlanner.sessionEndAddressees(
+ recipients: [recipient("addr", readAt: staleReadAt)],
+ journalEntries: entries,
+ playerName: "Bunny",
+ puzzleTitle: "Tuesday"
+ )
+ #expect(firstPause[0].payload
+ == PushPayload(event: .pause(fills: 1, clears: 0, checks: 0, reveals: 0)))
+
+ // Second pause after a bounce with no new move: readAt is still stale,
+ // but the watermark already covers `edit`, so nothing is re-reported.
+ let secondPause = SessionPushPlanner.sessionEndAddressees(
+ recipients: [recipient("addr", readAt: staleReadAt, notifiedThrough: edit)],
+ journalEntries: entries,
+ playerName: "Bunny",
+ 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'.")
+ }
+
@Test("A whole-grid check reports one check gesture, not a wall of letter churn")
func checkGestureCounted() {
let edit = Date(timeIntervalSince1970: 1_000)