crossmate

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

commit cbed8247852072c30b58bae734816fb96bd7797a
parent 680423f157152c75168904fbfc84278802510857
Author: Michael Camilleri <[email protected]>
Date:   Mon,  8 Jun 2026 22:32:51 +0900

Sync the catch-up baseline across devices

This commit makes the in-app catch-up banner compute a short settle after
the puzzle opens and advance its per-peer baseline only when the user
leaves, rather than draining the baseline on open. Computing on open
diffed against a half-synced grid, because peer moves stream in shortly
after the open's grid freshen, so the banner under-reported and then
swallowed those late arrivals on the next open once the drain had moved
the baseline forward. Reading the summary is now separate from advancing
the baseline: the read-only diff can run on every foreground and against
a partial grid, while the baseline moves forward only on background and
on disappear, once the user has actually seen what is on screen.

Sibling devices no longer recompute the baseline from their own possibly
stale view. The committed per-peer snapshot map now ships on this
account's own Player session snapshot, a previously dormant field already
in the schema, and other devices adopt it directly so they converge on
what the account actually saw. The old recompute on an incoming read
cursor is gone, and the fast account-seen push is now purely the
cross-device notification-dismissal signal, with the accurate baseline
riding the record sync its companion database change triggers.

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

Diffstat:
MCrossmate/CrossmateApp.swift | 8++++++++
MCrossmate/Models/LocalMovesSnapshot.swift | 9+++++----
MCrossmate/Persistence/GameStore.swift | 29+++++++++++++++++++++++++++++
MCrossmate/Services/AppServices.swift | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
MCrossmate/Sync/RecordApplier.swift | 12++++++++----
MCrossmate/Sync/RecordSerializer.swift | 9+++++----
MCrossmate/Sync/SessionMonitor.swift | 65+++++++++++++++++++++++++++++++++++++++++------------------------
MCrossmate/Sync/SyncEngine.swift | 6+++---
MTests/Unit/Sync/PlayerRecordPresenceTests.swift | 4++--
MTests/Unit/Sync/SessionMonitorTests.swift | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
10 files changed, 259 insertions(+), 73 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -702,6 +702,9 @@ private struct PuzzleDisplayView: View { let movesUpdater = services.movesUpdater let syncEngine = services.syncEngine let id = gameID + // Navigating away is a leave: commit the catch-up baseline and drop + // any pending banner timer (idempotent with the .background path). + services.handlePuzzleLeft(gameID: id) services.scheduleEngagementEnd(gameID: id) // A visit that never outlasted the begin grace was never announced // to peers, so there's no play session to pause on the way out. @@ -774,6 +777,8 @@ private struct PuzzleDisplayView: View { // pause/play pair, for a brief absence (phone sleep, a call, // app-switcher peek that escalated to background). let resumed = services.cancelPendingSessionEndPush(gameID: id) + // Schedules the catch-up banner after a short settle (open or + // resume); the matching commit happens on leave, below. services.handlePuzzleOpened(gameID: id) if !resumed { // Defer the play push so a quick pop-in/pop-out reaches no one; @@ -785,6 +790,9 @@ private struct PuzzleDisplayView: View { } case .background: NotificationState.clearActivePuzzleID(if: gameID) + // Leaving the puzzle: commit the catch-up baseline (the user has + // seen what's on screen) and drop a pending banner timer. + services.handlePuzzleLeft(gameID: id) // A session still inside its begin grace was never announced, so // there's nothing to pause — drop the deferred play push and skip // the matching pause rather than firing an unpaired one. diff --git a/Crossmate/Models/LocalMovesSnapshot.swift b/Crossmate/Models/LocalMovesSnapshot.swift @@ -1,10 +1,11 @@ import Foundation /// A point-in-time view of an author's merged Moves state, partitioned by -/// whether each cell currently holds a letter. Captured at session begin -/// and diffed at session end to derive net adds and net clears for the -/// pause-push body and the in-app catch-up banner — both sender and -/// receiver share the same algorithm so the numbers agree. +/// whether each cell currently holds a letter. Diffed against a later snapshot +/// to derive net adds and net clears for the in-app catch-up banner. Stored +/// per peer as the local "what I've seen" baseline, advanced on leave, and +/// shipped across the account's devices (keyed by peer) on +/// `Player.sessionSnapshot` so siblings converge. struct LocalMovesSnapshot: Equatable, Sendable, Codable { /// Positions where the Moves row currently has a non-empty letter. let filled: Set<GridPosition> diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -1206,6 +1206,35 @@ final class GameStore { saveContext("setLastMovesSnapshot") } + /// Persists `data` (an encoded `[peerAuthorID: LocalMovesSnapshot]`) onto + /// this account's own `Player.sessionSnapshot` for `gameID` — the per-peer + /// "what I've seen" baseline, shipped on the Player record so sibling + /// devices adopt it rather than recomputing from their own view. Unlike + /// `lastMovesSnapshotData` this *is* serialized to CloudKit. Creates a stub + /// PlayerEntity if none exists yet, keyed by the deterministic + /// `ckRecordName`. No-op if the GameEntity is missing. + func setSessionSnapshot(_ data: Data?, gameID: UUID, authorID: String) { + let entity: PlayerEntity + if let existing = fetchPlayerEntity(gameID: gameID, authorID: authorID) { + entity = existing + } else { + let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameRequest.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + gameRequest.fetchLimit = 1 + guard let game = try? context.fetch(gameRequest).first else { return } + entity = PlayerEntity(context: context) + entity.game = game + entity.authorID = authorID + entity.ckRecordName = RecordSerializer.recordName( + forPlayerInGame: gameID, + authorID: authorID + ) + entity.updatedAt = Date() + } + entity.sessionSnapshot = data + saveContext("setSessionSnapshot") + } + /// Stamps the local author's Player record for `gameID` with the address /// derived from `secret` (see `RecordSerializer.deriveGameAddress`), so it /// ships on the Player-record write the puzzle-open burst is already making. diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -86,6 +86,12 @@ final class AppServices { /// only a sustained absence does. static let sessionEndGrace: TimeInterval = 30 private var pendingSessionEndTasks: [UUID: Task<Void, Never>] = [:] + /// Settle delay before the catch-up banner is computed on open. Lets the + /// `.appeared` grid freshen land peer moves first, so the diff reflects the + /// settled grid rather than a half-synced snapshot; cancelled if the user + /// leaves before it elapses. + static let sessionSummaryBannerDelay: TimeInterval = 3 + private var pendingSessionSummaryBannerTasks: [UUID: Task<Void, Never>] = [:] /// Background-execution assertions keeping the matching grace timer alive /// after the app is backgrounded. iOS grants only a limited budget (often /// well under `sessionEndGrace`), so the assertion's expiration handler @@ -475,12 +481,19 @@ final class AppServices { // horizon bump and leaves delivered notifications untouched. await syncEngine.setOnIncomingReadCursor { [weak self, store, sessionMonitor] pairs in let now = Date() - for (gameID, readAt) in pairs { + for (gameID, readAt, seenBaselineData) in pairs { store.noteIncomingReadCursor(gameID: gameID, readAt: readAt) - // A sibling device showed the catch-up banner for these - // peers already; snap our per-peer baselines forward so we - // won't re-show the same letters when this device opens. - sessionMonitor.refreshMovesSnapshots(for: gameID) + // A sibling device shipped the per-peer baseline it saw on its + // own `Player.sessionSnapshot`; adopt it directly so we converge + // on what the account actually saw rather than recomputing from + // this device's (possibly stale) local view. + if let data = seenBaselineData, + let map = try? JSONDecoder().decode( + [String: LocalMovesSnapshot].self, from: data + ), + !map.isEmpty { + sessionMonitor.applyMovesBaseline(map, for: gameID) + } if readAt > now { await self?.dismissDeliveredNotifications( for: gameID, @@ -2133,7 +2146,10 @@ final class AppServices { } syncMonitor.note("push(accountSeen): sibling saw \(gameID.uuidString.prefix(8))") store.noteIncomingReadCursor(gameID: gameID, readAt: readAt) - sessionMonitor.refreshMovesSnapshots(for: gameID) + // The catch-up baseline is no longer recomputed here — it arrives, + // accurate, on the sibling's `Player.sessionSnapshot` via the + // record sync this push's companion DB change triggers. This fast + // push is now only the cross-device notification-dismissal signal. await dismissDeliveredNotifications( for: gameID, seenAt: readAt, @@ -2889,15 +2905,76 @@ final class AppServices { /// minutes' time. No-op if nothing was accumulated. func handlePuzzleOpened(gameID: UUID) { logLocalPauseDiagnostics(for: gameID) - let summaries = sessionMonitor.consumeMovesSnapshots(for: gameID) + // Defer the banner so the open's `.appeared` grid freshen can land peer + // moves first; otherwise it would diff against a half-synced grid and + // under-report. The baseline is not touched here — it advances only on + // leave (`handlePuzzleLeft`) — so this is a pure read and re-running it + // on a later foreground is harmless. + scheduleSessionSummaryBanner(gameID: gameID, after: Self.sessionSummaryBannerDelay) + } + + /// Called when the user leaves the puzzle (backgrounded or navigated away). + /// Drops a still-pending banner timer and commits the per-peer baseline — + /// the user has now seen what's on screen, so the next open diffs against + /// this state — then ships that baseline to sibling devices on this + /// account's own `Player.sessionSnapshot`, so they adopt it rather than + /// recomputing from their own view. Returns the committed snapshots. + @discardableResult + func handlePuzzleLeft(gameID: UUID) -> [String: LocalMovesSnapshot] { + cancelPendingSessionSummaryBanner(gameID: gameID) + let committed = sessionMonitor.commitMovesBaseline(for: gameID) + guard let authorID = identity.currentID, !authorID.isEmpty, + !committed.isEmpty, + let data = try? JSONEncoder().encode(committed) + else { return committed } + // Write it onto our own Player record and enqueue the send. This also + // rides the leave's read-cursor Player write, but enqueuing directly + // guarantees it ships even when that write is a no-op. + store.setSessionSnapshot(data, gameID: gameID, authorID: authorID) + let syncEngine = self.syncEngine + Task { await syncEngine.enqueuePlayer(gameID: gameID, authorID: authorID, reason: "sessionSnapshot") } + return committed + } + + /// Defer the catch-up banner by `seconds`, replacing any pending timer for + /// the same game. Leaving the puzzle (`handlePuzzleLeft`) cancels it. + func scheduleSessionSummaryBanner(gameID: UUID, after seconds: TimeInterval) { + pendingSessionSummaryBannerTasks.removeValue(forKey: gameID)?.cancel() + pendingSessionSummaryBannerTasks[gameID] = Task { [weak self] in + try? await Task.sleep(for: .seconds(seconds)) + guard !Task.isCancelled, let self else { return } + self.pendingSessionSummaryBannerTasks.removeValue(forKey: gameID) + self.postSessionSummaryBanner(gameID: gameID, reason: "open") + } + } + + func cancelPendingSessionSummaryBanner(gameID: UUID) { + pendingSessionSummaryBannerTasks.removeValue(forKey: gameID)?.cancel() + } + + /// Computes the receiver-side catch-up summary for `gameID` and, when a peer + /// has unseen activity, posts (or replaces, by stable id) the "Puzzle + /// Updated" banner. Read-only — the baseline advances on leave, not here — + /// so it is safe to recompute on every foreground. Logs the per-peer counts + /// it surfaces so a missing or wrong banner is diagnosable after the fact. + func postSessionSummaryBanner(gameID: UUID, reason: String) { + let summaries = sessionMonitor.movesSummaries(for: gameID) guard !summaries.isEmpty else { return } - let body = Self.formatSummaryBanner(summaries) + let detail = summaries.map { summary -> String in + let who = summary.playerName.isEmpty + ? String(summary.authorID.prefix(8)) + : summary.playerName + return "\(who) +\(summary.added)/-\(summary.cleared)\(summary.isFirstObservation ? " first" : "")" + }.joined(separator: ", ") + syncMonitor.note( + "session summary[\(gameID.uuidString.prefix(8))] \(reason): \(detail)" + ) announcements.post(Announcement( id: "session-summary-\(gameID.uuidString)", scope: .game(gameID), severity: .info, title: "Puzzle Updated", - body: body, + body: Self.formatSummaryBanner(summaries), dismissal: .transient(after: 6) )) } diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -19,7 +19,11 @@ struct BatchEffects { var playerPresenceChanged = Set<UUID>() var engagementChanged = Set<UUID>() var removed = Set<UUID>() - var readCursors: [(UUID, Date)] = [] + /// Per-game incoming read cursor from one of *our own* devices: the + /// `readAt` horizon plus the encoded per-peer "seen" baseline that sibling + /// shipped on its `Player.sessionSnapshot` (nil on older writes). Drives + /// cross-device baseline adoption. + var readCursors: [(UUID, Date, Data?)] = [] /// Games that just transitioned to completed via an inbound Game record, so /// this device uploads its journal for replay even though it didn't run the /// local completion path. @@ -77,7 +81,7 @@ extension SyncEngine { localAuthorID: localAuthorID, onFirstTime: { effects.playersUpdated.insert($0) }, onPresenceChange: { effects.playerPresenceChanged.insert($0) }, - onReadCursor: { effects.readCursors.append(($0, $1)) } + onReadCursor: { effects.readCursors.append(($0, $1, $2)) } ) effects.rosterRelevant.insert(gameID) } @@ -165,7 +169,7 @@ extension SyncEngine { localAuthorID: String?, onFirstTime: (UUID) -> Void, onPresenceChange: (UUID) -> Void, - onReadCursor: (UUID, Date) -> Void + onReadCursor: (UUID, Date, Data?) -> Void ) { let ckName = record.recordID.recordName guard let (gameID, authorID) = RecordSerializer.parsePlayerRecordName(ckName) else { @@ -247,7 +251,7 @@ extension SyncEngine { // per-game address (the LWW winner of this record). entity.pushAddress = RecordSerializer.parsePlayerPushAddress(from: record) if authorID == localAuthorID, let readAt = incomingReadAt { - onReadCursor(gameID, readAt) + onReadCursor(gameID, readAt, entity.sessionSnapshot) } let isRemoteAuthor = authorID != localAuthorID && authorID != CKCurrentUserDefaultName let hasSelection = entity.selRow != nil && entity.selCol != nil && entity.selDir != nil diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -424,10 +424,11 @@ enum RecordSerializer { } /// Reads `sessionSnapshot` off an inbound Player record — the encoded - /// `LocalMovesSnapshot` captured at session begin and cleared at session - /// end. Shared across all of the author's devices so a pause push from - /// any device reports the cumulative session delta. Returns `nil` when - /// no session is active or the field is missing from older records. + /// `[peerAuthorID: LocalMovesSnapshot]` map of what this account has seen of + /// each peer, written on leave. Shared across the author's own devices so a + /// sibling adopts the catch-up baseline rather than recomputing it from its + /// own (possibly stale) view. Returns `nil` on older records or when the + /// account has not yet left a game with peers. static func parsePlayerSessionSnapshot(from record: CKRecord) -> Data? { record["sessionSnapshot"] as? Data } diff --git a/Crossmate/Sync/SessionMonitor.swift b/Crossmate/Sync/SessionMonitor.swift @@ -4,14 +4,20 @@ import Foundation /// when the user opens a game ("Alice added 12 letters; Bob added 5"). For /// each peer who has a `MovesEntity` for the game, diffs their current Moves /// snapshot against the baseline persisted on the matching `PlayerEntity` -/// (the `LocalMovesSnapshot` captured at the user's last `consumeMovesSnapshots` -/// drain), then promotes the current snapshot to the new baseline. +/// (the `LocalMovesSnapshot` captured by the last `commitMovesBaseline`, which +/// runs when the user leaves the puzzle). +/// +/// Reading the diff (`movesSummaries`) and advancing the baseline +/// (`commitMovesBaseline`) are deliberately separate: the banner is computed on +/// open without consuming, so a half-synced grid or a repeated foreground just +/// re-reads the same growing delta; the baseline only moves forward on leave, +/// once the user has actually seen what's on screen. /// /// Shares the same snapshot/diff algorithm the sender-side pause push uses /// against its own MovesEntity row, so the numbers agree on both ends. First -/// observation of a peer per `(gameID, authorID)` seeds the baseline and -/// returns no summary, so a fresh install or new collaborator doesn't surface -/// a giant catch-up count. +/// observation of a peer per `(gameID, authorID)` reports the peer's whole +/// contribution as away context, so a fresh install or new collaborator gets +/// "added N while you were away" rather than a giant bare count. @MainActor final class SessionMonitor { private let store: GameStore @@ -61,12 +67,13 @@ final class SessionMonitor { } } - /// Walks every peer with a MovesEntity for `gameID`, diffs against the + /// Walks every peer with a MovesEntity for `gameID`, diffs each against the /// per-peer baseline, and returns the non-zero deltas as `SessionSummary` - /// records. Promotes the freshly computed snapshot to the new baseline - /// for each peer (including those whose diff was zero) so the next call - /// only surfaces activity since now. - func consumeMovesSnapshots(for gameID: UUID) -> [SessionSummary] { + /// records. Read-only: it does not advance the baseline, so it is safe to + /// call repeatedly while a puzzle is open (every foreground) and against a + /// half-synced grid — the diff just grows as peer moves arrive. The baseline + /// moves forward only in `commitMovesBaseline`, on leave. + func movesSummaries(for gameID: UUID) -> [SessionSummary] { let localAuthorID = localAuthorIDProvider() let peers = store.peerAuthorIDs(for: gameID, excluding: localAuthorID) guard !peers.isEmpty else { return [] } @@ -75,7 +82,6 @@ final class SessionMonitor { for authorID in peers { let current = store.movesSnapshot(for: gameID, by: authorID, on: nil) let baseline = store.lastMovesSnapshot(for: gameID, by: authorID) - store.setLastMovesSnapshot(current, for: gameID, by: authorID) let added: Int let cleared: Int let isFirstObservation: Bool @@ -107,24 +113,35 @@ final class SessionMonitor { return summaries } - /// Advances every peer's `lastMovesSnapshot` for `gameID` to the - /// merged-moves state currently visible on this device. Called when a - /// sibling device of the local user advances `Player.readAt` — the - /// catch-up banner has already been shown on that other device, so the - /// next `consumeMovesSnapshots` here should diff against "now" rather - /// than the stale per-device cursor and re-show the same content. - /// - /// Slight imprecision: peer moves that arrived locally before the - /// readAt update but after the sibling actually opened will be folded - /// into the baseline and not surfaced on this device. The window is - /// typically sub-second (own-account silent pushes), and "eventual - /// consistency is OK" stance covers it. - func refreshMovesSnapshots(for gameID: UUID) { + /// Advances every peer's baseline for `gameID` to the merged-moves state + /// currently visible on this device, and returns the snapshots it committed + /// keyed by peer authorID — the caller ships these to sibling devices so + /// they converge without recomputing from their own (possibly stale) view. + /// Called when the user leaves the puzzle: they have now seen everything on + /// screen, so the next `movesSummaries` diffs against "now". + @discardableResult + func commitMovesBaseline(for gameID: UUID) -> [String: LocalMovesSnapshot] { let localAuthorID = localAuthorIDProvider() let peers = store.peerAuthorIDs(for: gameID, excluding: localAuthorID) + var committed: [String: LocalMovesSnapshot] = [:] for authorID in peers { let current = store.movesSnapshot(for: gameID, by: authorID, on: nil) store.setLastMovesSnapshot(current, for: gameID, by: authorID) + committed[authorID] = current + } + return committed + } + + /// Adopts the per-peer baseline a sibling device shipped on its own + /// `Player.sessionSnapshot` (decoded from the synced record). Overwrites each + /// peer's baseline with the sibling's snapshot rather than recomputing from + /// this device's local view, so a freshly-woken or partially-synced device + /// converges on what the account actually saw. Latest write wins by the + /// Player record's own last-writer-wins, so the most recent leaver's view is + /// the one adopted. + func applyMovesBaseline(_ snapshots: [String: LocalMovesSnapshot], for gameID: UUID) { + for (authorID, snapshot) in snapshots { + store.setLastMovesSnapshot(snapshot, for: gameID, by: authorID) } } diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -126,7 +126,7 @@ actor SyncEngine { /// whose authorID matches the local user. A sibling device has recorded /// the account's read horizon; active sessions may move it into the near /// future and later close it with a lower current-time value. - var onIncomingReadCursor: (@MainActor @Sendable ([(UUID, Date)]) async -> Void)? + var onIncomingReadCursor: (@MainActor @Sendable ([(UUID, Date, Data?)]) async -> Void)? var onAccountPushAddress: (@MainActor @Sendable (String) async -> Void)? var onAccountPushSecret: (@MainActor @Sendable (String) async -> Void)? private var localAuthorIDProvider: (@MainActor @Sendable () -> String?)? @@ -210,7 +210,7 @@ actor SyncEngine { onPingDeleted = cb } - func setOnIncomingReadCursor(_ cb: @MainActor @Sendable @escaping ([(UUID, Date)]) async -> Void) { + func setOnIncomingReadCursor(_ cb: @MainActor @Sendable @escaping ([(UUID, Date, Data?)]) async -> Void) { onIncomingReadCursor = cb } @@ -1294,7 +1294,7 @@ actor SyncEngine { localAuthorID: localAuthorID, onFirstTime: { effects.playersUpdated.insert($0) }, onPresenceChange: { effects.playerPresenceChanged.insert($0) }, - onReadCursor: { effects.readCursors.append(($0, $1)) } + onReadCursor: { effects.readCursors.append(($0, $1, $2)) } ) effects.rosterRelevant.insert(gameID) } diff --git a/Tests/Unit/Sync/PlayerRecordPresenceTests.swift b/Tests/Unit/Sync/PlayerRecordPresenceTests.swift @@ -105,7 +105,7 @@ struct PlayerRecordPresenceTests { localAuthorID: localAuthorID, onFirstTime: { firstTimeGameIDs.append($0) }, onPresenceChange: { presenceGameIDs.append($0) }, - onReadCursor: { _, _ in } + onReadCursor: { _, _, _ in } ) #expect(firstTimeGameIDs.isEmpty) @@ -144,7 +144,7 @@ struct PlayerRecordPresenceTests { localAuthorID: localAuthorID, onFirstTime: { firstTimeGameIDs.append($0) }, onPresenceChange: { presenceGameIDs.append($0) }, - onReadCursor: { _, _ in } + onReadCursor: { _, _, _ in } ) #expect(firstTimeGameIDs.isEmpty) diff --git a/Tests/Unit/Sync/SessionMonitorTests.swift b/Tests/Unit/Sync/SessionMonitorTests.swift @@ -136,7 +136,7 @@ struct SessionMonitorTests { ] ) - let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) #expect(summaries.count == 1) let summary = try #require(summaries.first) @@ -158,7 +158,7 @@ struct SessionMonitorTests { cells: [position(0, 0): ("", Date())] ) - let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) #expect(summaries.isEmpty) } @@ -170,7 +170,7 @@ struct SessionMonitorTests { authorID: Self.alice, cells: [position(0, 0): ("A", Date())] ) - _ = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + fixture.monitor.commitMovesBaseline(for: fixture.gameID) // Alice adds another letter, then the user opens the puzzle again. try writeMoves( @@ -181,7 +181,7 @@ struct SessionMonitorTests { position(0, 1): ("B", Date()), ] ) - let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) let summary = try #require(summaries.first) #expect(!summary.isFirstObservation) @@ -199,7 +199,7 @@ struct SessionMonitorTests { authorID: Self.alice, cells: [position(0, 0): ("A", Date())] ) - _ = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + fixture.monitor.commitMovesBaseline(for: fixture.gameID) try writeMoves( in: fixture, @@ -210,7 +210,7 @@ struct SessionMonitorTests { position(2, 2): ("H", Date()), ] ) - let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) let summary = try #require(summaries.first) #expect(summary.added == 2) #expect(summary.cleared == 0) @@ -228,7 +228,7 @@ struct SessionMonitorTests { position(0, 1): ("B", Date()), ] ) - _ = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + fixture.monitor.commitMovesBaseline(for: fixture.gameID) try writeMoves( in: fixture, @@ -238,7 +238,7 @@ struct SessionMonitorTests { position(0, 1): ("", Date()), ] ) - let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) let summary = try #require(summaries.first) #expect(summary.added == 0) #expect(summary.cleared == 1) @@ -252,12 +252,61 @@ struct SessionMonitorTests { authorID: Self.alice, cells: [position(0, 0): ("A", Date())] ) - _ = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + fixture.monitor.commitMovesBaseline(for: fixture.gameID) - let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) #expect(summaries.isEmpty) } + // MARK: - Read / commit separation + + @Test("movesSummaries is read-only; commitMovesBaseline advances the baseline") + func summariesReadOnlyCommitAdvances() throws { + let fixture = try makeFixture() + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [ + position(0, 0): ("A", Date()), + position(0, 1): ("B", Date()), + ] + ) + + // Reading twice without committing returns the same delta — the + // baseline has not moved, so a repeated foreground is idempotent. + #expect(fixture.monitor.movesSummaries(for: fixture.gameID).first?.added == 2) + #expect(fixture.monitor.movesSummaries(for: fixture.gameID).first?.added == 2) + + // Committing advances the baseline to current; the next read is empty. + let committed = fixture.monitor.commitMovesBaseline(for: fixture.gameID) + #expect(committed[Self.alice]?.filled.count == 2) + #expect(fixture.monitor.movesSummaries(for: fixture.gameID).isEmpty) + } + + @Test("applyMovesBaseline adopts a sibling's shipped snapshot") + func applyAdoptsSiblingBaseline() throws { + let fixture = try makeFixture() + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [ + position(0, 0): ("A", Date()), + position(0, 1): ("B", Date()), + ] + ) + // A sibling committed its baseline at Alice's current two-cell state and + // shipped it. Adopting that snapshot suppresses the banner here without + // this device recomputing from its own view. + let shipped: [String: LocalMovesSnapshot] = [ + Self.alice: LocalMovesSnapshot( + filled: [position(0, 0), position(0, 1)], + cleared: [] + ) + ] + fixture.monitor.applyMovesBaseline(shipped, for: fixture.gameID) + #expect(fixture.monitor.movesSummaries(for: fixture.gameID).isEmpty) + } + @Test("The local author's Moves row is excluded from the summaries") func localAuthorExcluded() throws { let fixture = try makeFixture() @@ -272,7 +321,7 @@ struct SessionMonitorTests { cells: [position(0, 1): ("B", Date())] ) - let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) #expect(summaries.count == 1) #expect(summaries.first?.authorID == Self.alice) } @@ -294,7 +343,7 @@ struct SessionMonitorTests { ] ) - let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) #expect(summaries.count == 2) let alice = try #require(summaries.first { $0.authorID == Self.alice }) let bob = try #require(summaries.first { $0.authorID == Self.bob }) @@ -320,7 +369,7 @@ struct SessionMonitorTests { cells: [position(0, 1): ("B", Date(timeIntervalSince1970: 200))] ) - let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) let summary = try #require(summaries.first) // Both devices' filled cells roll into Alice's single across-devices // total — the receiver banner should not split iPad/iPhone work. @@ -343,7 +392,7 @@ struct SessionMonitorTests { authorID: Self.bob, cells: [position(0, 1): ("B", Date())] ) - _ = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + fixture.monitor.commitMovesBaseline(for: fixture.gameID) fixture.monitor.clearMovesSnapshots(for: fixture.gameID, by: nil) @@ -359,7 +408,7 @@ struct SessionMonitorTests { position(2, 2): ("H", Date()), ] ) - let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) let alice = try #require(summaries.first { $0.authorID == Self.alice }) // Alice now has 2 filled cells, baseline was reset to .empty, so the // diff is 2 against an empty baseline — Alice's net "added since @@ -382,7 +431,7 @@ struct SessionMonitorTests { authorID: Self.bob, cells: [position(0, 1): ("B", Date())] ) - _ = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + fixture.monitor.commitMovesBaseline(for: fixture.gameID) fixture.monitor.clearMovesSnapshots(for: fixture.gameID, by: Self.alice) @@ -402,7 +451,7 @@ struct SessionMonitorTests { position(2, 2): ("H", Date()), ] ) - let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) let alice = try #require(summaries.first { $0.authorID == Self.alice }) let bob = try #require(summaries.first { $0.authorID == Self.bob }) // Alice's baseline was reset, so her current 2 filled cells diff @@ -428,9 +477,9 @@ struct SessionMonitorTests { // Simulate the sibling-device readAt sync: pretend the catch-up // banner was already shown elsewhere, so the baseline jumps to // current without consuming. - fixture.monitor.refreshMovesSnapshots(for: fixture.gameID) + fixture.monitor.commitMovesBaseline(for: fixture.gameID) - let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) #expect(summaries.isEmpty) } @@ -442,7 +491,7 @@ struct SessionMonitorTests { authorID: Self.alice, cells: [position(0, 0): ("A", Date())] ) - fixture.monitor.refreshMovesSnapshots(for: fixture.gameID) + fixture.monitor.commitMovesBaseline(for: fixture.gameID) try writeMoves( in: fixture, @@ -452,7 +501,7 @@ struct SessionMonitorTests { position(0, 1): ("B", Date()), ] ) - let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) let summary = try #require(summaries.first) #expect(summary.added == 1) #expect(!summary.isFirstObservation) @@ -474,13 +523,13 @@ struct SessionMonitorTests { cells: [position(2, 2): ("H", Date())] ) - fixture.monitor.refreshMovesSnapshots(for: fixture.gameID) + fixture.monitor.commitMovesBaseline(for: fixture.gameID) // Local author's PlayerEntity (if one exists) should not have had // its lastMovesSnapshot written by adoption; only peers do. The // observable proxy is that consume still ignores the local author // and reports nothing about them. - let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + let summaries = fixture.monitor.movesSummaries(for: fixture.gameID) #expect(summaries.allSatisfy { $0.authorID != Self.localAuthorID }) } }