crossmate

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

commit e61e0c123d9c469ad2f5bcddaf346c949020fbb9
parent 386810a1adddbbd83b4831dc91c82d215b232ac4
Author: Michael Camilleri <[email protected]>
Date:   Thu, 28 May 2026 07:52:48 +0900

Use session-activity calculations consistently

The pause-push body and the in-app catch-up banner described the same activity
in different terms. The sender pushed letter counts from an in-memory keystroke
tally, while the receiver accumulated per-(gameID, authorID) deltas computed by
diffing the merged grid before and after each incoming Moves batch. The two
disagreed by construction: substituting a peer's letter counted +1 add for the
sender but 0 for the receiver, in-session add-then-clear netted out on the
sender but accumulated separately on the receiver, and a 180s quiescence cutoff
applied only on the receiver side.

This commit pulls both onto the same algorithm: diff a LocalMovesSnapshot of
the author's MovesEntity row — partitioned into filled vs explicitly-cleared
positions — against a stored baseline. The sender scopes to the local device so
overlapping iPad/iPhone sessions on the same author don't double-report, and
the receiver merges across all of that peer's devices via GridStateMerger.
Receiver baselines persist on a new local-only lastMovesSnapshotData attribute
on PlayerEntity, kept out of RecordSerializer so each device tracks its own
'last consumed' cursor independently.

The first observation of a peer has no baseline to diff against, and a
brand-new shared-game join shouldn't either silence the banner or claim 'Alice
added 50 letters' as live activity. SessionSummary gains an isFirstObservation
flag and the formatter emits 'Alice has added 50 letters so far' to surface
cumulative context once, with subsequent opens reverting to the delta wording.

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

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# On branch master
# Your branch is ahead of 'inqk/master' by 15 commits.
#   (use "git push" to publish your local commits)
#
# Changes to be committed:
#	modified:   Crossmate.xcodeproj/project.pbxproj
#	modified:   Crossmate/CrossmateApp.swift
#	modified:   Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
#	modified:   Crossmate/Persistence/GameStore.swift
#	modified:   Crossmate/Services/AppServices.swift
#	modified:   Crossmate/Services/LocalSessionTracker.swift
#	modified:   Crossmate/Sync/RecordApplier.swift
#	modified:   Crossmate/Sync/SessionMonitor.swift
#	modified:   Crossmate/Sync/SyncEngine.swift
#	deleted:    Tests/Unit/Sync/AuthorDeltaTests.swift
#	modified:   Tests/Unit/Sync/SessionMonitorTests.swift
#

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4----
MCrossmate/CrossmateApp.swift | 2+-
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 1+
MCrossmate/Persistence/GameStore.swift | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/AppServices.swift | 64+++++++++++++++++++++++++++++++++++++---------------------------
MCrossmate/Services/LocalSessionTracker.swift | 65++++++++++++++++++++++++++++++++++++++++-------------------------
MCrossmate/Sync/RecordApplier.swift | 137-------------------------------------------------------------------------------
MCrossmate/Sync/SessionMonitor.swift | 228+++++++++++++++++++++++++++++++++----------------------------------------------
MCrossmate/Sync/SyncEngine.swift | 28----------------------------
DTests/Unit/Sync/AuthorDeltaTests.swift | 232-------------------------------------------------------------------------------
MTests/Unit/Sync/SessionMonitorTests.swift | 507+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
11 files changed, 697 insertions(+), 713 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -95,7 +95,6 @@ BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */; }; C1930083671621AC79CF95DD /* MovesUpdaterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */; }; C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */; }; - C2D8A9C79D75DBEF45720927 /* AuthorDeltaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2570F892610F1834C92F7F6E /* AuthorDeltaTests.swift */; }; C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; }; C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */; }; C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */; }; @@ -161,7 +160,6 @@ 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>"; }; - 2570F892610F1834C92F7F6E /* AuthorDeltaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorDeltaTests.swift; sourceTree = "<group>"; }; 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; }; 2D2FD896D75863554E31654C /* NotificationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationState.swift; sourceTree = "<group>"; }; 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreUnreadMovesTests.swift; sourceTree = "<group>"; }; @@ -451,7 +449,6 @@ isa = PBXGroup; children = ( 9998739ED0875A17271B7899 /* AppServicesAnnouncementTests.swift */, - 2570F892610F1834C92F7F6E /* AuthorDeltaTests.swift */, 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */, 67CFF96D54D2DE9C44EB120A /* EngagementCoordinatorTests.swift */, 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */, @@ -603,7 +600,6 @@ files = ( 6C091D30AAC9F63B7CE6FB58 /* AnnouncementCenterTests.swift in Sources */, D519F54F8CE0BD53D9C6144C /* AppServicesAnnouncementTests.swift in Sources */, - C2D8A9C79D75DBEF45720927 /* AuthorDeltaTests.swift in Sources */, A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */, 328309D8CC72CCB5623FB2A1 /* EngagementCoordinatorTests.swift in Sources */, 02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -653,7 +653,7 @@ private struct PuzzleDisplayView: View { // app-switcher peek that escalated to background). let resumed = services.cancelPendingSessionEndPush(gameID: id) Task { - await services.handlePuzzleOpened(gameID: id) + services.handlePuzzleOpened(gameID: id) if !resumed { await services.publishSessionStartPush(gameID: id) } diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -35,6 +35,7 @@ <attribute name="authorID" attributeType="String"/> <attribute name="ckRecordName" attributeType="String"/> <attribute name="ckSystemFields" optional="YES" attributeType="Binary"/> + <attribute name="lastMovesSnapshotData" optional="YES" attributeType="Binary"/> <attribute name="name" attributeType="String" defaultValueString=""/> <attribute name="readAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="selCol" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/> diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -819,6 +819,148 @@ final class GameStore { ) } + /// Snapshot of `authorID`'s `MovesEntity` contribution for `gameID`, + /// partitioned into cells currently holding a letter vs cells explicitly + /// emptied. Pass `deviceID: nil` to merge every device the author has + /// touched the puzzle from (the receiver-banner case — Bob wants Alice's + /// across-devices total). Pass an explicit `deviceID` to scope to one row + /// (the sender-push case — each device's session covers only its own + /// edits, so overlapping iPad/iPhone sessions don't double-count). + /// + /// Reads MovesEntity directly, not the derived `CellEntity` cache: the + /// cache attributes by current author, so a peer overwriting one of the + /// author's cells would flip ownership and a CellEntity-based diff would + /// falsely count a "clear" the author never made. The Moves row is the + /// authoritative log of what each device wrote. + func movesSnapshot( + for gameID: UUID, + by authorID: String, + on deviceID: String? + ) -> LocalMovesSnapshot { + let request = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + if let deviceID { + request.predicate = NSPredicate( + format: "game.id == %@ AND authorID == %@ AND deviceID == %@", + gameID as CVarArg, + authorID, + deviceID + ) + } else { + request.predicate = NSPredicate( + format: "game.id == %@ AND authorID == %@", + gameID as CVarArg, + authorID + ) + } + let entities = (try? context.fetch(request)) ?? [] + let values: [MovesValue] = entities.compactMap { Self.movesValue(from: $0) } + guard !values.isEmpty else { return .empty } + // Per-device rows can disagree on a cell (Alice on iPad wrote A, then + // on iPhone wrote B). Merge with the same LWW logic the rest of the + // app uses so the snapshot reflects the cell's winning state. + let grid = GridStateMerger.merge(values) + var filled: Set<GridPosition> = [] + var cleared: Set<GridPosition> = [] + for (position, cell) in grid { + if cell.letter.isEmpty { + cleared.insert(position) + } else { + filled.insert(position) + } + } + return LocalMovesSnapshot(filled: filled, cleared: cleared) + } + + /// Decoded `lastMovesSnapshotData` for `(gameID, authorID)`, or `nil` if + /// no PlayerEntity row exists yet or no baseline has been written. Used + /// by the in-app session-summary banner to diff a peer's current Moves + /// snapshot against what was current the last time the local user opened + /// the puzzle. + func lastMovesSnapshot(for gameID: UUID, by authorID: String) -> LocalMovesSnapshot? { + guard let entity = fetchPlayerEntity(gameID: gameID, authorID: authorID), + let data = entity.lastMovesSnapshotData + else { return nil } + return try? LocalMovesSnapshot.decode(data) + } + + /// Persists `snapshot` as the new baseline for `(gameID, authorID)`. Stays + /// local to this device — `lastMovesSnapshotData` is excluded from the + /// CloudKit serializer so each device tracks its own "last consumed" state + /// independently. Creates a stub PlayerEntity if none exists yet (Moves + /// records can race ahead of Player records during sync), keyed by the + /// deterministic `ckRecordName` so the real Player record, when it + /// arrives, updates this row rather than creating a duplicate. No-op if + /// the GameEntity is missing. + func setLastMovesSnapshot( + _ snapshot: LocalMovesSnapshot, + for gameID: UUID, + by 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.lastMovesSnapshotData = try? snapshot.encoded() + saveContext("setLastMovesSnapshot") + } + + /// 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. + func peerAuthorIDs(for gameID: UUID, excluding localAuthorID: String?) -> [String] { + let request = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) + let entities = (try? context.fetch(request)) ?? [] + var unique: Set<String> = [] + for entity in entities { + guard let authorID = entity.authorID, !authorID.isEmpty else { continue } + if let localAuthorID, authorID == localAuthorID { continue } + unique.insert(authorID) + } + return Array(unique) + } + + /// Formatted title used by notifications and the in-app session banner + /// for `gameID`. Returns an empty string when the game can't be found. + func puzzleTitleForNotification(for gameID: UUID) -> String { + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + request.fetchLimit = 1 + let entity = try? context.fetch(request).first + return PuzzleNotificationText.title(for: entity) + } + + /// Display name persisted on the PlayerEntity for `(gameID, authorID)`, + /// or an empty string when no row exists. The session banner falls back + /// to "A player" via `SessionMonitor.bodyText` when empty. + func playerName(for gameID: UUID, by authorID: String) -> String { + return fetchPlayerEntity(gameID: gameID, authorID: authorID)?.name ?? "" + } + + private func fetchPlayerEntity(gameID: UUID, authorID: String) -> PlayerEntity? { + let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + request.predicate = NSPredicate( + format: "game.id == %@ AND authorID == %@", + gameID as CVarArg, + authorID + ) + request.fetchLimit = 1 + return try? context.fetch(request).first + } + /// Replaces a game's persisted XD source with a re-converted equivalent, /// stamps the current CmVer, raises `hasPushPending` so the next outbound /// Game record re-includes the `puzzleSource` asset, and enqueues the push diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -196,11 +196,6 @@ final class AppServices { ) self.movesUpdater = movesUpdater - self.sessionMonitor = SessionMonitor( - persistence: persistence, - localAuthorIDProvider: { await MainActor.run { identity.currentID } } - ) - self.announcements = AnnouncementCenter() let cursorStore = GameCursorStore() @@ -236,6 +231,11 @@ final class AppServices { ) self.store = store + self.sessionMonitor = SessionMonitor( + store: store, + localAuthorIDProvider: { identity.currentID } + ) + self.shareController = ShareController( container: self.ckContainer, persistence: persistence, @@ -290,10 +290,6 @@ final class AppServices { } self.store.onLocalCellEdit = { [weak self] edit in guard let self else { return } - self.localSessionTracker.noteEdit( - gameID: edit.gameID, - emptied: edit.letter.isEmpty - ) guard self.engagementStatus.isLive(gameID: edit.gameID) else { return } Task { await self.engagementCoordinator.sendCellEdit(edit) } } @@ -341,10 +337,6 @@ final class AppServices { identity.currentID } - await syncEngine.setOnRemoteAuthorDelta { [sessionMonitor] deltas in - await sessionMonitor.ingest(deltas) - } - await syncEngine.setOnRemoteMovesUpdated { [weak self, store, identity] gameIDs in store.noteIncomingMovesUpdate( gameIDs: gameIDs, @@ -407,7 +399,7 @@ final class AppServices { await syncEngine.setOnGameAccessRevoked { [store, sessionMonitor, announcements] gameID in store.markAccessRevoked(gameID: gameID) - await sessionMonitor.cancel(gameID: gameID) + sessionMonitor.clearMovesSnapshots(for: gameID, by: nil) // Surface the revocation as a sticky, input-blocking banner on // the open puzzle, replacing the former AccessRevokedBanner // overlay. Game-scoped, so it only shows for this puzzle. @@ -416,7 +408,7 @@ final class AppServices { await syncEngine.setOnGameRemoved { [store, sessionMonitor, announcements] gameID in let wasOpen = store.handleRemoteRemoval(gameID: gameID) - await sessionMonitor.cancel(gameID: gameID) + sessionMonitor.clearMovesSnapshots(for: gameID, by: nil) // A hard-deleted game (private zone gone, or a shared game left // elsewhere) only needs UI when its puzzle is on screen: a sticky, // input-blocking banner freezes the now-orphaned puzzle until the @@ -616,15 +608,22 @@ final class AppServices { /// 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 { - localSessionTracker.begin(gameID: gameID) - guard let pushClient else { - syncMonitor.note("push(play): skipped (no pushClient)") - return - } 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 + ) + ) + guard let pushClient else { + syncMonitor.note("push(play): skipped (no pushClient)") + return + } let plan = pushPlan(forGameID: gameID, excluding: localAuthorID) guard !plan.recipients.isEmpty else { syncMonitor.note("push(play): skipped (no recipients)") @@ -693,7 +692,18 @@ final class AppServices { // grace-window timer for this game — drop it so we don't fire a // second pause once the timer elapses. pendingSessionEndTasks.removeValue(forKey: gameID)?.cancel() - let counts = localSessionTracker.consume(gameID: gameID) + guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { + syncMonitor.note("push(pause): skipped (no authorID)") + return + } + let counts = localSessionTracker.consume( + gameID: gameID, + snapshot: store.movesSnapshot( + for: gameID, + by: localAuthorID, + on: RecordSerializer.localDeviceID + ) + ) guard counts.added > 0 || counts.cleared > 0 else { syncMonitor.note("push(pause): skipped (no edits)") return @@ -702,10 +712,6 @@ final class AppServices { syncMonitor.note("push(pause): skipped (no pushClient)") return } - guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { - syncMonitor.note("push(pause): skipped (no authorID)") - return - } let plan = pushPlan(forGameID: gameID, excluding: localAuthorID) guard !plan.recipients.isEmpty else { syncMonitor.note("push(pause): skipped (no recipients)") @@ -2019,8 +2025,8 @@ final class AppServices { /// transient announcement on the puzzle header, in lieu of the /// local notification that would otherwise have fired in a few /// minutes' time. No-op if nothing was accumulated. - func handlePuzzleOpened(gameID: UUID) async { - let summaries = await sessionMonitor.consumeOnOpen(gameID: gameID) + func handlePuzzleOpened(gameID: UUID) { + let summaries = sessionMonitor.consumeMovesSnapshots(for: gameID) guard !summaries.isEmpty else { return } let body = Self.formatSummaryBanner(summaries) announcements.post(Announcement( @@ -2036,6 +2042,10 @@ final class AppServices { guard !summaries.isEmpty else { return "" } let phrases: [String] = summaries.map { summary in let name = summary.playerName.isEmpty ? "A player" : summary.playerName + if summary.isFirstObservation { + let count = summary.added + return "\(name) has added \(count) \(count == 1 ? "letter" : "letters") so far" + } var parts: [String] = [] if summary.added > 0 { parts.append("added \(summary.added) \(summary.added == 1 ? "letter" : "letters")") diff --git a/Crossmate/Services/LocalSessionTracker.swift b/Crossmate/Services/LocalSessionTracker.swift @@ -1,38 +1,53 @@ import Foundation -/// Per-session running tally of the local player's adds and clears, used to -/// build the body text of the pause APN. Reset on every `begin(gameID:)` so a +/// 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 -/// zero — the prior segment's counts already shipped in its own pause push. +/// a fresh baseline. @MainActor final class LocalSessionTracker { private(set) var activeGameID: UUID? - private var added: Int = 0 - private var cleared: Int = 0 + private var baseline: LocalMovesSnapshot = .empty - func begin(gameID: UUID) { + func begin(gameID: UUID, snapshot: LocalMovesSnapshot) { activeGameID = gameID - added = 0 - cleared = 0 - } - - func noteEdit(gameID: UUID, emptied: Bool) { - guard gameID == activeGameID else { return } - if emptied { - cleared += 1 - } else { - added += 1 - } + baseline = snapshot } - /// Returns the running tally for `gameID` and resets the counters. 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) -> (added: Int, cleared: Int) { + /// 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 result = (added, cleared) - added = 0 - cleared = 0 - return result + 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 @@ -2,20 +2,6 @@ import CloudKit import CoreData import Foundation -/// One game's worth of remote-driven cell churn attributed to a single -/// writer. Surfaced from the receiver-side apply path so SessionMonitor can -/// accumulate per-author tallies and (re)schedule end-of-session -/// notifications. `latestUpdate` is the wall-clock timestamp of the most -/// recent winning cell write in this batch — used to schedule the -/// quiescence trigger relative to the actual typing, not to batch arrival. -struct AuthorDelta: Sendable { - let gameID: UUID - let authorID: String - let added: Int - let cleared: Int - let latestUpdate: Date -} - struct BatchEffects { var movesUpdated = Set<UUID>() var affected = Set<UUID>() @@ -24,7 +10,6 @@ struct BatchEffects { var playerPresenceChanged = Set<UUID>() var removed = Set<UUID>() var readCursors: [(UUID, Date)] = [] - var authorDeltas: [AuthorDelta] = [] } extension SyncEngine { @@ -39,16 +24,6 @@ extension SyncEngine { let localAuthorID = await currentLocalAuthorID() let effects: BatchEffects = ctx.performAndWait { var effects = BatchEffects() - // Pre-pass: snapshot the merged grid for every game that has a - // Moves record in this batch, so the post-apply diff can attribute - // each cell transition to the LWW-winning writer. - let movesBearingGameIDs: Set<UUID> = Set(records.compactMap { record -> UUID? in - guard record.recordType == "Moves", - let (gameID, _, _) = RecordSerializer.parseMovesRecordName(record.recordID.recordName) - else { return nil } - return gameID - }) - let beforeGrids = Self.gridSnapshots(for: movesBearingGameIDs, in: ctx) for record in records { switch record.recordType { case "Game": @@ -94,12 +69,6 @@ extension SyncEngine { for gameID in effects.movesUpdated { self.replayCellCache(for: gameID, in: ctx) } - let afterGrids = Self.gridSnapshots(for: movesBearingGameIDs, in: ctx) - effects.authorDeltas = Self.authorDeltas( - before: beforeGrids, - after: afterGrids, - cutoff: Date().addingTimeInterval(-SessionMonitor.quiescenceWindow) - ) if ctx.hasChanges { do { try ctx.save() @@ -118,9 +87,6 @@ extension SyncEngine { if let onRemoteMovesUpdated, !effects.movesUpdated.isEmpty { await onRemoteMovesUpdated(effects.movesUpdated) } - if let onRemoteAuthorDelta, !effects.authorDeltas.isEmpty { - await onRemoteAuthorDelta(effects.authorDeltas) - } if let onRemotePlayersUpdated, !effects.playersUpdated.isEmpty { await onRemotePlayersUpdated(effects.playersUpdated) } @@ -329,50 +295,6 @@ extension SyncEngine { ) } - /// Computes the merged-grid provenance snapshot for each game. Called - /// twice during a Moves batch apply — once before any apply, once after - /// `replayCellCache` runs — so the diff between the two yields per-cell - /// transitions attributed to the LWW-winning writer. - nonisolated static func gridSnapshots( - for gameIDs: Set<UUID>, - in ctx: NSManagedObjectContext - ) -> [UUID: [GridPosition: GridStateMerger.Provenance]] { - var result: [UUID: [GridPosition: GridStateMerger.Provenance]] = [:] - for gameID in gameIDs { - let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") - gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - gameReq.fetchLimit = 1 - let game: GameEntity? - do { - game = try ctx.fetch(gameReq).first - } catch { - // Batch is committed at the engine layer; can't redeliver, so - // surface the dropped snapshot to console. Empty snapshot - // means authorDeltas will see no transitions for this game. - logSyncError("gridSnapshots game fetch", gameID: gameID, error: error) - result[gameID] = [:] - continue - } - guard let game else { - result[gameID] = [:] - continue - } - let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") - movesReq.predicate = NSPredicate(format: "game == %@", game) - let movesEntities: [MovesEntity] - do { - movesEntities = try ctx.fetch(movesReq) - } catch { - logSyncError("gridSnapshots moves fetch", gameID: gameID, error: error) - result[gameID] = [:] - continue - } - let values: [MovesValue] = movesEntities.compactMap { movesValue(from: $0) } - result[gameID] = GridStateMerger.mergeWithProvenance(values) - } - return result - } - /// Sync-context error surfacing — mirrors the `print(...)` format used by /// the surrounding save-failure handlers. The engine's change token has /// already advanced by the time these helpers run inside performAndWait, @@ -386,65 +308,6 @@ extension SyncEngine { ) } - /// Aggregates the merged-grid diff per `(gameID, writerAuthorID)`. Counts - /// empty→letter transitions as `added` and letter→empty as `cleared`; - /// letter→different-letter (one author rewriting over another's cell) - /// contributes zero. Cells whose winning entry was written before - /// `cutoff` are ignored — older entries can't represent a currently - /// active session and would mis-fire on cold-launch backfill of an old - /// record stream. - nonisolated static func authorDeltas( - before: [UUID: [GridPosition: GridStateMerger.Provenance]], - after: [UUID: [GridPosition: GridStateMerger.Provenance]], - cutoff: Date - ) -> [AuthorDelta] { - struct Key: Hashable { - let gameID: UUID - let authorID: String - } - var byKey: [Key: (added: Int, cleared: Int, latest: Date)] = [:] - let allGameIDs = Set(before.keys).union(after.keys) - for gameID in allGameIDs { - let b = before[gameID] ?? [:] - let a = after[gameID] ?? [:] - let positions = Set(b.keys).union(a.keys) - for pos in positions { - let beforeLetter = b[pos]?.cell.letter ?? "" - let afterEntry = a[pos] - let afterLetter = afterEntry?.cell.letter ?? "" - // Attribute to the LWW-winning writer in the post-apply - // snapshot; if the cell vanished (no MovesEntity for it - // anymore — only via record deletion), fall back to the - // pre-apply writer. - let writer = afterEntry?.writerAuthorID ?? b[pos]?.writerAuthorID ?? "" - guard !writer.isEmpty else { continue } - let stamp = afterEntry?.cell.updatedAt ?? b[pos]?.cell.updatedAt ?? .distantPast - guard stamp >= cutoff else { continue } - let key = Key(gameID: gameID, authorID: writer) - if beforeLetter.isEmpty, !afterLetter.isEmpty { - var bucket = byKey[key] ?? (0, 0, .distantPast) - bucket.added += 1 - if stamp > bucket.latest { bucket.latest = stamp } - byKey[key] = bucket - } else if !beforeLetter.isEmpty, afterLetter.isEmpty { - var bucket = byKey[key] ?? (0, 0, .distantPast) - bucket.cleared += 1 - if stamp > bucket.latest { bucket.latest = stamp } - byKey[key] = bucket - } - } - } - return byKey.map { key, value in - AuthorDelta( - gameID: key.gameID, - authorID: key.authorID, - added: value.added, - cleared: value.cleared, - latestUpdate: value.latest - ) - } - } - nonisolated func applyDeletion( recordID: CKRecord.ID, recordType: CKRecord.RecordType, diff --git a/Crossmate/Sync/SessionMonitor.swift b/Crossmate/Sync/SessionMonitor.swift @@ -1,83 +1,38 @@ -import CoreData import Foundation -/// Receiver-side observer that accumulates a per-`(gameID, authorID)` tally -/// of unseen peer activity. The accumulated tally is drained by -/// `consumeOnOpen(gameID:)` when the user opens a puzzle, producing the -/// catch-up announcement banner ("Alice added 12 letters; Bob added 5"). +/// Receiver-side computation of the catch-up announcement banner that fires +/// 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. /// -/// Time-triggered end-of-session local notifications used to live here too, -/// but those have been replaced by the sender-side leave APN -/// (`AppServices.publishSessionEndPush`). The peer's device fires the leave -/// push the instant they close the puzzle (or background the app), which is -/// both timelier and more accurate than the 3-minute quiescence heuristic -/// this monitor used to maintain. -actor SessionMonitor { - /// Wall-clock window used by `RecordApplier.authorDeltas` to gate stale - /// cell writes out of cold-launch backfill so they don't show up in the - /// catch-up banner as if they had just happened. - static let quiescenceWindow: TimeInterval = 180 +/// 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. +@MainActor +final class SessionMonitor { + private let store: GameStore + private let localAuthorIDProvider: () -> String? - private struct Key: Hashable { - let gameID: UUID - let authorID: String - } - - private struct Bucket { - var added: Int - var cleared: Int - } - - private var buckets: [Key: Bucket] = [:] - private let persistence: PersistenceController - private let nameLookup: @Sendable (UUID, String) async -> (playerName: String, puzzleTitle: String) - private let suppressionGate: @Sendable (UUID) -> Bool - private let localAuthorIDProvider: @Sendable () async -> String? - - init( - persistence: PersistenceController, - localAuthorIDProvider: @escaping @Sendable () async -> String?, - nameLookup: (@Sendable (UUID, String) async -> (playerName: String, puzzleTitle: String))? = nil, - suppressionGate: @escaping @Sendable (UUID) -> Bool = { NotificationState.isSuppressed(gameID: $0) } - ) { - self.persistence = persistence + init(store: GameStore, localAuthorIDProvider: @escaping () -> String?) { + self.store = store self.localAuthorIDProvider = localAuthorIDProvider - self.suppressionGate = suppressionGate - if let nameLookup { - self.nameLookup = nameLookup - } else { - self.nameLookup = { gameID, authorID in - await Self.coreDataNameLookup( - persistence: persistence, - gameID: gameID, - authorID: authorID - ) - } - } - } - - /// Folds one batch of receiver-side deltas into the per-author tallies. - /// Drops self-authored deltas (sibling device of the same iCloud account) - /// and deltas for puzzles the user is currently viewing. - func ingest(_ deltas: [AuthorDelta]) async { - guard !deltas.isEmpty else { return } - let localAuthorID = await localAuthorIDProvider() - for delta in deltas { - if delta.added == 0 && delta.cleared == 0 { continue } - if delta.authorID == localAuthorID { continue } - if suppressionGate(delta.gameID) { continue } - let key = Key(gameID: delta.gameID, authorID: delta.authorID) - var bucket = buckets[key] ?? Bucket(added: 0, cleared: 0) - bucket.added += delta.added - bucket.cleared += delta.cleared - buckets[key] = bucket - } } /// One author's worth of unseen activity, surfaced to the puzzle's /// announcement banner when the user opens a game. Hydrated with the /// player and puzzle names so the caller can format a body string /// without a second Core Data round-trip. + /// + /// `isFirstObservation` flips the formatter from delta wording ("Alice + /// added 5 letters") to cumulative wording ("Alice has added 50 letters + /// so far"). Set on the first ever observation of `(gameID, authorID)` — + /// e.g. just-joined shared game or fresh install — when there is no + /// "since you last looked" period to describe and `added` is the peer's + /// current total filled cells, not a per-session delta. struct SessionSummary: Equatable, Sendable { let gameID: UUID let authorID: String @@ -85,82 +40,87 @@ actor SessionMonitor { let puzzleTitle: String let added: Int let cleared: Int + let isFirstObservation: Bool + + init( + gameID: UUID, + authorID: String, + playerName: String, + puzzleTitle: String, + added: Int, + cleared: Int, + isFirstObservation: Bool = false + ) { + self.gameID = gameID + self.authorID = authorID + self.playerName = playerName + self.puzzleTitle = puzzleTitle + self.added = added + self.cleared = cleared + self.isFirstObservation = isFirstObservation + } } - /// Pulls the running tallies for every author in `gameID` out as - /// summaries and drops their buckets. - func consumeOnOpen(gameID: UUID) async -> [SessionSummary] { - let keysForGame = buckets.keys.filter { $0.gameID == gameID } - guard !keysForGame.isEmpty else { return [] } + /// Walks every peer with a MovesEntity for `gameID`, diffs 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] { + let localAuthorID = localAuthorIDProvider() + let peers = store.peerAuthorIDs(for: gameID, excluding: localAuthorID) + guard !peers.isEmpty else { return [] } + let puzzleTitle = store.puzzleTitleForNotification(for: gameID) var summaries: [SessionSummary] = [] - for key in keysForGame { - guard let bucket = buckets[key], bucket.added > 0 || bucket.cleared > 0 - else { continue } - let (playerName, puzzleTitle) = await nameLookup(key.gameID, key.authorID) + 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 + if let baseline { + added = current.filled.subtracting(baseline.filled).count + cleared = current.cleared.subtracting(baseline.cleared).count + isFirstObservation = false + } else { + // First observation of this peer — there's no "since you + // last looked" period to describe. Surface the peer's + // current contribution as cumulative context ("Alice has + // added 50 letters so far") so a freshly joined user knows + // there's progress already in the grid. + added = current.filled.count + cleared = 0 + isFirstObservation = true + } + guard added > 0 || cleared > 0 else { continue } summaries.append(SessionSummary( - gameID: key.gameID, - authorID: key.authorID, - playerName: playerName, + gameID: gameID, + authorID: authorID, + playerName: store.playerName(for: gameID, by: authorID), puzzleTitle: puzzleTitle, - added: bucket.added, - cleared: bucket.cleared + added: added, + cleared: cleared, + isFirstObservation: isFirstObservation )) } - for key in keysForGame { - buckets.removeValue(forKey: key) - } return summaries } - /// Drops the running tally for `(gameID, authorID)`. Pass `authorID == nil` - /// to drop every author's bucket for the game — used when the user opens - /// the puzzle or the Game gains a `completedBy` (the catch-up banner is - /// superseded by a stronger signal). - func cancel(gameID: UUID, authorID: String? = nil) async { - let keysToCancel: [Key] = buckets.keys.filter { key in - key.gameID == gameID && (authorID == nil || key.authorID == authorID) - } - guard !keysToCancel.isEmpty else { return } - for key in keysToCancel { - buckets.removeValue(forKey: key) + /// 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 + /// `completedBy` (the catch-up banner is superseded by a stronger + /// signal), and when the game is access-revoked or removed. + func clearMovesSnapshots(for gameID: UUID, by authorID: String?) { + let authors: [String] + if let authorID { + authors = [authorID] + } else { + authors = store.peerAuthorIDs(for: gameID, excluding: nil) } - } - - /// Number of distinct `(gameID, authorID)` buckets currently being - /// accumulated. Exposed for tests; not part of the runtime API. - var pendingBucketCount: Int { buckets.count } - - /// Snapshot of the running tally for `(gameID, authorID)`, if any. - /// Exposed for tests; not part of the runtime API. - func tally(gameID: UUID, authorID: String) -> (added: Int, cleared: Int)? { - guard let bucket = buckets[Key(gameID: gameID, authorID: authorID)] else { return nil } - return (bucket.added, bucket.cleared) - } - - private static func coreDataNameLookup( - persistence: PersistenceController, - gameID: UUID, - authorID: String - ) async -> (playerName: String, puzzleTitle: String) { - let ctx = persistence.container.newBackgroundContext() - return ctx.performAndWait { - let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") - gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - gameReq.fetchLimit = 1 - let game = try? ctx.fetch(gameReq).first - let title = PuzzleNotificationText.title(for: game) - - var name = "" - if let game { - let playerReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") - playerReq.predicate = NSPredicate( - format: "game == %@ AND authorID == %@", - game, authorID - ) - playerReq.fetchLimit = 1 - name = (try? ctx.fetch(playerReq).first?.name) ?? "" - } - return (name, title) + for author in authors { + store.setLastMovesSnapshot(.empty, for: gameID, by: author) } } diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -73,11 +73,6 @@ actor SyncEngine { private var loggedFirstSharedPushPayload = false var onRemoteMovesUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)? - /// Fires with per-`(gameID, authorID)` deltas computed by diffing the - /// merged grid before and after a batch of incoming Moves records. Drives - /// SessionMonitor's end-of-session notifications: each delta bumps the - /// running tally and reschedules the quiescence trigger. - var onRemoteAuthorDelta: (@MainActor @Sendable ([AuthorDelta]) async -> Void)? /// Fires with the game IDs for which a collaborator's `Player` record was /// seen for the **first time** (a new `PlayerEntity` was created) — not on /// their subsequent name / cursor updates. Independent of moves; the @@ -149,10 +144,6 @@ actor SyncEngine { onRemoteMovesUpdated = cb } - func setOnRemoteAuthorDelta(_ cb: @MainActor @Sendable @escaping ([AuthorDelta]) async -> Void) { - onRemoteAuthorDelta = cb - } - func setOnRemotePlayersUpdated(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { onRemotePlayersUpdated = cb } @@ -1170,16 +1161,6 @@ actor SyncEngine { let localAuthorID = await currentLocalAuthorID() let effects: BatchEffects = ctx.performAndWait { var effects = BatchEffects() - // Pre-pass: snapshot the merged grid for every game that has a - // Moves record in this batch, so the post-apply diff can attribute - // each cell transition to the LWW-winning writer. - let movesBearingGameIDs: Set<UUID> = Set(event.modifications.compactMap { mod -> UUID? in - guard mod.record.recordType == "Moves", - let (gameID, _, _) = RecordSerializer.parseMovesRecordName(mod.record.recordID.recordName) - else { return nil } - return gameID - }) - let beforeGrids = Self.gridSnapshots(for: movesBearingGameIDs, in: ctx) for mod in event.modifications { let record = mod.record switch record.recordType { @@ -1247,12 +1228,6 @@ actor SyncEngine { for gameID in effects.movesUpdated { self.replayCellCache(for: gameID, in: ctx) } - let afterGrids = Self.gridSnapshots(for: movesBearingGameIDs, in: ctx) - effects.authorDeltas = Self.authorDeltas( - before: beforeGrids, - after: afterGrids, - cutoff: Date().addingTimeInterval(-SessionMonitor.quiescenceWindow) - ) // CKSyncEngine advances its change token whenever the delegate // returns from fetchedRecordZoneChanges, regardless of whether we // persisted anything. A silent failure here means the records are @@ -1276,9 +1251,6 @@ actor SyncEngine { if let onRemoteMovesUpdated, !effects.movesUpdated.isEmpty { await onRemoteMovesUpdated(effects.movesUpdated) } - if let onRemoteAuthorDelta, !effects.authorDeltas.isEmpty { - await onRemoteAuthorDelta(effects.authorDeltas) - } if let onRemotePlayersUpdated, !effects.playersUpdated.isEmpty { await onRemotePlayersUpdated(effects.playersUpdated) } diff --git a/Tests/Unit/Sync/AuthorDeltaTests.swift b/Tests/Unit/Sync/AuthorDeltaTests.swift @@ -1,232 +0,0 @@ -import Foundation -import Testing - -@testable import Crossmate - -@Suite("GridStateMerger.mergeWithProvenance") -struct GridStateMergerProvenanceTests { - - private let gameID = UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")! - - private func view( - author: String, - device: String = "d1", - cells: [(row: Int, col: Int, letter: String, updatedAt: Date)] - ) -> MovesValue { - var dict: [GridPosition: TimestampedCell] = [:] - for entry in cells { - dict[GridPosition(row: entry.row, col: entry.col)] = TimestampedCell( - letter: entry.letter, - markKind: 0, - checkedRight: false, checkedWrong: false, - updatedAt: entry.updatedAt, - authorID: author - ) - } - return MovesValue( - gameID: gameID, - authorID: author, - deviceID: device, - cells: dict, - updatedAt: cells.map(\.updatedAt).max() ?? .distantPast - ) - } - - @Test("Provenance carries the writer authorID, not just the cell author") - func writerAttribution() { - let alice = view( - author: "alice", - cells: [(0, 0, "A", Date(timeIntervalSince1970: 5))] - ) - let result = GridStateMerger.mergeWithProvenance([alice]) - let entry = result[GridPosition(row: 0, col: 0)] - #expect(entry?.writerAuthorID == "alice") - #expect(entry?.cell.letter == "A") - #expect(entry?.cell.updatedAt == Date(timeIntervalSince1970: 5)) - } - - @Test("Cleared cells (empty letter) are retained with their writer") - func emptyCellRetained() { - let cleared = view( - author: "alice", - cells: [(0, 0, "", Date(timeIntervalSince1970: 9))] - ) - let result = GridStateMerger.mergeWithProvenance([cleared]) - let entry = result[GridPosition(row: 0, col: 0)] - #expect(entry?.cell.letter == "") - #expect(entry?.writerAuthorID == "alice") - } - - @Test("LWW winner's writer survives across multiple devices") - func lwwWinnerWriter() { - let alice = view( - author: "alice", - device: "ipad", - cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))] - ) - let bob = view( - author: "bob", - device: "phone", - cells: [(0, 0, "B", Date(timeIntervalSince1970: 2))] - ) - let result = GridStateMerger.mergeWithProvenance([alice, bob]) - #expect(result[GridPosition(row: 0, col: 0)]?.writerAuthorID == "bob") - } -} - -@Suite("RecordApplier.authorDeltas") -struct AuthorDeltaTests { - - private let gameA = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! - private let gameB = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! - - /// Convenience to build a per-game provenance map without going through - /// GridStateMerger — keeps tests focused on the diff logic. - private func snapshot( - _ entries: [(row: Int, col: Int, letter: String, writer: String, updatedAt: Date)] - ) -> [GridPosition: GridStateMerger.Provenance] { - var out: [GridPosition: GridStateMerger.Provenance] = [:] - for entry in entries { - out[GridPosition(row: entry.row, col: entry.col)] = GridStateMerger.Provenance( - cell: TimestampedCell( - letter: entry.letter, - markKind: 0, - checkedRight: false, checkedWrong: false, - updatedAt: entry.updatedAt, - authorID: entry.writer - ), - writerAuthorID: entry.writer - ) - } - return out - } - - private let recentEnough = Date(timeIntervalSince1970: 10_000) - private let cutoff = Date(timeIntervalSince1970: 9_000) - - @Test("Empty-to-letter transition counts as added, attributed to the writer") - func addedCount() { - let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [gameA: [:]] - let after = [gameA: snapshot([(0, 0, "A", "alice", recentEnough)])] - let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) - #expect(deltas.count == 1) - #expect(deltas[0].gameID == gameA) - #expect(deltas[0].authorID == "alice") - #expect(deltas[0].added == 1) - #expect(deltas[0].cleared == 0) - } - - @Test("Letter-to-empty transition counts as cleared, attributed to the clearing writer") - func clearedCount() { - // Bob wrote "B" originally, then Alice's record overwrites that cell - // with an empty letter — the clear is attributed to Alice, who is - // the LWW-winning writer of the post-apply state. - let before = [gameA: snapshot([(0, 0, "B", "bob", recentEnough)])] - let after = [gameA: snapshot([(0, 0, "", "alice", recentEnough.addingTimeInterval(1))])] - let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) - #expect(deltas.count == 1) - #expect(deltas[0].authorID == "alice") - #expect(deltas[0].added == 0) - #expect(deltas[0].cleared == 1) - } - - @Test("Letter-to-different-letter contributes zero (no add, no clear)") - func overwriteIsNeutral() { - let before = [gameA: snapshot([(0, 0, "A", "alice", recentEnough)])] - let after = [gameA: snapshot([(0, 0, "B", "bob", recentEnough.addingTimeInterval(1))])] - let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) - #expect(deltas.isEmpty) - } - - @Test("Cells unchanged between before and after contribute zero") - func unchangedCells() { - // Re-applying the same merged-grid state (idempotent CKSyncEngine - // re-delivery) yields no deltas. - let stamp = recentEnough - let same = snapshot([(0, 0, "A", "alice", stamp)]) - let deltas = SyncEngine.authorDeltas(before: [gameA: same], after: [gameA: same], cutoff: cutoff) - #expect(deltas.isEmpty) - } - - @Test("Cells whose winning entry predates cutoff are gated out") - func cutoffGateExcludesStale() { - let stale = Date(timeIntervalSince1970: 5_000) // well before cutoff - let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [gameA: [:]] - let after = [gameA: snapshot([(0, 0, "A", "alice", stale)])] - let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) - #expect(deltas.isEmpty) - } - - @Test("Adds and clears in the same batch aggregate per author") - func aggregatesPerAuthor() { - let before = [gameA: snapshot([ - (0, 0, "X", "alice", recentEnough), - (0, 1, "Y", "alice", recentEnough), - ])] - let after = [gameA: snapshot([ - (0, 0, "", "alice", recentEnough.addingTimeInterval(1)), // cleared - (0, 1, "Y", "alice", recentEnough), // unchanged - (0, 2, "Z", "alice", recentEnough.addingTimeInterval(2)), // added - (0, 3, "W", "alice", recentEnough.addingTimeInterval(3)), // added - ])] - let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) - #expect(deltas.count == 1) - #expect(deltas[0].added == 2) - #expect(deltas[0].cleared == 1) - #expect(deltas[0].latestUpdate == recentEnough.addingTimeInterval(3)) - } - - @Test("Multi-device same-author writes all attribute to one bucket") - func multiDeviceSameAuthor() { - // Alice's iPad wrote A at (0,0) in the prior batch; her iPhone - // arrives with a clear at the same cell. Both writes have authorID - // = "alice", so the cleared count rolls up into one bucket — the - // hard problem the design was supposed to solve. - let before = [gameA: snapshot([(0, 0, "A", "alice", recentEnough)])] - let after = [gameA: snapshot([(0, 0, "", "alice", recentEnough.addingTimeInterval(1))])] - let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) - #expect(deltas.count == 1) - #expect(deltas[0].authorID == "alice") - #expect(deltas[0].cleared == 1) - } - - @Test("Different authors in the same game produce distinct buckets") - func separateAuthorsSeparateBuckets() { - let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [gameA: [:]] - let after = [gameA: snapshot([ - (0, 0, "A", "alice", recentEnough), - (1, 1, "B", "bob", recentEnough), - ])] - let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) - let byAuthor = Dictionary(uniqueKeysWithValues: deltas.map { ($0.authorID, $0) }) - #expect(byAuthor["alice"]?.added == 1) - #expect(byAuthor["bob"]?.added == 1) - } - - @Test("Deltas across multiple games are surfaced independently") - func multiGameDeltas() { - let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [ - gameA: [:], - gameB: [:], - ] - let after = [ - gameA: snapshot([(0, 0, "A", "alice", recentEnough)]), - gameB: snapshot([(0, 0, "B", "bob", recentEnough)]), - ] - let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) - #expect(deltas.count == 2) - #expect(Set(deltas.map { $0.gameID }) == Set([gameA, gameB])) - } - - @Test("LWW-loser writes don't show up: if the cell didn't change, no delta") - func lwwLoserIgnored() { - // Before: Alice already wrote A at t=20. After-apply still shows A - // at t=20 (LWW kept Alice's value despite a stale Bob write being - // applied at t=5, which lost to Alice and so doesn't change the - // merged-grid). The diff sees no transition. - let stamp = recentEnough.addingTimeInterval(20) - let same = snapshot([(0, 0, "A", "alice", stamp)]) - let deltas = SyncEngine.authorDeltas(before: [gameA: same], after: [gameA: same], cutoff: cutoff) - #expect(deltas.isEmpty) - } -} diff --git a/Tests/Unit/Sync/SessionMonitorTests.swift b/Tests/Unit/Sync/SessionMonitorTests.swift @@ -1,159 +1,416 @@ +import CoreData import Foundation import Testing @testable import Crossmate +@Suite("SessionMonitor", .isolatedNotificationState) @MainActor -private func makeMonitor( - localAuthorID: String? = nil, - suppressionGate: @escaping @Sendable (UUID) -> Bool = { _ in false } -) -> SessionMonitor { - SessionMonitor( - persistence: makeTestPersistence(), - localAuthorIDProvider: { localAuthorID }, - nameLookup: { _, authorID in ("Player \(authorID)", "Puzzle") }, - suppressionGate: suppressionGate - ) -} +struct SessionMonitorTests { -private func makeDelta( - gameID: UUID, - authorID: String, - added: Int = 0, - cleared: Int = 0, - latestUpdate: Date = Date() -) -> AuthorDelta { - AuthorDelta( - gameID: gameID, - authorID: authorID, - added: added, - cleared: cleared, - latestUpdate: latestUpdate - ) -} + private static let localAuthorID = "local-author" + private static let alice = "alice" + private static let bob = "bob" -@Suite("SessionMonitor", .isolatedNotificationState) -struct SessionMonitorTests { + private static let puzzleSource = """ + Title: Test Puzzle + Author: Test + + + ABC + D#E + FGH - @Test("Successive deltas for the same key accumulate; the bucket totals grow") - @MainActor func tallyAccumulates() async { - let monitor = makeMonitor() - let gameID = UUID() - await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 2)]) - await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 1, cleared: 4)]) - let tally = await monitor.tally(gameID: gameID, authorID: "alice") - #expect(tally?.added == 3) - #expect(tally?.cleared == 4) + 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 monitor: SessionMonitor + let gameID: UUID + let game: GameEntity } - @Test("Self-authored deltas (sibling device) are dropped") - @MainActor func selfAuthoredSkipped() async { - let monitor = makeMonitor(localAuthorID: "me") + private func makeFixture( + localAuthorID: String? = SessionMonitorTests.localAuthorID + ) throws -> Fixture { + let persistence = makeTestPersistence() + let store = makeTestStore( + persistence: persistence, + authorIDProvider: { localAuthorID } + ) + let monitor = SessionMonitor( + store: store, + localAuthorIDProvider: { localAuthorID } + ) + let ctx = persistence.viewContext let gameID = UUID() - await monitor.ingest([ - makeDelta(gameID: gameID, authorID: "me", added: 99), - makeDelta(gameID: gameID, authorID: "alice", added: 1), - ]) + let entity = GameEntity(context: ctx) + entity.id = gameID + entity.title = "Test Puzzle" + entity.puzzleSource = Self.puzzleSource + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = "game-\(gameID.uuidString)" + let xd = try XD.parse(Self.puzzleSource) + entity.populateCachedSummaryFields(from: Puzzle(xd: xd)) + try ctx.save() + return Fixture( + persistence: persistence, + store: store, + monitor: monitor, + gameID: gameID, + game: entity + ) + } - let selfTally = await monitor.tally(gameID: gameID, authorID: "me") - let aliceTally = await monitor.tally(gameID: gameID, authorID: "alice") - #expect(selfTally == nil) - #expect(aliceTally?.added == 1) + private func writeMoves( + in fixture: Fixture, + authorID: String, + deviceID: String = "device-1", + cells: [GridPosition: (letter: String, updatedAt: Date)] + ) throws { + let ctx = fixture.persistence.viewContext + let stamped = cells.mapValues { value in + TimestampedCell( + letter: value.letter, + markKind: 0, + checkedRight: false, + checkedWrong: false, + updatedAt: value.updatedAt, + authorID: authorID + ) + } + let data = try MovesCodec.encode(stamped) + let row = MovesEntity(context: ctx) + row.game = fixture.game + row.authorID = authorID + row.deviceID = deviceID + row.cells = data + row.updatedAt = Date() + row.ckRecordName = RecordSerializer.recordName( + forMovesInGame: fixture.gameID, + authorID: authorID, + deviceID: deviceID + ) + try ctx.save() } - @Test("Zero-count deltas are dropped") - @MainActor func zeroCountSkipped() async { - let monitor = makeMonitor() - let gameID = UUID() - await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 0, cleared: 0)]) + private func addPlayer( + in fixture: Fixture, + authorID: String, + name: String + ) throws { + let ctx = fixture.persistence.viewContext + let player = PlayerEntity(context: ctx) + player.game = fixture.game + player.authorID = authorID + player.name = name + player.updatedAt = Date() + player.ckRecordName = "player-\(fixture.gameID.uuidString)-\(authorID)" + try ctx.save() + } - let tally = await monitor.tally(gameID: gameID, authorID: "alice") - #expect(tally == nil) + private func position(_ row: Int, _ col: Int) -> GridPosition { + GridPosition(row: row, col: col) } - @Test("Active-puzzle suppression skips ingestion for that game") - @MainActor func suppressionGateBlocks() async { - let suppressed = UUID() - let other = UUID() - let monitor = makeMonitor(suppressionGate: { $0 == suppressed }) - await monitor.ingest([ - makeDelta(gameID: suppressed, authorID: "alice", added: 5), - makeDelta(gameID: other, authorID: "alice", added: 5), - ]) + // MARK: - First observation - #expect(await monitor.tally(gameID: suppressed, authorID: "alice") == nil) - #expect(await monitor.tally(gameID: other, authorID: "alice")?.added == 5) + @Test("First observation of a peer surfaces a cumulative summary with isFirstObservation set") + func firstObservationCumulative() throws { + let fixture = try makeFixture() + try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [ + position(0, 0): ("A", Date()), + position(0, 1): ("B", Date()), + position(2, 2): ("H", Date()), + ] + ) + + let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + + #expect(summaries.count == 1) + let summary = try #require(summaries.first) + #expect(summary.authorID == Self.alice) + #expect(summary.playerName == "Alice") + #expect(summary.added == 3) + #expect(summary.cleared == 0) + #expect(summary.isFirstObservation) } - @Test("cancel(gameID:) drops every author's bucket for the game") - @MainActor func cancelDropsAllAuthors() async { - let monitor = makeMonitor() - let gameID = UUID() - await monitor.ingest([ - makeDelta(gameID: gameID, authorID: "alice", added: 1), - makeDelta(gameID: gameID, authorID: "bob", added: 1), - ]) - #expect(await monitor.pendingBucketCount == 2) + @Test("First observation skips a peer whose filled count is zero") + func firstObservationSkipsEmptyPeer() throws { + let fixture = try makeFixture() + try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") + // Alice has only an explicit-clear entry — no visible letters. + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [position(0, 0): ("", Date())] + ) - await monitor.cancel(gameID: gameID) - #expect(await monitor.pendingBucketCount == 0) + let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + #expect(summaries.isEmpty) } - @Test("cancel(gameID:authorID:) only drops the named author's bucket") - @MainActor func cancelTargetsOneAuthor() async { - let monitor = makeMonitor() - let gameID = UUID() - await monitor.ingest([ - makeDelta(gameID: gameID, authorID: "alice", added: 1), - makeDelta(gameID: gameID, authorID: "bob", added: 1), - ]) - await monitor.cancel(gameID: gameID, authorID: "alice") + @Test("First observation still seeds the baseline so the next call diffs against it") + func firstObservationSeedsBaseline() throws { + let fixture = try makeFixture() + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [position(0, 0): ("A", Date())] + ) + _ = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + + // Alice adds another letter, then the user opens the puzzle again. + 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 aliceTally = await monitor.tally(gameID: gameID, authorID: "alice") - let bobTally = await monitor.tally(gameID: gameID, authorID: "bob") - #expect(aliceTally == nil) - #expect(bobTally?.added == 1) + let summary = try #require(summaries.first) + #expect(!summary.isFirstObservation) + #expect(summary.added == 1) + #expect(summary.cleared == 0) } - @Test("consumeOnOpen returns hydrated summaries and clears the buckets") - @MainActor func consumeOnOpenReturnsAndClears() async { - let monitor = SessionMonitor( - persistence: makeTestPersistence(), - localAuthorIDProvider: { nil }, - nameLookup: { _, authorID in ("Player \(authorID)", "Tuesday Mini") }, - suppressionGate: { _ in false } + // MARK: - Delta observation + + @Test("After baseline is established, consume returns the per-cell delta") + func deltaAfterBaseline() throws { + let fixture = try makeFixture() + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [position(0, 0): ("A", Date())] ) - let gameID = UUID() - await monitor.ingest([ - makeDelta(gameID: gameID, authorID: "alice", added: 4), - makeDelta(gameID: gameID, authorID: "bob", added: 1, cleared: 2), - ]) - - let summaries = await monitor.consumeOnOpen(gameID: gameID) - let byAuthor = Dictionary(uniqueKeysWithValues: summaries.map { ($0.authorID, $0) }) - #expect(byAuthor["alice"]?.added == 4) - #expect(byAuthor["alice"]?.playerName == "Player alice") - #expect(byAuthor["alice"]?.puzzleTitle == "Tuesday Mini") - #expect(byAuthor["bob"]?.added == 1) - #expect(byAuthor["bob"]?.cleared == 2) - #expect(await monitor.pendingBucketCount == 0) - } - - @Test("consumeOnOpen on a game with no pending tallies returns an empty list") - @MainActor func consumeOnOpenEmpty() async { - let monitor = makeMonitor() - let summaries = await monitor.consumeOnOpen(gameID: UUID()) + _ = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [ + position(0, 0): ("A", Date()), + position(2, 0): ("F", Date()), + position(2, 2): ("H", Date()), + ] + ) + let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + let summary = try #require(summaries.first) + #expect(summary.added == 2) + #expect(summary.cleared == 0) + #expect(!summary.isFirstObservation) + } + + @Test("A clear (letter→empty) since the baseline counts toward the cleared total") + func clearsCountInDelta() throws { + let fixture = try makeFixture() + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [ + position(0, 0): ("A", Date()), + position(0, 1): ("B", Date()), + ] + ) + _ = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [ + position(0, 0): ("A", Date()), + position(0, 1): ("", Date()), + ] + ) + let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + let summary = try #require(summaries.first) + #expect(summary.added == 0) + #expect(summary.cleared == 1) + } + + @Test("No activity since the baseline returns no summary") + func noActivityReturnsNothing() throws { + let fixture = try makeFixture() + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [position(0, 0): ("A", Date())] + ) + _ = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + + let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) #expect(summaries.isEmpty) } - @Test("Notification body pluralises and combines added + cleared") - func bodyTextWording() { - let alice = SessionMonitor.bodyText(playerName: "Alice", puzzleTitle: "Tuesday Mini", added: 1, cleared: 0) - #expect(alice == "Alice added 1 letter in the puzzle 'Tuesday Mini'") - let bob = SessionMonitor.bodyText(playerName: "Bob", puzzleTitle: "Tuesday Mini", added: 4, cleared: 2) - #expect(bob == "Bob added 4 letters and cleared 2 letters in the puzzle 'Tuesday Mini'") - let nameless = SessionMonitor.bodyText(playerName: "", puzzleTitle: "", added: 3, cleared: 0) - #expect(nameless == "A player added 3 letters in the puzzle") + @Test("The local author's Moves row is excluded from the summaries") + func localAuthorExcluded() throws { + let fixture = try makeFixture() + try writeMoves( + in: fixture, + authorID: Self.localAuthorID, + cells: [position(0, 0): ("A", Date())] + ) + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [position(0, 1): ("B", Date())] + ) + + let summaries = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + #expect(summaries.count == 1) + #expect(summaries.first?.authorID == Self.alice) + } + + @Test("Multiple peers each appear as their own summary") + func multiplePeers() throws { + let fixture = try makeFixture() + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [position(0, 0): ("A", Date())] + ) + try writeMoves( + in: fixture, + authorID: Self.bob, + cells: [ + position(0, 1): ("B", Date()), + position(0, 2): ("C", Date()), + ] + ) + + let summaries = fixture.monitor.consumeMovesSnapshots(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 }) + #expect(alice.added == 1) + #expect(bob.added == 2) + #expect(alice.isFirstObservation) + #expect(bob.isFirstObservation) + } + + @Test("Per-author snapshot merges multiple devices' Moves rows") + func peerMultipleDevicesMerged() throws { + let fixture = try makeFixture() + try writeMoves( + in: fixture, + authorID: Self.alice, + deviceID: "ipad", + cells: [position(0, 0): ("A", Date(timeIntervalSince1970: 100))] + ) + try writeMoves( + in: fixture, + authorID: Self.alice, + deviceID: "iphone", + cells: [position(0, 1): ("B", Date(timeIntervalSince1970: 200))] + ) + + let summaries = fixture.monitor.consumeMovesSnapshots(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. + #expect(summary.added == 2) + #expect(summary.isFirstObservation) + } + + // MARK: - clearMovesSnapshots + + @Test("clearMovesSnapshots with nil authorID resets every peer for the game") + func clearAllResets() throws { + let fixture = try makeFixture() + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [position(0, 0): ("A", Date())] + ) + try writeMoves( + in: fixture, + authorID: Self.bob, + cells: [position(0, 1): ("B", Date())] + ) + _ = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + + fixture.monitor.clearMovesSnapshots(for: fixture.gameID, by: nil) + + // Both peers' baselines are wiped, so the next consume sees no + // activity (the seeded baseline equals the current snapshot). + // Reissuing means the diff against an emptied baseline registers + // every still-filled cell as a re-add. Match against current state. + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [ + position(0, 0): ("A", Date()), + position(2, 2): ("H", Date()), + ] + ) + let summaries = fixture.monitor.consumeMovesSnapshots(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 + // baseline" is 2. + #expect(alice.added == 2) + #expect(alice.cleared == 0) + #expect(!alice.isFirstObservation) + } + + @Test("clearMovesSnapshots with a specific authorID leaves other peers' baselines untouched") + func clearSingleAuthorScoped() throws { + let fixture = try makeFixture() + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [position(0, 0): ("A", Date())] + ) + try writeMoves( + in: fixture, + authorID: Self.bob, + cells: [position(0, 1): ("B", Date())] + ) + _ = fixture.monitor.consumeMovesSnapshots(for: fixture.gameID) + + fixture.monitor.clearMovesSnapshots(for: fixture.gameID, by: Self.alice) + + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [ + position(0, 0): ("A", Date()), + position(2, 0): ("F", Date()), + ] + ) + try writeMoves( + in: fixture, + authorID: Self.bob, + cells: [ + position(0, 1): ("B", Date()), + position(2, 2): ("H", Date()), + ] + ) + let summaries = fixture.monitor.consumeMovesSnapshots(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 + // against empty → 2 added. Bob's baseline survives, so only the new + // cell since his prior consume counts → 1 added. + #expect(alice.added == 2) + #expect(bob.added == 1) } }