crossmate

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

commit 1bb923cb407c5683e9591bb24a16a5789cfe30e1
parent 9052bda0be81c81b035f94a5990166e7e78b63d9
Author: Michael Camilleri <[email protected]>
Date:   Thu, 28 May 2026 12:06:32 +0900

Refresh peer baselines on sibling readAt sync

Prior to this commit, each device keeps its own local lastMovesSnapshotData on
PlayerEntity as the per-peer cursor for the catch-up banner ('Bob added 30
letters since you last opened'). When the user opens the puzzle on the iPad and
then on the iPhone, the iPad's open advanced the iPad's cursor and wrote
Player.readAt; the iPhone received the readAt, but its own local cursor still
pointed at the older state — so the next the iPhone open re-showed the same
banner the user had already read.

Player.readAt syncing for the local author now also calls a new
SessionMonitor.refreshMovesSnapshots(for:) that snaps every peer's baseline on
this device forward to the merged-moves state currently visible. A sibling's
read has already shown the banner content elsewhere, so the local device should
diff against now, not against its stale per-device cursor.

The receiver-side baseline stays a local-only PlayerEntity attribute (unchanged
from e61e0c1) — only the trigger for advancing it gains a cross-device path via
readAt. This creates potential imprecision: peer moves that landed on this
device between the sibling's open and the readAt update get lost and are not
part of the new baseline.

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

Diffstat:
MCrossmate/Services/AppServices.swift | 6+++++-
MCrossmate/Sync/SessionMonitor.swift | 21+++++++++++++++++++++
MTests/Unit/Sync/SessionMonitorTests.swift | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 98 insertions(+), 1 deletion(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -383,10 +383,14 @@ final class AppServices { // delivered for that game (e.g. "X is solving"); opening it here is no // longer something to nudge for. A past readAt is just a closed-session // horizon bump and leaves delivered notifications untouched. - await syncEngine.setOnIncomingReadCursor { [weak self, store] pairs in + await syncEngine.setOnIncomingReadCursor { [weak self, store, sessionMonitor] pairs in let now = Date() for (gameID, readAt) 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) if readAt > now { await self?.dismissDeliveredNotifications(for: gameID) } diff --git a/Crossmate/Sync/SessionMonitor.swift b/Crossmate/Sync/SessionMonitor.swift @@ -107,6 +107,27 @@ 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) { + let localAuthorID = localAuthorIDProvider() + let peers = store.peerAuthorIDs(for: gameID, excluding: localAuthorID) + for authorID in peers { + let current = store.movesSnapshot(for: gameID, by: authorID, on: nil) + store.setLastMovesSnapshot(current, for: gameID, by: authorID) + } + } + /// Drops the persisted baseline for `(gameID, authorID)`. Pass /// `authorID == nil` to clear every author's baseline for the game — /// used when the user opens the puzzle elsewhere or the Game gains a diff --git a/Tests/Unit/Sync/SessionMonitorTests.swift b/Tests/Unit/Sync/SessionMonitorTests.swift @@ -413,4 +413,76 @@ struct SessionMonitorTests { #expect(alice.added == 2) #expect(bob.added == 1) } + + // MARK: - Sibling-readAt baseline adoption + + @Test("Adopting current snapshots suppresses the catch-up banner on the next consume") + func adoptSuppressesNextBanner() throws { + let fixture = try makeFixture() + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [ + position(0, 0): ("A", Date()), + position(0, 1): ("B", Date()), + ] + ) + // 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) + + let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + #expect(summaries.isEmpty) + } + + @Test("Peers who write after adoption still surface in the next banner") + func adoptThenLaterEditsStillReport() throws { + let fixture = try makeFixture() + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [position(0, 0): ("A", Date())] + ) + fixture.monitor.refreshMovesSnapshots(for: fixture.gameID) + + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [ + position(0, 0): ("A", Date()), + position(0, 1): ("B", Date()), + ] + ) + let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + let summary = try #require(summaries.first) + #expect(summary.added == 1) + #expect(!summary.isFirstObservation) + } + + @Test("Adoption skips the local author's Moves row") + func adoptSkipsLocalAuthor() throws { + let fixture = try makeFixture() + // Local user has some letters; should not become a baseline target. + try writeMoves( + in: fixture, + authorID: Self.localAuthorID, + cells: [position(0, 0): ("A", Date())] + ) + // And Alice has some too. + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [position(2, 2): ("H", Date())] + ) + + fixture.monitor.refreshMovesSnapshots(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) + #expect(summaries.allSatisfy { $0.authorID != Self.localAuthorID }) + } }