commit 6d56e4082f2a6634ec426e9fe6e3dd773b02da50
parent 8d36bbed1260f5dc2b8d115ae53e366852ca890a
Author: Michael Camilleri <[email protected]>
Date: Thu, 30 Jul 2026 20:13:06 +0900
Synchronise Completed game badges across devices
A completed shared game could lose its Game List badge when its
Chronicle replaced the live Game while the app-icon badge remained set.
Opening the Chronicle also addressed its derived archive UUID, so it did
not dismiss the original game's notification or publish a read receipt
sibling devices understood.
This commit treats the live Game and Chronicle as one canonical unread
identity. It mirrors monotonic latest-move and read-through watermarks
onto the visible Chronicle, deduplicates the app-icon count by the
original game ID and clears the notification ledger when the Chronicle
opens.
Sibling Player watermarks now update the Chronicle projection. A
Completed account-seen push can also advance an archive-only local
watermark after live-zone retirement, allowing devices to converge even
when the hidden live row is gone.
Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>
Diffstat:
7 files changed, 239 insertions(+), 35 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -955,7 +955,12 @@ private struct PuzzleDisplayView: View {
roster = nil
loadError = nil
loadingMessage = "Loading puzzle…"
- Task { await services.badge.dismissDeliveredNotifications(for: gameID) }
+ let canonicalGameID = store.canonicalGameID(for: gameID)
+ Task {
+ await services.badge.dismissDeliveredNotifications(
+ for: canonicalGameID
+ )
+ }
do {
if let plan = NYTPuzzleUpgrader.plan(for: gameID, store: store) {
@@ -1118,6 +1123,14 @@ private struct PuzzleDisplayView: View {
"PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded Chronicle roster " +
"(live lifecycle disabled)"
)
+ // The Chronicle is a local projection with a derived storage ID.
+ // Publish the read watermark through the retained live game's
+ // original identity so sibling devices clear the same Completed
+ // tile and app-icon badge.
+ await services.publishReadCursor(
+ for: store.canonicalGameID(for: gameID),
+ mode: .currentTime
+ )
return
}
if isShared && preferences.isICloudSyncEnabled {
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -1025,6 +1025,7 @@ final class GameStore {
(entity.readThroughAt ?? .distantPast) < latest {
entity.readThroughAt = latest
}
+ mirrorReadStateToChronicle(from: entity)
}
if context.hasChanges {
@@ -1149,32 +1150,18 @@ final class GameStore {
/// `hasUnreadOtherMoves` heuristic the library list uses, aggregated as
/// a count for the app-icon badge.
func unreadOtherMovesGameCount() -> Int {
- let request = NSFetchRequest<NSNumber>(entityName: "GameEntity")
- request.resultType = .countResultType
- request.predicate = unreadOtherMovesPredicate
- return (try? context.count(for: request)) ?? 0
+ unreadOtherMovesGameTimes().count
}
/// The same heuristic as `unreadOtherMovesGameCount`, returning the
/// individual game IDs so the App Group `BadgeState` set can be unioned
/// with NSE-added entries.
func unreadOtherMovesGameIDs() -> Set<UUID> {
- let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- request.predicate = unreadOtherMovesPredicate
- request.propertiesToFetch = ["id"]
- let rows = (try? context.fetch(request)) ?? []
- return Set(rows.compactMap(\.id))
+ Set(unreadOtherMovesGameTimes().keys)
}
func hasUnreadOtherMoves(gameID: UUID) -> Bool {
- let request = NSFetchRequest<NSNumber>(entityName: "GameEntity")
- request.resultType = .countResultType
- request.fetchLimit = 1
- request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
- NSPredicate(format: "id == %@", gameID as CVarArg),
- unreadOtherMovesPredicate
- ])
- return ((try? context.count(for: request)) ?? 0) > 0
+ unreadOtherMovesGameIDs().contains(canonicalGameID(for: gameID))
}
/// The same heuristic as `unreadOtherMovesGameIDs`, but paired with each
@@ -1193,7 +1180,10 @@ final class GameStore {
var result: [UUID: Date] = [:]
for row in rows {
if let id = row.id, let at = row.latestOtherMoveAt {
- result[id] = at
+ let canonicalID = row.ckRecordName.flatMap(
+ Archive.originalGameID(fromName:)
+ ) ?? id
+ result[canonicalID] = max(result[canonicalID] ?? .distantPast, at)
}
}
return result
@@ -1225,9 +1215,13 @@ final class GameStore {
private var unreadOtherMovesPredicate: NSPredicate {
// Keyed off the read *watermark* (`readThroughAt`), not the forward-
// dated presence lease (`lastReadOtherMoveAt`) — matches
- // `GameSummary.computeHasUnread`.
+ // `GameSummary.computeHasUnread`. A shared Chronicle is locally owned
+ // (`databaseScope == 0`) but still participates after retirement
+ // deletes its live row; `unreadOtherMovesGameTimes` canonicalizes the
+ // overlapping rows to one original game ID before counting.
NSPredicate(
- format: "(databaseScope == 1 OR ckShareRecordName != nil) "
+ format: "(databaseScope == 1 OR ckShareRecordName != nil "
+ + "OR archiveParticipants != nil) "
+ "AND latestOtherMoveAt != nil "
+ "AND (readThroughAt == nil OR latestOtherMoveAt > readThroughAt)"
)
@@ -1259,6 +1253,30 @@ final class GameStore {
return (game, mutator)
}
+ /// The stable identity used by notifications and unread state. A
+ /// materialized Chronicle has its own Core Data ID so it can coexist with
+ /// the retained live row, but its record name preserves the original game
+ /// ID that pushes and Player records use.
+ func canonicalGameID(for storedGameID: UUID) -> UUID {
+ guard let entity = fetchGameEntity(id: storedGameID),
+ let recordName = entity.ckRecordName,
+ let originalID = Archive.originalGameID(fromName: recordName)
+ else { return storedGameID }
+ return originalID
+ }
+
+ /// Whether either persisted representation in a live-game/Chronicle family
+ /// is terminal. Used only for account-level read receipts: unlike an active
+ /// puzzle, a completed game cannot gain a later move after a sibling says
+ /// it has been opened, so the receiving device can safely advance its local
+ /// Chronicle watermark immediately.
+ func isCompletedGameFamily(gameID: UUID) -> Bool {
+ let canonicalID = canonicalGameID(for: gameID)
+ return readStateEntities(canonicalGameID: canonicalID).contains {
+ $0.completedAt != nil
+ }
+ }
+
// MARK: - Duplicate detection
/// Returns the ID of an existing game for the same source. Exact source
@@ -2029,31 +2047,72 @@ final class GameStore {
/// Returns `true` if the watermark moved.
@discardableResult
func advanceReadThrough(gameID: UUID, through: Date) -> Bool {
- let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
- request.fetchLimit = 1
- guard let entity = try? context.fetch(request).first else { return false }
- let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1
- guard isShared else { return false }
- guard (entity.readThroughAt ?? .distantPast) < through else { return false }
- entity.readThroughAt = through
+ let canonicalID = canonicalGameID(for: gameID)
+ let entities = readStateEntities(canonicalGameID: canonicalID)
+ guard entities.contains(where: {
+ $0.ckShareRecordName != nil
+ || $0.databaseScope == 1
+ || isArchivedSharedGame($0)
+ }) else { return false }
+ var changed = false
+ for entity in entities where (entity.readThroughAt ?? .distantPast) < through {
+ entity.readThroughAt = through
+ changed = true
+ }
+ guard changed else { return false }
saveContext("advanceReadThrough")
onUnreadOtherMovesChanged?()
return true
}
private func markOtherMovesRead(for entity: GameEntity) {
- let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1
- guard isShared, let latest = entity.latestOtherMoveAt else { return }
+ guard let storedID = entity.id else { return }
+ let canonicalID = canonicalGameID(for: storedID)
+ let entities = readStateEntities(canonicalGameID: canonicalID)
+ let isShared = entities.contains {
+ $0.ckShareRecordName != nil
+ || $0.databaseScope == 1
+ || isArchivedSharedGame($0)
+ }
+ guard isShared,
+ let latest = entities.compactMap(\.latestOtherMoveAt).max()
+ else { return }
// Advances the *read watermark* (`readThroughAt`), not the presence
// lease (`lastReadOtherMoveAt`). Opening the game means the user has now
// seen every other-author move up to `latest`; the lease is a separate,
// forward-dated "actively present" horizon owned by `setReadCursor`.
- if (entity.readThroughAt ?? .distantPast) < latest {
- entity.readThroughAt = latest
- saveContext("markOtherMovesRead")
- onUnreadOtherMovesChanged?()
+ var changed = false
+ for candidate in entities where (candidate.readThroughAt ?? .distantPast) < latest {
+ candidate.readThroughAt = latest
+ changed = true
}
+ guard changed else { return }
+ saveContext("markOtherMovesRead")
+ onUnreadOtherMovesChanged?()
+ }
+
+ private func readStateEntities(canonicalGameID: UUID) -> [GameEntity] {
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(
+ format: "id IN %@",
+ [
+ canonicalGameID,
+ Archive.archiveGameID(for: canonicalGameID)
+ ]
+ )
+ return (try? context.fetch(request)) ?? []
+ }
+
+ private func mirrorReadStateToChronicle(from live: GameEntity) {
+ guard let liveID = live.id else { return }
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(
+ format: "id == %@",
+ Archive.archiveGameID(for: liveID) as CVarArg
+ )
+ request.fetchLimit = 1
+ guard let chronicle = try? context.fetch(request).first else { return }
+ Archive.mirrorReadState(from: live, to: chronicle)
}
private func seedFromSample() throws -> (GameEntity, Puzzle) {
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -2121,6 +2121,17 @@ final class AppServices {
return true
}
let (previous, adopted) = store.noteIncomingReadCursor(gameID: gameID, presenceUntil: presenceUntil)
+ if store.isCompletedGameFamily(gameID: gameID) {
+ // A completed game is immutable. Treat the sibling's explicit
+ // open as a read receipt immediately, including when retirement
+ // has already removed the live row and only the Chronicle
+ // projection remains. The Player.readThrough echo is still the
+ // durable convergence path while the live zone exists.
+ store.advanceReadThrough(
+ gameID: gameID,
+ through: min(presenceUntil, Date())
+ )
+ }
syncMonitor.note(
"push(accountSeen): sibling saw \(gameID.uuidString.prefix(8)) " +
"presenceUntil=\(presenceUntil.ISO8601Format()) " +
diff --git a/Crossmate/Sync/Archive.swift b/Crossmate/Sync/Archive.swift
@@ -130,6 +130,22 @@ enum Archive {
return nil
}
+ /// Copies the account's mutable unread state from a completed live Game
+ /// onto its local Chronicle projection. The Chronicle payload itself stays
+ /// immutable; these fields are device-local projections of the canonical
+ /// live-game state so the visible Completed tile survives the handoff from
+ /// the hidden Game row.
+ static func mirrorReadState(from live: GameEntity, to chronicle: GameEntity) {
+ if let latest = live.latestOtherMoveAt,
+ (chronicle.latestOtherMoveAt ?? .distantPast) < latest {
+ chronicle.latestOtherMoveAt = latest
+ }
+ if let readThrough = live.readThroughAt,
+ (chronicle.readThroughAt ?? .distantPast) < readThrough {
+ chronicle.readThroughAt = readThrough
+ }
+ }
+
static func isArchiveZone(_ zoneName: String) -> Bool {
zoneName == self.zoneName || zoneName.hasPrefix("archive-")
}
@@ -904,6 +920,19 @@ enum Archive {
? payload.participants.map(\.authorID).sorted().joined(separator: ",")
: nil
entity.isSupersededByChronicle = false
+ // When the live row is still present, it owns the account's mutable
+ // unread watermark. Mirror that state before the Chronicle becomes the
+ // visible Completed tile (and before retirement may delete the live
+ // row), without putting mutable state into the frozen archive payload.
+ let liveRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ liveRequest.predicate = NSPredicate(
+ format: "id == %@",
+ payload.originalGameID as CVarArg
+ )
+ liveRequest.fetchLimit = 1
+ if let live = try? ctx.fetch(liveRequest).first {
+ mirrorReadState(from: live, to: entity)
+ }
// Pending Chronicles carry no partial journal, but remain visibly
// retryable until reconciliation either captures every device or the
// retention deadline turns them into a terminal no-replay fallback.
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -401,6 +401,15 @@ extension SyncEngine {
let game = entity.game,
(game.readThroughAt ?? .distantPast) < incomingReadThrough {
game.readThroughAt = incomingReadThrough
+ let archiveRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ archiveRequest.predicate = NSPredicate(
+ format: "id == %@",
+ Archive.archiveGameID(for: gameID) as CVarArg
+ )
+ archiveRequest.fetchLimit = 1
+ if let chronicle = try? ctx.fetch(archiveRequest).first {
+ Archive.mirrorReadState(from: game, to: chronicle)
+ }
}
// The remaining value fields are only adopted when the incoming record
diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift
@@ -592,6 +592,21 @@ struct ArchiveTests {
#expect(summary.isShared)
#expect(Set(summary.allParticipants.map(\.authorID)) == ["alice", "bob"])
#expect(summary.completedAt != nil)
+
+ // Once retirement removes the live row, a shared Chronicle remains a
+ // valid unread source for the app-icon count.
+ game.latestOtherMoveAt = payload.completedAt
+ game.readThroughAt = payload.completedAt.addingTimeInterval(-1)
+ try ctx.save()
+ let store = makeTestStore(persistence: persistence)
+ #expect(store.unreadOtherMovesGameCount() == 1)
+ #expect(store.unreadOtherMovesGameIDs() == [original])
+ #expect(store.isCompletedGameFamily(gameID: original))
+ #expect(store.advanceReadThrough(
+ gameID: original,
+ through: payload.completedAt
+ ))
+ #expect(store.unreadOtherMovesGameCount() == 0)
}
@Test("a materialized archive replays the full multi-author timeline locally")
@@ -930,6 +945,9 @@ struct ArchiveTests {
live.updatedAt = Date()
live.databaseScope = 1
live.ckRecordName = "game-\(original.uuidString)"
+ live.completedAt = Date(timeIntervalSince1970: 1_700_001_000)
+ live.latestOtherMoveAt = Date(timeIntervalSince1970: 1_700_000_900)
+ live.readThroughAt = Date(timeIntervalSince1970: 1_700_000_800)
for (authorID, name) in [("alice", "Alice"), ("bob", "Bob")] {
let player = PlayerEntity(context: ctx)
player.game = live
@@ -974,10 +992,25 @@ struct ArchiveTests {
#expect(liveSummary.id != archiveSummary.id)
#expect(liveSummary.listID == original)
#expect(archiveSummary.listID == original)
+ #expect(liveSummary.hasUnreadOtherMoves)
+ #expect(archiveSummary.hasUnreadOtherMoves)
#expect(
liveSummary.allParticipants.first { $0.authorID == "bob" }?.color
== archiveSummary.allParticipants.first { $0.authorID == "bob" }?.color
)
+
+ // Both persisted rows describe one unread game, and opening the
+ // visible Chronicle clears the canonical live state plus its
+ // projection rather than addressing only the derived archive UUID.
+ let store = makeTestStore(persistence: persistence)
+ #expect(store.canonicalGameID(for: archive.id!) == original)
+ #expect(store.unreadOtherMovesGameCount() == 1)
+ _ = try store.loadGame(id: archive.id!)
+ let reviewedLive = try #require(GameSummary(entity: live))
+ let reviewedArchive = try #require(GameSummary(entity: archive))
+ #expect(!reviewedLive.hasUnreadOtherMoves)
+ #expect(!reviewedArchive.hasUnreadOtherMoves)
+ #expect(store.unreadOtherMovesGameCount() == 0)
}
@Test("block reconciliation preserves legacy Chronicle replacement")
diff --git a/Tests/Unit/Sync/PlayerRecordPresenceTests.swift b/Tests/Unit/Sync/PlayerRecordPresenceTests.swift
@@ -229,6 +229,56 @@ struct PlayerRecordPresenceTests {
#expect(row.updatedAt == Date(timeIntervalSince1970: 20))
}
+ @Test("A sibling read watermark clears the visible Chronicle projection")
+ func siblingReadThroughMirrorsToChronicle() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let game = try makeGame(in: ctx)
+ let latest = Date(timeIntervalSince1970: 12)
+ game.latestOtherMoveAt = latest
+ try makeExistingLocalPlayer(
+ in: ctx,
+ game: game,
+ updatedAt: Date(timeIntervalSince1970: 10)
+ )
+
+ let chronicle = GameEntity(context: ctx)
+ chronicle.id = Archive.archiveGameID(for: gameID)
+ chronicle.ckRecordName = Archive.recordName(forOriginalGameID: gameID)
+ chronicle.title = "Presence Test Chronicle"
+ chronicle.puzzleSource = ""
+ chronicle.createdAt = Date(timeIntervalSince1970: 1)
+ chronicle.updatedAt = Date(timeIntervalSince1970: 1)
+ chronicle.latestOtherMoveAt = latest
+ chronicle.readThroughAt = Date(timeIntervalSince1970: 5)
+ try ctx.save()
+
+ let record = RecordSerializer.playerRecord(
+ gameID: gameID,
+ authorID: localAuthorID,
+ name: "Local",
+ updatedAt: Date(timeIntervalSince1970: 20),
+ selection: nil,
+ presenceUntil: Date(timeIntervalSince1970: 20),
+ readThrough: latest,
+ zone: zoneID,
+ systemFields: nil
+ )
+
+ let engine = makeEngine(persistence: persistence)
+ engine.applyPlayerRecord(
+ record,
+ in: ctx,
+ localAuthorID: localAuthorID,
+ onFirstTime: { _ in },
+ onPresenceChange: { _ in },
+ onReadCursor: { _, _, _ in }
+ )
+
+ #expect(game.readThroughAt == latest)
+ #expect(chronicle.readThroughAt == latest)
+ }
+
@Test("Partial player record without name still applies other fields")
func partialPlayerRecordWithoutNameStillAppliesFields() throws {
let persistence = makeTestPersistence()