crossmate

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

commit 9052bda0be81c81b035f94a5990166e7e78b63d9
parent e11b8ed41f538acb1a502254f66c3344e8c9c41f
Author: Michael Camilleri <[email protected]>
Date:   Thu, 28 May 2026 11:44:48 +0900

Share session snapshot across an author's devices

A player on multiple devices used to send one pause push per device, each
reporting only the letters typed on that device — peers got '+30 from iPad'
then '+10 from iPhone' instead of one cumulative '+40'. The baseline that
anchors the diff lived in an in-memory LocalSessionTracker per device, with no
way for sibling devices to share it.

The shared baseline now lives on the Player CKRecord as a new sessionSnapshot
Bytes field, mirrored locally on PlayerEntity. On session-begin the device
captures the author's merged-across-devices snapshot (or adopts an existing one
if Player.updatedAt is within 30 minutes — anything older is treated as a
crashed-session leftover and overwritten). On session-end the device diffs
current merged-author state against that baseline so whichever device pauses
last reports the cumulative delta, then clears the field so the next begin
re-anchors fresh.

To stop multiple devices each emitting a pause push for the same session,
scheduleSessionEndPush captures pauseStart at scheduling time and
publishSessionEndPush suppresses the push when Player.updatedAt has advanced
past pauseStart during the grace window — the local device wrote nothing in
that window by construction, so a fresher updatedAt can only mean another
device of this author is still active. Its eventual pause will report
cumulatively against the still-intact baseline.

LocalSessionTracker is gone; LocalMovesSnapshot moves to Models/ now that both
sender and receiver baselines flow through GameStore. The receiver-side
per-device lastMovesSnapshotData is unchanged — that's a separate 'what this
device has seen of each peer' cursor, deliberately not cross-device.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 12++++++++----
MCrossmate/CrossmateApp.swift | 2+-
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 1+
ACrossmate/Models/LocalMovesSnapshot.swift | 24++++++++++++++++++++++++
MCrossmate/Persistence/GameStore.swift | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/AppServices.swift | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
DCrossmate/Services/LocalSessionTracker.swift | 53-----------------------------------------------------
MCrossmate/Sync/RecordApplier.swift | 1+
MCrossmate/Sync/RecordBuilder.swift | 1+
MCrossmate/Sync/RecordSerializer.swift | 15+++++++++++++++
ATests/Unit/GameStoreSessionSnapshotTests.swift | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 337 insertions(+), 98 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; }; 3C54AE4AA04342CCF5705B20 /* PlayerNamePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */; }; 40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */; }; + 42D795339FFF80C3E730395B /* GameStoreSessionSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9E3B149B506E74AC1B89892 /* GameStoreSessionSnapshotTests.swift */; }; 449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */; }; 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; }; 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; }; @@ -47,6 +48,7 @@ 5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */; }; 5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */; }; 5FB26F40F5DB52111E3D1BDC /* CheckResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */; }; + 66A2B6DAB2F132950789CA98 /* LocalMovesSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */; }; 6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B766E872B12DC79ECCD80941 /* FriendModelTests.swift */; }; 6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; }; 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; }; @@ -125,7 +127,6 @@ ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */; }; F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */; }; F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */; }; - F63FCB29A7E9EE5208ED2C7E /* LocalSessionTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19C3ED4479869ADC66808C7D /* LocalSessionTracker.swift */; }; F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */; }; F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */; }; FFBE2EC8A3A60E119A0D314F /* NYTBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */; }; @@ -156,7 +157,6 @@ 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebuggingMonitors.swift; sourceTree = "<group>"; }; 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRosterTests.swift; sourceTree = "<group>"; }; 18C701DAE36000DE19F7CC95 /* EngagementHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementHost.swift; sourceTree = "<group>"; }; - 19C3ED4479869ADC66808C7D /* LocalSessionTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalSessionTracker.swift; sourceTree = "<group>"; }; 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementCenter.swift; sourceTree = "<group>"; }; 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; }; 20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; }; @@ -198,6 +198,7 @@ 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisher.swift; sourceTree = "<group>"; }; 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; }; 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; + 7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMovesSnapshot.swift; sourceTree = "<group>"; }; 7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendZone.swift; sourceTree = "<group>"; }; 7B3E1A382B24A7803701D947 /* Crossmate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Crossmate.entitlements; sourceTree = "<group>"; }; 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; }; @@ -266,6 +267,7 @@ F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; }; F91707302CBA406BF7FB8F8A /* FriendAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendAvatarView.swift; sourceTree = "<group>"; }; F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; + F9E3B149B506E74AC1B89892 /* GameStoreSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreSessionSnapshotTests.swift; sourceTree = "<group>"; }; FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUZToXDConverterTests.swift; sourceTree = "<group>"; }; FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationNavigationBrokerTests.swift; sourceTree = "<group>"; }; FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisherTests.swift; sourceTree = "<group>"; }; @@ -322,6 +324,7 @@ 978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */, 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */, BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */, + F9E3B149B506E74AC1B89892 /* GameStoreSessionSnapshotTests.swift */, 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */, 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */, 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */, @@ -356,6 +359,7 @@ B09D52DB46731E92C3E9297C /* EngagementStore.swift */, 465F2BB469EFE84CF3733398 /* Game.swift */, 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */, + 7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */, DB55FC337CF72C650373210A /* PlayerColor.swift */, 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */, 3292748EAE27B608C769D393 /* PlayerRoster.swift */, @@ -491,7 +495,6 @@ 462CE0FD356F6137C9BFD30F /* ImportService.swift */, 6BDD06460A76D4AF31077732 /* InputMonitor.swift */, 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */, - 19C3ED4479869ADC66808C7D /* LocalSessionTracker.swift */, A253416F4FEA271A80B22A73 /* NYTAuthService.swift */, B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */, CE54EF557E8D808BAA20EA54 /* NYTPuzzleUpgrader.swift */, @@ -607,6 +610,7 @@ 712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */, 85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */, 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */, + 42D795339FFF80C3E730395B /* GameStoreSessionSnapshotTests.swift in Sources */, 449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */, AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */, DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */, @@ -685,7 +689,7 @@ 1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */, F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */, 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */, - F63FCB29A7E9EE5208ED2C7E /* LocalSessionTracker.swift in Sources */, + 66A2B6DAB2F132950789CA98 /* LocalMovesSnapshot.swift in Sources */, 91703E54DB4679C1911BF994 /* Moves.swift in Sources */, 4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */, 1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -655,7 +655,7 @@ private struct PuzzleDisplayView: View { Task { services.handlePuzzleOpened(gameID: id) if !resumed { - await services.publishSessionStartPush(gameID: id) + await services.publishSessionBeginPush(gameID: id) } } case .background: diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -41,6 +41,7 @@ <attribute name="selCol" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/> <attribute name="selDir" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/> <attribute name="selRow" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/> + <attribute name="sessionSnapshot" optional="YES" attributeType="Binary"/> <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> <relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="players" inverseEntity="GameEntity"/> <fetchIndex name="byGameAndAuthor"> diff --git a/Crossmate/Models/LocalMovesSnapshot.swift b/Crossmate/Models/LocalMovesSnapshot.swift @@ -0,0 +1,24 @@ +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. +struct LocalMovesSnapshot: Equatable, Sendable, Codable { + /// Positions where the Moves row currently has a non-empty letter. + let filled: Set<GridPosition> + /// Positions where the Moves row currently has an explicit empty entry + /// (cells the user cleared, including ones a peer had filled). + let cleared: Set<GridPosition> + + static let empty = LocalMovesSnapshot(filled: [], cleared: []) + + func encoded() throws -> Data { + try JSONEncoder().encode(self) + } + + static func decode(_ data: Data) throws -> LocalMovesSnapshot { + try JSONDecoder().decode(LocalMovesSnapshot.self, from: data) + } +} diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -917,6 +917,57 @@ final class GameStore { saveContext("setLastMovesSnapshot") } + /// Returns the author's current `sessionSnapshot` (decoded) and Player + /// `updatedAt` for `(gameID, authorID)`. Both fields are nil when no + /// PlayerEntity row exists yet. The snapshot is the baseline an active + /// session writes at begin and clears at end; `updatedAt` doubles as the + /// staleness check (an old snapshot with a fresh `updatedAt` is live; + /// an old snapshot with an old `updatedAt` is a crash leftover) and as + /// the peer-device liveness probe during the pause grace window. + func sessionState( + for gameID: UUID, + by authorID: String + ) -> (snapshot: LocalMovesSnapshot?, updatedAt: Date?) { + guard let entity = fetchPlayerEntity(gameID: gameID, authorID: authorID) else { + return (nil, nil) + } + let snapshot = entity.sessionSnapshot.flatMap { try? LocalMovesSnapshot.decode($0) } + return (snapshot, entity.updatedAt) + } + + /// Writes `snapshot` (encoded) to `PlayerEntity.sessionSnapshot` and + /// bumps `updatedAt` to `now`. Pass `nil` to clear the field at session + /// end. Creates a stub PlayerEntity if none exists yet, mirroring + /// `setLastMovesSnapshot`. Unlike `setLastMovesSnapshot`, the value is + /// included in the outbound Player CKRecord — the caller is responsible + /// for calling `SyncEngine.enqueuePlayer` to ship the change. + func setSessionSnapshot( + _ snapshot: LocalMovesSnapshot?, + for gameID: UUID, + by authorID: String, + now: Date = Date() + ) { + 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.sessionSnapshot = snapshot.flatMap { try? $0.encoded() } + entity.updatedAt = now + saveContext("setSessionSnapshot") + } + /// Distinct authorIDs that have written a `MovesEntity` for `gameID`, /// with `excluding` filtered out. The session-summary banner uses this /// to enumerate peers whose activity it should diff. diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -58,12 +58,19 @@ final class AppServices { let playerSelectionPublisher: PlayerSelectionPublisher let identity: AuthorIdentity let pushClient: PushClient? - let localSessionTracker = LocalSessionTracker() /// Grace window before a backgrounded session is treated as ended. A /// briefly-backgrounded puzzle (phone sleep, app switcher peek, taking a /// call) should not fan out pause/play pings to peers on every flicker — /// only a sustained absence does. static let sessionEndGrace: TimeInterval = 120 + /// Window past which a non-nil `Player.sessionSnapshot` is treated as a + /// crashed-session leftover rather than a live session belonging to + /// another device of this author. Comfortably exceeds any plausible + /// mid-session idle gap (selection writes, readAt writes, and the + /// pause-grace window are all much shorter), and well below "user came + /// back the next day" so a real abandoned baseline doesn't suppress a + /// genuine new-session begin. + static let sessionSnapshotStaleness: TimeInterval = 30 * 60 private var pendingSessionEndTasks: [UUID: Task<Void, Never>] = [:] let shareController: ShareController let friendController: FriendController @@ -600,26 +607,21 @@ final class AppServices { await publishCompletionPush(gameID: gameID, resigned: resigned) } - /// Sender-side session-start push. Replaces the receiver-side + /// Sender-side session-begin push. Replaces the receiver-side /// `SessionMonitor.presentBegins(...)` path: the opening device owns the /// notification timing, so peers get "Alice is solving X" the instant - /// Alice opens the puzzle. Also resets the local edit tracker so the - /// matching pause push counts only this segment. Kind is `play` (rather - /// than `join`) to avoid collision with the `PingKind.join` invite-accept - /// ping — the user-facing event is "started playing", not "joined". - func publishSessionStartPush(gameID: UUID) async { + /// Alice opens the puzzle. Anchors the matching pause push by adopting + /// or writing a shared `Player.sessionSnapshot` so the cumulative delta + /// reported at session end spans every device of this author, not just + /// this one. Kind is `play` (rather than `join`) to avoid collision with + /// the `PingKind.join` invite-accept ping — the user-facing event is + /// "started playing", not "joined". + func publishSessionBeginPush(gameID: UUID) async { guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { syncMonitor.note("push(play): skipped (no authorID)") return } - localSessionTracker.begin( - gameID: gameID, - snapshot: store.movesSnapshot( - for: gameID, - by: localAuthorID, - on: RecordSerializer.localDeviceID - ) - ) + await anchorSessionSnapshot(gameID: gameID, authorID: localAuthorID) guard let pushClient else { syncMonitor.note("push(play): skipped (no pushClient)") return @@ -652,32 +654,53 @@ final class AppServices { ) } - /// Sender-side session-end push. Drains the local edit tracker, formats - /// the same summary text the receiver-side quiescence path used to build - /// (`SessionMonitor.bodyText`), and ships it. A session with no edits - /// (opened and closed without typing, or already drained by a prior - /// background-publish) skips the push entirely. Kind is `pause` — the - /// user is stepping away from a puzzle they may return to, not - /// permanently leaving the game. + /// Adopts `Player.sessionSnapshot` if a fresh one already exists (another + /// device of this author began a session within the staleness window), + /// otherwise captures the author's merged-across-devices snapshot and + /// writes it to Player. Either way, when the matching pause push fires + /// on whichever device pauses last, it diffs against this shared + /// baseline so the reported delta covers every device's contribution. + private func anchorSessionSnapshot(gameID: UUID, authorID: String) async { + let now = Date() + let state = store.sessionState(for: gameID, by: authorID) + if state.snapshot != nil, + let updatedAt = state.updatedAt, + now.timeIntervalSince(updatedAt) < Self.sessionSnapshotStaleness { + syncMonitor.note("session-snapshot: adopted") + return + } + let snapshot = store.movesSnapshot(for: gameID, by: authorID, on: nil) + store.setSessionSnapshot(snapshot, for: gameID, by: authorID, now: now) + await syncEngine.enqueuePlayer( + gameID: gameID, + authorID: authorID, + reason: "session-begin" + ) + } + /// Defer the session-end push by `seconds`. Cancels any previously /// scheduled pause for the same game. If the user resumes within the /// grace window, call `cancelPendingSessionEndPush` to drop the timer - /// and skip the matching session-start push so peers don't get a - /// pause/play pair for a brief absence. + /// and skip the matching session-begin push so peers don't get a + /// pause/play pair for a brief absence. The wall-clock at scheduling + /// time is passed through so the fire-time peer-device-active check has + /// a stable reference point for "did anyone other than me write to + /// Player during the grace window." func scheduleSessionEndPush(gameID: UUID, after seconds: TimeInterval) { + let pauseStart = Date() pendingSessionEndTasks[gameID]?.cancel() pendingSessionEndTasks[gameID] = Task { [weak self] in try? await Task.sleep(for: .seconds(seconds)) guard !Task.isCancelled else { return } guard let self else { return } self.pendingSessionEndTasks[gameID] = nil - await self.publishSessionEndPush(gameID: gameID) + await self.publishSessionEndPush(gameID: gameID, pauseStart: pauseStart) } } /// Cancel any pending scheduled session-end push for `gameID`. Returns /// `true` if a pending task was dropped, i.e. the caller is inside the - /// grace window and should suppress the matching session-start push. + /// grace window and should suppress the matching session-begin push. @discardableResult func cancelPendingSessionEndPush(gameID: UUID) -> Bool { guard let task = pendingSessionEndTasks.removeValue(forKey: gameID) else { @@ -687,7 +710,14 @@ final class AppServices { return true } - func publishSessionEndPush(gameID: UUID) async { + /// Sender-side session-end push. Diffs the author's current + /// merged-across-devices Moves snapshot against the shared + /// `Player.sessionSnapshot` baseline and ships the summary. Suppresses + /// the push when a peer device of this author wrote to Player during + /// the grace window — that device is still playing and will report the + /// cumulative delta when it pauses. Otherwise clears the shared + /// snapshot so the next session-begin re-anchors fresh. + func publishSessionEndPush(gameID: UUID, pauseStart: Date = Date()) async { // A direct call (e.g. from `.onDisappear`) supersedes any pending // grace-window timer for this game — drop it so we don't fire a // second pause once the timer elapses. @@ -696,15 +726,35 @@ final class AppServices { syncMonitor.note("push(pause): skipped (no authorID)") return } - let counts = localSessionTracker.consume( + let state = store.sessionState(for: gameID, by: localAuthorID) + // During the grace window this device wrote nothing to Player + // (any local activity would have reset the timer via + // `cancelPendingSessionEndPush`). A Player `updatedAt` newer than + // pauseStart therefore came from another device of this author — + // that device is still active, leave the baseline intact so its + // eventual pause reports cumulatively. + if let updatedAt = state.updatedAt, updatedAt > pauseStart { + syncMonitor.note("push(pause): skipped (peer device active)") + return + } + guard let baseline = state.snapshot else { + syncMonitor.note("push(pause): skipped (no session snapshot)") + return + } + let current = store.movesSnapshot(for: gameID, by: localAuthorID, on: nil) + let added = current.filled.subtracting(baseline.filled).count + let cleared = current.cleared.subtracting(baseline.cleared).count + // Session is over from this device's perspective. Clear the shared + // snapshot regardless of whether we actually send a push — no-edit + // sessions, no-recipient sessions, and completed games all still + // need the field cleared so a future begin re-anchors fresh. + store.setSessionSnapshot(nil, for: gameID, by: localAuthorID) + await syncEngine.enqueuePlayer( gameID: gameID, - snapshot: store.movesSnapshot( - for: gameID, - by: localAuthorID, - on: RecordSerializer.localDeviceID - ) + authorID: localAuthorID, + reason: "session-end" ) - guard counts.added > 0 || counts.cleared > 0 else { + guard added > 0 || cleared > 0 else { syncMonitor.note("push(pause): skipped (no edits)") return } @@ -717,7 +767,7 @@ final class AppServices { syncMonitor.note("push(pause): skipped (no recipients)") return } - // Symmetric with `publishSessionStartPush`: a finished or revoked + // Symmetric with `publishSessionBeginPush`: a finished or revoked // game has no live play session, so a pause summary is meaningless. guard plan.completedAt == nil else { syncMonitor.note("push(pause): skipped (game completed)") @@ -730,11 +780,11 @@ final class AppServices { let playerName = preferences.name.isEmpty ? "A player" : preferences.name let puzzleSuffix = plan.title.isEmpty ? "the puzzle" : "the puzzle '\(plan.title)'" var parts: [String] = [] - if counts.added > 0 { - parts.append("added \(counts.added) \(counts.added == 1 ? "letter" : "letters")") + if added > 0 { + parts.append("added \(added) \(added == 1 ? "letter" : "letters")") } - if counts.cleared > 0 { - parts.append("cleared \(counts.cleared) \(counts.cleared == 1 ? "letter" : "letters")") + if cleared > 0 { + parts.append("cleared \(cleared) \(cleared == 1 ? "letter" : "letters")") } let action = parts.joined(separator: " and ") await pushClient.publish( @@ -1319,7 +1369,7 @@ final class AppServices { } } // Session-start notifications now ride on sender-side APNs - // (see `publishSessionStartPush`); the receiver-side + // (see `publishSessionBeginPush`); the receiver-side // `presentBegins` path is no longer wired up. The catch-up // banner that summarises peer adds/clears still consumes the // SessionMonitor buckets via `consumeOnOpen` — see diff --git a/Crossmate/Services/LocalSessionTracker.swift b/Crossmate/Services/LocalSessionTracker.swift @@ -1,53 +0,0 @@ -import Foundation - -/// A point-in-time view of this device's `MovesEntity` row for a game, -/// partitioned by whether the cell currently holds a letter. The session -/// tracker captures one at session start and one at session end, then diffs -/// each set to derive net adds and net clears. -struct LocalMovesSnapshot: Equatable, Sendable, Codable { - /// Positions where the local Moves row currently has a non-empty letter. - let filled: Set<GridPosition> - /// Positions where the local Moves row currently has an explicit empty - /// entry (cells the user cleared, including ones a peer had filled). - let cleared: Set<GridPosition> - - static let empty = LocalMovesSnapshot(filled: [], cleared: []) - - func encoded() throws -> Data { - try JSONEncoder().encode(self) - } - - static func decode(_ data: Data) throws -> LocalMovesSnapshot { - try JSONDecoder().decode(LocalMovesSnapshot.self, from: data) - } -} - -/// Snapshots the local Moves row at session start, then diffs against the -/// same row at session end to build the body text of the pause APN. Net -/// delta — not keystroke count — so overtypes and trial-and-error don't -/// inflate the figure. Reset on every `begin(gameID:snapshot:)` so a -/// background/foreground cycle on the same puzzle starts each segment from -/// a fresh baseline. -@MainActor -final class LocalSessionTracker { - private(set) var activeGameID: UUID? - private var baseline: LocalMovesSnapshot = .empty - - func begin(gameID: UUID, snapshot: LocalMovesSnapshot) { - activeGameID = gameID - baseline = snapshot - } - - /// Diffs `snapshot` against the baseline captured at `begin` and clears - /// state. A nil or mismatched `gameID` returns zero counts and leaves - /// state untouched, so a stray end-call from a stale view doesn't drain a - /// fresh session. - func consume(gameID: UUID, snapshot: LocalMovesSnapshot) -> (added: Int, cleared: Int) { - guard gameID == activeGameID else { return (0, 0) } - let added = snapshot.filled.subtracting(baseline.filled).count - let cleared = snapshot.cleared.subtracting(baseline.cleared).count - baseline = .empty - activeGameID = nil - return (added, cleared) - } -} diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -193,6 +193,7 @@ extension SyncEngine { } let incomingReadAt = RecordSerializer.parsePlayerReadAt(from: record) entity.readAt = incomingReadAt + entity.sessionSnapshot = RecordSerializer.parsePlayerSessionSnapshot(from: record) if authorID == localAuthorID, let readAt = incomingReadAt { onReadCursor(gameID, readAt) } diff --git a/Crossmate/Sync/RecordBuilder.swift b/Crossmate/Sync/RecordBuilder.swift @@ -99,6 +99,7 @@ extension SyncEngine { updatedAt: updatedAt, selection: selection, readAt: entity.game?.lastReadOtherMoveAt, + sessionSnapshot: entity.sessionSnapshot, zone: zoneID, systemFields: entity.ckSystemFields ) diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -247,6 +247,7 @@ enum RecordSerializer { updatedAt: Date, selection: PlayerSelection?, readAt: Date? = nil, + sessionSnapshot: Data? = nil, zone: CKRecordZone.ID, systemFields: Data? ) -> CKRecord { @@ -275,6 +276,11 @@ enum RecordSerializer { } else { record["readAt"] = nil } + if let sessionSnapshot { + record["sessionSnapshot"] = sessionSnapshot as CKRecordValue + } else { + record["sessionSnapshot"] = nil + } return record } @@ -304,6 +310,15 @@ enum RecordSerializer { return PlayerSelection(row: Int(row), col: Int(col), direction: direction) } + /// 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. + static func parsePlayerSessionSnapshot(from record: CKRecord) -> Data? { + record["sessionSnapshot"] as? Data + } + /// Parses an incoming `Player` record name back into its `(gameID, /// authorID)` components. Returns `nil` if the name doesn't match the /// `player-<UUID>-<authorID>` shape. diff --git a/Tests/Unit/GameStoreSessionSnapshotTests.swift b/Tests/Unit/GameStoreSessionSnapshotTests.swift @@ -0,0 +1,145 @@ +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +/// Pins down the shared per-author session-snapshot field on `PlayerEntity`. +/// The sender-side pause push anchors against this baseline so the cumulative +/// delta spans every device of an author, not just the device emitting the +/// push (see `AppServices.anchorSessionSnapshot` / `publishSessionEndPush`). +@Suite("GameStore session snapshot", .isolatedNotificationState) +@MainActor +struct GameStoreSessionSnapshotTests { + + private static let authorID = "author-1" + + private static let puzzleSource = """ + Title: Test Puzzle + Author: Test + + + ABC + D#E + FGH + + + A1. Across 1 ~ ABC + A4. Across 4 ~ DE + A5. Across 5 ~ FGH + D1. Down 1 ~ ADF + D2. Down 2 ~ BG + D3. Down 3 ~ CEH + """ + + private struct Fixture { + let persistence: PersistenceController + let store: GameStore + let gameID: UUID + } + + private func makeFixture() throws -> Fixture { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let ctx = persistence.viewContext + let gameID = UUID() + let xd = try XD.parse(Self.puzzleSource) + let entity = GameEntity(context: ctx) + entity.id = gameID + entity.title = "Test" + entity.puzzleSource = Self.puzzleSource + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = "game-\(gameID.uuidString)" + entity.populateCachedSummaryFields(from: Puzzle(xd: xd)) + try ctx.save() + return Fixture(persistence: persistence, store: store, gameID: gameID) + } + + private func snapshot(filled: [(Int, Int)] = [], cleared: [(Int, Int)] = []) -> LocalMovesSnapshot { + LocalMovesSnapshot( + filled: Set(filled.map { GridPosition(row: $0.0, col: $0.1) }), + cleared: Set(cleared.map { GridPosition(row: $0.0, col: $0.1) }) + ) + } + + @Test("sessionState returns (nil, nil) when no PlayerEntity exists") + func sessionStateMissingRow() throws { + let fixture = try makeFixture() + let state = fixture.store.sessionState(for: fixture.gameID, by: Self.authorID) + #expect(state.snapshot == nil) + #expect(state.updatedAt == nil) + } + + @Test("setSessionSnapshot creates a stub PlayerEntity and round-trips the value") + func setSessionSnapshotCreatesStub() throws { + let fixture = try makeFixture() + let baseline = snapshot(filled: [(0, 0), (0, 1)], cleared: [(2, 2)]) + let now = Date() + + fixture.store.setSessionSnapshot(baseline, for: fixture.gameID, by: Self.authorID, now: now) + + let state = fixture.store.sessionState(for: fixture.gameID, by: Self.authorID) + #expect(state.snapshot == baseline) + #expect(state.updatedAt == now) + } + + @Test("setSessionSnapshot(nil) clears the field while leaving the row intact") + func setSessionSnapshotClears() throws { + let fixture = try makeFixture() + let baseline = snapshot(filled: [(0, 0)]) + fixture.store.setSessionSnapshot(baseline, for: fixture.gameID, by: Self.authorID) + + let clearAt = Date().addingTimeInterval(10) + fixture.store.setSessionSnapshot(nil, for: fixture.gameID, by: Self.authorID, now: clearAt) + + let state = fixture.store.sessionState(for: fixture.gameID, by: Self.authorID) + #expect(state.snapshot == nil) + // updatedAt is still bumped — the clear write is itself activity that + // peer devices' suppression checks must see. + #expect(state.updatedAt == clearAt) + } + + @Test("setSessionSnapshot bumps updatedAt on every write") + func setSessionSnapshotBumpsUpdatedAt() throws { + let fixture = try makeFixture() + let first = Date() + fixture.store.setSessionSnapshot(snapshot(filled: [(0, 0)]), for: fixture.gameID, by: Self.authorID, now: first) + let firstState = fixture.store.sessionState(for: fixture.gameID, by: Self.authorID) + #expect(firstState.updatedAt == first) + + let second = first.addingTimeInterval(60) + fixture.store.setSessionSnapshot(snapshot(filled: [(0, 0), (0, 1)]), for: fixture.gameID, by: Self.authorID, now: second) + let secondState = fixture.store.sessionState(for: fixture.gameID, by: Self.authorID) + #expect(secondState.updatedAt == second) + #expect(secondState.snapshot == snapshot(filled: [(0, 0), (0, 1)])) + } + + @Test("setSessionSnapshot reuses an existing PlayerEntity rather than creating a duplicate") + func setSessionSnapshotReusesRow() throws { + let fixture = try makeFixture() + let ctx = fixture.persistence.viewContext + let recordName = RecordSerializer.recordName( + forPlayerInGame: fixture.gameID, + authorID: Self.authorID + ) + let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameReq.predicate = NSPredicate(format: "id == %@", fixture.gameID as CVarArg) + let game = try #require(try ctx.fetch(gameReq).first) + let existing = PlayerEntity(context: ctx) + existing.game = game + existing.authorID = Self.authorID + existing.ckRecordName = recordName + existing.name = "Original" + existing.updatedAt = Date() + try ctx.save() + + fixture.store.setSessionSnapshot(snapshot(filled: [(1, 0)]), for: fixture.gameID, by: Self.authorID) + + let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + req.predicate = NSPredicate(format: "ckRecordName == %@", recordName) + let rows = try ctx.fetch(req) + #expect(rows.count == 1) + #expect(rows.first?.name == "Original") + } +}