crossmate

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

SessionCoordinator.swift (41780B)


      1 import CoreData
      2 import CloudKit
      3 import Foundation
      4 import UIKit
      5 
      6 struct CompletionDeliveryProgress: Codable, Equatable {
      7     let resigned: Bool
      8     var durableRecords: Set<CompletionDurableRecordKind> = []
      9     var completionDelivered = false
     10     var replayDelivered = false
     11 
     12     var canDeliverCompletion: Bool {
     13         !completionDelivered && durableRecords.isSuperset(of: [.game, .moves])
     14     }
     15 
     16     var canDeliverReplay: Bool {
     17         completionDelivered && !replayDelivered && durableRecords.contains(.journal)
     18     }
     19 
     20     mutating func acknowledge(_ kinds: Set<CompletionDurableRecordKind>) {
     21         let gameWasDurable = durableRecords.contains(.game)
     22         if kinds.contains(.game) {
     23             durableRecords.insert(.game)
     24         }
     25         // Ignore data acknowledgements that can belong to requests already in
     26         // flight before completion was staged. Once the completed Game is
     27         // durable (or accompanies them in this event), subsequent saves come
     28         // from completion's flush/enqueue path.
     29         if gameWasDurable || kinds.contains(.game) {
     30             if kinds.contains(.moves) { durableRecords.insert(.moves) }
     31             if kinds.contains(.journal) { durableRecords.insert(.journal) }
     32         }
     33     }
     34 }
     35 
     36 /// Owns the play-session lifecycle: the sender-side session pushes
     37 /// (pause / win / resign / replay), the manual `nudge` push, and the
     38 /// receiver-side catch-up banner, driven by three lifecycle events —
     39 /// `notePuzzleActive`, `notePuzzleBackgrounded`, `notePuzzleClosed` — that
     40 /// `CrossmateApp`'s scene-phase and `onDisappear` handlers forward. The
     41 /// per-game timer lives in one `PuzzleSession` state machine per open game, so
     42 /// the leave/resume/grace interleavings are decided (and testable) in one
     43 /// place. `AppServices` composes one instance.
     44 @MainActor
     45 final class SessionCoordinator {
     46     /// Grace window before a backgrounded session is treated as ended. A
     47     /// briefly-backgrounded puzzle (phone sleep, app switcher peek, taking a
     48     /// call) should not fan out a pause ping to peers on every flicker —
     49     /// only a sustained absence does.
     50     static let sessionEndGrace: TimeInterval = 30
     51     /// Minimum gap between nudges for a given game, enforced per device. A
     52     /// nudge is a deliberate manual ping from the players menu, so the
     53     /// cooldown only guards against the button being spammed.
     54     static let nudgeCooldown: TimeInterval = 60
     55     /// Settle delay before the catch-up banner is computed on open. Lets the
     56     /// `.appeared` grid freshen land peer moves first, so the diff reflects the
     57     /// settled grid rather than a half-synced snapshot; cancelled if the user
     58     /// leaves before it elapses.
     59     static let sessionSummaryBannerDelay: TimeInterval = 3
     60     /// Cadence of the solve-clock liveness heartbeat for a shared game on screen.
     61     /// Kept well under `TimeLog.openGrace` so a co-solver's continuous sitting
     62     /// is never briefly capped on peers' clocks; only fires for shared games, so
     63     /// solo play makes no extra Player writes.
     64     static let clockHeartbeatInterval: TimeInterval = 90
     65 
     66     private let persistence: PersistenceController
     67     private let store: GameStore
     68     private let syncEngine: SyncEngine
     69     private let syncMonitor: SyncMonitor
     70     private let sessionMonitor: SessionMonitor
     71     private let gameViewedStore: GameViewedStore
     72     private let announcements: AnnouncementCenter
     73     private let identity: AuthorIdentity
     74     private let preferences: PlayerPreferences
     75     private let pushClient: PushClient?
     76     private let completionDefaults: UserDefaults
     77     private static let pendingCompletionsKey = "pendingCompletionDeliveries.v1"
     78 
     79     /// Per-open-game session state machines — each owns its game's grace
     80     /// timers, background assertion, banner timer, and announced state.
     81     /// Created on the first event for a game and pruned once idle; see
     82     /// `PuzzleSession`.
     83     private var sessions: [UUID: PuzzleSession] = [:]
     84 
     85     /// When this device last sent a nudge for each game, used to enforce
     86     /// `nudgeCooldown`. Device-local and ephemeral — a relaunch clears it,
     87     /// which at worst allows one extra nudge.
     88     private var lastNudge: [UUID: Date] = [:]
     89 
     90     /// Games whose solve clock has been opened at least once this app run. The
     91     /// first open of a game reconciles any session left dangling by a previous
     92     /// run's crash; later opens (resumes) continue the same sitting. Cleared by a
     93     /// relaunch, which is exactly when the next first-open should reconcile again.
     94     private var clockSessionsOpenedThisLaunch: Set<UUID> = []
     95 
     96     init(
     97         persistence: PersistenceController,
     98         store: GameStore,
     99         syncEngine: SyncEngine,
    100         syncMonitor: SyncMonitor,
    101         sessionMonitor: SessionMonitor,
    102         gameViewedStore: GameViewedStore,
    103         announcements: AnnouncementCenter,
    104         identity: AuthorIdentity,
    105         preferences: PlayerPreferences,
    106         pushClient: PushClient?,
    107         completionDefaults: UserDefaults = .standard
    108     ) {
    109         self.persistence = persistence
    110         self.store = store
    111         self.syncEngine = syncEngine
    112         self.syncMonitor = syncMonitor
    113         self.sessionMonitor = sessionMonitor
    114         self.gameViewedStore = gameViewedStore
    115         self.announcements = announcements
    116         self.identity = identity
    117         self.preferences = preferences
    118         self.pushClient = pushClient
    119         self.completionDefaults = completionDefaults
    120     }
    121 
    122     // MARK: Per-game sessions
    123 
    124     /// The session state machine for `gameID`, created on demand with its
    125     /// effects wired back into this coordinator's push and banner paths.
    126     private func session(for gameID: UUID) -> PuzzleSession {
    127         if let existing = sessions[gameID] { return existing }
    128         let session = PuzzleSession(
    129             gameID: gameID,
    130             effects: PuzzleSession.Effects(
    131                 publishEnd: { [weak self] pauseStart in
    132                     await self?.publishSessionEndPush(gameID: gameID, pauseStart: pauseStart)
    133                 },
    134                 postSummaryBanner: { [weak self] in
    135                     self?.postSessionSummaryBanner(gameID: gameID, reason: "open")
    136                 },
    137                 note: { [weak self] message in
    138                     self?.syncMonitor.note(message)
    139                 },
    140                 beginBackgroundAssertion: { name, onExpiration in
    141                     UIApplication.shared.beginBackgroundTask(
    142                         withName: name,
    143                         expirationHandler: onExpiration
    144                     )
    145                 },
    146                 endBackgroundAssertion: { id in
    147                     UIApplication.shared.endBackgroundTask(id)
    148                 }
    149             )
    150         )
    151         sessions[gameID] = session
    152         return session
    153     }
    154 
    155     /// Drops `gameID`'s session once nothing keeps it alive (no pending
    156     /// timer, no assertion held, no announced play session awaiting its
    157     /// pause). Run after each lifecycle event and after a pause publish, so
    158     /// the map only holds games with live session state.
    159     private func pruneIfIdle(_ gameID: UUID) {
    160         guard let session = sessions[gameID], session.isIdle else { return }
    161         sessions[gameID] = nil
    162     }
    163 
    164     // MARK: Puzzle lifecycle events
    165 
    166     /// The puzzle is on screen and the scene is active (open or resume).
    167     /// Stamps the active-puzzle ID for notification suppression. If a pause
    168     /// was queued in the grace window and the user returned before it fired,
    169     /// drops it — peers should see one continuous session, not a stray pause,
    170     /// for a brief absence (phone sleep, a call, an app-switcher peek that
    171     /// escalated to background). Either way, schedules the catch-up banner
    172     /// after a short settle; the matching baseline commit happens on leave.
    173     func notePuzzleActive(gameID: UUID) {
    174         NotificationState.setActivePuzzleID(gameID)
    175         session(for: gameID).cancelPendingEndPush()
    176         handlePuzzleOpened(gameID: gameID)
    177     }
    178 
    179     /// The app backgrounded while the puzzle is open. Clears the
    180     /// active-puzzle ID and commits the catch-up baseline (the user has seen
    181     /// what's on screen), then defers the pause by the end grace under a
    182     /// background assertion. The pause self-gates on content when it fires, so
    183     /// a brief visit that changed no letters reaches no one regardless.
    184     func notePuzzleBackgrounded(gameID: UUID) {
    185         NotificationState.clearActivePuzzleID(if: gameID)
    186         handlePuzzleLeft(gameID: gameID)
    187         session(for: gameID).scheduleEndPush(after: Self.sessionEndGrace)
    188     }
    189 
    190     /// The user navigated away from the puzzle (`onDisappear`). Same commit as
    191     /// backgrounding. The caller sequences `publishSessionEndPush` after the
    192     /// moves flush: the pause counts read the journal, so the buffered cell
    193     /// edits must land first. The pause self-gates on content, so a visit that
    194     /// changed no letters reaches no one.
    195     func notePuzzleClosed(gameID: UUID) {
    196         NotificationState.clearActivePuzzleID(if: gameID)
    197         handlePuzzleLeft(gameID: gameID)
    198         pruneIfIdle(gameID)
    199     }
    200 
    201     /// Persists the intent to notify peers before completion's asynchronous
    202     /// CloudKit work begins. The visible completion alert waits until both the
    203     /// Game marker and this device's final Moves snapshot are durable; replay
    204     /// waits for the journal too.
    205     func stageCompletionDelivery(gameID: UUID, resigned: Bool) {
    206         var pending = pendingCompletions()
    207         pending[gameID] = CompletionDeliveryProgress(resigned: resigned)
    208         savePendingCompletions(pending)
    209         syncMonitor.note("push(completion): staged pending durable Game+Moves for \(gameID.uuidString.prefix(8))…")
    210     }
    211 
    212     func noteCompletionRecordsSaved(
    213         _ records: [UUID: Set<CompletionDurableRecordKind>]
    214     ) async {
    215         var pending = pendingCompletions()
    216         for (gameID, kinds) in records where pending[gameID] != nil {
    217             pending[gameID]?.acknowledge(kinds)
    218         }
    219         savePendingCompletions(pending)
    220         for gameID in records.keys where pending[gameID] != nil {
    221             await deliverPendingCompletion(gameID: gameID)
    222         }
    223     }
    224 
    225     /// Retries a worker publish that was interrupted after CloudKit durability
    226     /// had already been established. Called during service wiring on launch.
    227     func resumePendingCompletionDeliveries() async {
    228         for gameID in pendingCompletions().keys {
    229             await deliverPendingCompletion(gameID: gameID)
    230         }
    231     }
    232 
    233     private func deliverPendingCompletion(gameID: UUID) async {
    234         var all = pendingCompletions()
    235         guard var pending = all[gameID] else { return }
    236         if pending.canDeliverCompletion {
    237             guard await publishCompletionPush(gameID: gameID, resigned: pending.resigned) else { return }
    238             pending.completionDelivered = true
    239             all[gameID] = pending
    240             savePendingCompletions(all)
    241         }
    242         if pending.canDeliverReplay {
    243             guard await publishReplayPush(gameID: gameID) else { return }
    244             pending.replayDelivered = true
    245         }
    246         if pending.completionDelivered && pending.replayDelivered {
    247             all.removeValue(forKey: gameID)
    248         } else {
    249             all[gameID] = pending
    250         }
    251         savePendingCompletions(all)
    252     }
    253 
    254     private func pendingCompletions() -> [UUID: CompletionDeliveryProgress] {
    255         guard let data = completionDefaults.data(forKey: Self.pendingCompletionsKey),
    256               let stored = try? JSONDecoder().decode([String: CompletionDeliveryProgress].self, from: data)
    257         else { return [:] }
    258         return Dictionary(uniqueKeysWithValues: stored.compactMap { key, value in
    259             UUID(uuidString: key).map { ($0, value) }
    260         })
    261     }
    262 
    263     private func savePendingCompletions(_ pending: [UUID: CompletionDeliveryProgress]) {
    264         let stored = Dictionary(uniqueKeysWithValues: pending.map { ($0.key.uuidString, $0.value) })
    265         completionDefaults.set(try? JSONEncoder().encode(stored), forKey: Self.pendingCompletionsKey)
    266     }
    267 
    268     // MARK: Nudge
    269 
    270     /// Whether a nudge for `gameID` is allowed right now — i.e. the cooldown
    271     /// since the last one this device sent has elapsed. The players menu reads
    272     /// this (rebuilt each time it opens) to disable the button. It does not
    273     /// consult the roster or push capability; an empty fan-out is a silent
    274     /// no-op inside `nudge`.
    275     func canNudge(gameID: UUID, asOf now: Date = Date()) -> Bool {
    276         guard let last = lastNudge[gameID] else { return true }
    277         return now.timeIntervalSince(last) >= Self.nudgeCooldown
    278     }
    279 
    280     /// When the next nudge for `gameID` becomes allowed, or `nil` if one is
    281     /// allowed right now. The nudge button reads this so it can re-enable itself
    282     /// exactly when the cooldown lapses, rather than polling `canNudge`.
    283     func nudgeReadyAt(gameID: UUID, asOf now: Date = Date()) -> Date? {
    284         guard let last = lastNudge[gameID] else { return nil }
    285         let ready = last.addingTimeInterval(Self.nudgeCooldown)
    286         return ready > now ? ready : nil
    287     }
    288 
    289     /// Sends a manual nudge for `gameID` to every other player who isn't
    290     /// currently present in the puzzle, rousing them through an APNs alert. A
    291     /// deliberate action from the players menu, so unlike the session pushes it
    292     /// carries no grid summary — just "Alice nudged you to play X". Gated by
    293     /// `nudgeCooldown` (the button is also disabled via `canNudge`, but the
    294     /// guard here closes the double-tap race), and skipped on a finished or
    295     /// access-revoked game where there's nothing to rouse anyone into.
    296     func nudge(gameID: UUID) async {
    297         guard canNudge(gameID: gameID) else {
    298             syncMonitor.note("push(nudge): skipped (cooldown)")
    299             return
    300         }
    301         guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else {
    302             syncMonitor.note("push(nudge): skipped (no authorID)")
    303             return
    304         }
    305         // Arm the cooldown the moment we accept the gesture, not after a publish
    306         // that happens to find recipients. The button flashes "Nudge Sent" and
    307         // dims on every tap regardless of how many devices we actually reach (a
    308         // present-only or push-incapable peer reaches none), so the cooldown that
    309         // drives the dimming has to track the gesture — otherwise the button
    310         // snaps back to ready the instant the confirmation clears. Also closes
    311         // the double-tap race before this publish returns.
    312         lastNudge[gameID] = Date()
    313         guard let pushClient else {
    314             syncMonitor.note("push(nudge): skipped (no pushClient)")
    315             return
    316         }
    317         let plan = await pushPlan(for: gameID, excluding: localAuthorID)
    318         guard plan.completedAt == nil else {
    319             syncMonitor.note("push(nudge): skipped (game completed)")
    320             return
    321         }
    322         guard !plan.isAccessRevoked else {
    323             syncMonitor.note("push(nudge): skipped (access revoked)")
    324             return
    325         }
    326         // Broadcast to the whole room rather than an enumerated recipient list:
    327         // every participant registered under the game credential is reached even
    328         // if their Player record hasn't synced to us. A recipient who is
    329         // actually present suppresses the banner on the device they're using
    330         // (foreground `isSuppressed`) and sweeps it from their other devices
    331         // once their present device's read cursor syncs; `excludeAddress` keeps
    332         // the nudge off our own other devices.
    333         await pushClient.publish(
    334             kind: "nudge",
    335             gameID: gameID,
    336             addressees: [],
    337             title: "Crossmate",
    338             puzzleTitle: plan.title,
    339             broadcast: true,
    340             excludeAddress: store.localPushAddress(gameID: gameID, authorID: localAuthorID),
    341             broadcastPayload: PushPayload(event: .nudge, playerName: preferences.name),
    342             collapseID: PushClient.gameCollapseID(gameID),
    343             body: PuzzleNotificationText.nudgeBody(
    344                 playerName: preferences.name,
    345                 puzzleTitle: plan.title
    346             )
    347         )
    348     }
    349 
    350     /// Announces to everyone already in the room that this account has accepted
    351     /// an invitation and joined `gameID`. Broadcast like `nudge` — the joiner
    352     /// can't enumerate the other participants because their Player records are
    353     /// only just syncing in — and carries no grid summary, just "Alice joined
    354     /// 'X'". `excludeAddress` (passed by the join hook, which derives it as part
    355     /// of stamping the local push address) keeps the joiner's own other devices
    356     /// from being notified. Skipped on a finished or access-revoked game, which
    357     /// can't be meaningfully joined.
    358     func publishJoinPush(gameID: UUID, excludeAddress: String?) async {
    359         guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else {
    360             syncMonitor.note("push(join): skipped (no authorID)")
    361             return
    362         }
    363         guard let pushClient else {
    364             syncMonitor.note("push(join): skipped (no pushClient)")
    365             return
    366         }
    367         let plan = await pushPlan(for: gameID, excluding: localAuthorID)
    368         guard plan.completedAt == nil else {
    369             syncMonitor.note("push(join): skipped (game completed)")
    370             return
    371         }
    372         guard !plan.isAccessRevoked else {
    373             syncMonitor.note("push(join): skipped (access revoked)")
    374             return
    375         }
    376         await pushClient.publish(
    377             kind: "join",
    378             gameID: gameID,
    379             addressees: [],
    380             title: "Crossmate",
    381             puzzleTitle: plan.title,
    382             broadcast: true,
    383             excludeAddress: excludeAddress,
    384             broadcastPayload: PushPayload(event: .join, playerName: preferences.name),
    385             collapseID: PushClient.gameCollapseID(gameID),
    386             body: PuzzleNotificationText.joinBody(
    387                 playerName: preferences.name,
    388                 puzzleTitle: plan.title
    389             )
    390         )
    391     }
    392 
    393     /// Sender-side session-end push. For each recipient, tallies this
    394     /// device's journal entries newer than that recipient's read watermark
    395     /// (`Player.readThrough`), and ships a body describing only what *that*
    396     /// recipient hasn't seen. Caught-up recipients still get a presence-only
    397     /// "stopped solving"; recipients whose presence lease shows them in the
    398     /// game right now are dropped entirely — they watched the session live,
    399     /// and the push would banner their other devices.
    400     ///
    401     /// Suppresses the push when a peer device of this author wrote to
    402     /// Player during the grace window — that device is still playing and
    403     /// will publish its own pause when it stops.
    404     func publishSessionEndPush(gameID: UUID, pauseStart: Date = Date()) async {
    405         // A direct call (e.g. from `.onDisappear`) supersedes any pending
    406         // grace-window timer for this game — drop it so we don't fire a
    407         // second pause once the timer elapses.
    408         sessions[gameID]?.supersedePendingEndPush()
    409         guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else {
    410             syncMonitor.note("push(pause): skipped (no authorID)")
    411             return
    412         }
    413         // During the grace window this device wrote nothing to Player
    414         // (any local activity would have reset the timer via
    415         // `cancelPendingEndPush`). A Player `updatedAt` newer than
    416         // pauseStart therefore came from another device of this author —
    417         // that device is still active, so let its eventual pause cover
    418         // the session.
    419         if let updatedAt = store.playerUpdatedAt(for: gameID, by: localAuthorID),
    420            updatedAt > pauseStart {
    421             syncMonitor.note("push(pause): skipped (peer device active)")
    422             return
    423         }
    424         guard let pushClient else {
    425             syncMonitor.note("push(pause): skipped (no pushClient)")
    426             return
    427         }
    428         let plan = await pushPlan(for: gameID, excluding: localAuthorID)
    429         guard !plan.recipients.isEmpty else {
    430             syncMonitor.note("push(pause): skipped (no recipients)")
    431             return
    432         }
    433         // A finished or revoked game has no live play session, so a pause
    434         // summary is meaningless.
    435         guard plan.completedAt == nil else {
    436             syncMonitor.note("push(pause): skipped (game completed)")
    437             return
    438         }
    439         guard !plan.isAccessRevoked else {
    440             syncMonitor.note("push(pause): skipped (access revoked)")
    441             return
    442         }
    443         // Send to every participant: presence is no longer guessed here. A
    444         // present recipient suppresses the banner on the device they're using
    445         // and sweeps it from their others once their read cursor syncs. The
    446         // per-recipient tally below still drops recipients with nothing unseen,
    447         // so a session that changed no letters still reaches no one.
    448         let recipients = plan.recipients
    449         // The pause counts are derived from this device's own journal (gesture
    450         // history), not the merged grid, so the summary can name fills/clears/
    451         // checks/reveals. The merged-grid measurements still ride the
    452         // diagnostics block below for context.
    453         let journalEntries = store.localJournalEntries(for: gameID)
    454         // Sender-side diagnostics: store-derived measurements plus this
    455         // device's clock and the session-start it announced. Rides the
    456         // per-recipient payload (the planner stamps each recipient's presenceUntil)
    457         // so the receiver can log why the counts came out as they did.
    458         var diagnostics = store.movesDiagnostics(for: gameID, by: localAuthorID)
    459             ?? PushPayload.Diagnostics()
    460         diagnostics.senderNow = Date()
    461         // Each recipient is addressed only when this session changed letters
    462         // they haven't seen; cursor-only and check-only recipients are dropped
    463         // (see `SessionPushPlanner.sessionEndAddressees`).
    464         let addressees = SessionPushPlanner.sessionEndAddressees(
    465             recipients: recipients,
    466             journalEntries: journalEntries,
    467             selfAuthorID: localAuthorID,
    468             playerName: preferences.name,
    469             puzzleTitle: plan.title,
    470             diagnostics: diagnostics
    471         )
    472         guard !addressees.isEmpty else {
    473             // No recipient had unseen letter changes (cursor-only or
    474             // check-only session), or none could be addressed. Nothing to
    475             // report — the session still closed, so release the state machine.
    476             syncMonitor.note("push(pause): skipped (no letter changes to report)")
    477             pruneIfIdle(gameID)
    478             return
    479         }
    480         // Top-level broadcast body is the worker's fallback if an addressee
    481         // carries no per-recipient body. Under the new contract every
    482         // addressee has one, but the field is still required.
    483         let fallbackBody = PuzzleNotificationText.pauseBody(
    484             playerName: preferences.name,
    485             puzzleTitle: plan.title,
    486             fills: 0,
    487             clears: 0,
    488             checks: 0,
    489             reveals: 0
    490         )
    491         await pushClient.publish(
    492             kind: "pause",
    493             gameID: gameID,
    494             addressees: addressees,
    495             title: "Crossmate",
    496             puzzleTitle: plan.title,
    497             collapseID: PushClient.gameCollapseID(gameID),
    498             body: fallbackBody
    499         )
    500         // Advance each addressed recipient's notified-through watermark to the
    501         // latest move this pause reported. A later pause windows its counts to
    502         // the later of this and the recipient's presenceUntil, so a bounce that adds
    503         // no new move re-tallies to zero and reaches no one instead of
    504         // repeating the same summary. Only recipients we actually pushed to
    505         // advance: a recipient dropped for no letter changes (or no push
    506         // capability) keeps their old watermark and catches up when there's
    507         // genuinely new content. The addressee list carries only push
    508         // addresses, so map those back to author IDs.
    509         if let notifiedThrough = journalEntries.map(\.timestamp).max() {
    510             let addressedAddresses = Set(addressees.map(\.address))
    511             let addressed = recipients
    512                 .filter { $0.pushAddress.map(addressedAddresses.contains) ?? false }
    513                 .map(\.authorID)
    514             store.recordNotified(gameID: gameID, authorIDs: addressed, through: notifiedThrough)
    515         }
    516         // The pause closed the session; if no timer or assertion is live
    517         // either, the per-game state machine has nothing left to hold.
    518         pruneIfIdle(gameID)
    519     }
    520 
    521     private func publishCompletionPush(gameID: UUID, resigned: Bool) async -> Bool {
    522         let kindLabel = resigned ? "resign" : "win"
    523         guard let pushClient else {
    524             syncMonitor.note("push(\(kindLabel)): skipped (no pushClient)")
    525             return false
    526         }
    527         guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else {
    528             syncMonitor.note("push(\(kindLabel)): skipped (no authorID)")
    529             return false
    530         }
    531         let plan = await pushPlan(for: gameID, excluding: localAuthorID)
    532         guard !plan.recipients.isEmpty else {
    533             syncMonitor.note("push(\(kindLabel)): skipped (no recipients)")
    534             return true
    535         }
    536         // Send to every participant: presence is no longer guessed here. A
    537         // present recipient suppresses the banner where they're playing and
    538         // sweeps it from their other devices once their read cursor syncs.
    539         let event: PushPayload.Event = resigned ? .resign : .win
    540         let addressees = plan.recipients.compactMap { recipient in
    541             recipient.pushAddress.map {
    542                 PushClient.Addressee(
    543                     address: $0,
    544                     payload: PushPayload(
    545                         event: event,
    546                         playerName: preferences.name,
    547                         occurredAt: plan.completedAt
    548                     )
    549                 )
    550             }
    551         }
    552         guard !addressees.isEmpty else {
    553             syncMonitor.note("push(\(kindLabel)): skipped (no addressable recipients)")
    554             return true
    555         }
    556         let kind = resigned ? "resign" : "win"
    557         let body = PuzzleNotificationText.completionBody(
    558             playerName: preferences.name,
    559             puzzleTitle: plan.title,
    560             resigned: resigned
    561         )
    562         return await pushClient.publish(
    563             kind: kind,
    564             gameID: gameID,
    565             addressees: addressees,
    566             title: "Crossmate",
    567             puzzleTitle: plan.title,
    568             collapseID: PushClient.gameCollapseID(gameID),
    569             body: body
    570         )
    571     }
    572 
    573     private func publishReplayPush(gameID: UUID) async -> Bool {
    574         guard let pushClient else {
    575             syncMonitor.note("push(replay): skipped (no pushClient)")
    576             return false
    577         }
    578         let plan = await pushPlan(for: gameID)
    579         guard !plan.recipients.isEmpty else {
    580             syncMonitor.note("push(replay): skipped (no recipients)")
    581             return true
    582         }
    583         let addressees = plan.recipients.compactMap { recipient in
    584             recipient.pushAddress.map {
    585                 PushClient.Addressee(address: $0, payload: PushPayload(event: .replay))
    586             }
    587         }
    588         guard !addressees.isEmpty else {
    589             syncMonitor.note("push(replay): skipped (no addressable recipients)")
    590             return true
    591         }
    592         return await pushClient.publish(
    593             kind: "replay",
    594             gameID: gameID,
    595             addressees: addressees,
    596             title: "",
    597             background: true,
    598             body: ""
    599         )
    600     }
    601 
    602     private struct PushPlan {
    603         let recipients: [PushRecipient]
    604         let title: String
    605         let completedAt: Date?
    606         let isAccessRevoked: Bool
    607 
    608         static let empty = PushPlan(
    609             recipients: [],
    610             title: "",
    611             completedAt: nil,
    612             isAccessRevoked: false
    613         )
    614     }
    615 
    616     private func pushPlan(
    617         for gameID: UUID,
    618         excluding authorID: String? = nil
    619     ) async -> PushPlan {
    620         let ctx = persistence.container.newBackgroundContext()
    621         return await ctx.perform {
    622             let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    623             gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    624             gReq.fetchLimit = 1
    625             guard let game = try? ctx.fetch(gReq).first else { return .empty }
    626             var byAuthor: [String: (readThrough: Date?, notifiedThrough: Date?, pushAddress: String?)] = [:]
    627             let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    628             pReq.predicate = NSPredicate(format: "game == %@", game)
    629             for p in (try? ctx.fetch(pReq)) ?? [] {
    630                 guard let a = p.authorID,
    631                       a != CKCurrentUserDefaultName,
    632                       !a.isEmpty
    633                 else { continue }
    634                 if let authorID, a == authorID { continue }
    635                 byAuthor[a] = (p.readThrough, p.notifiedThrough, p.pushAddress)
    636             }
    637             let recipients = byAuthor.map {
    638                 PushRecipient(
    639                     authorID: $0.key,
    640                     readThrough: $0.value.readThrough,
    641                     notifiedThrough: $0.value.notifiedThrough,
    642                     pushAddress: $0.value.pushAddress
    643                 )
    644             }
    645             return PushPlan(
    646                 recipients: recipients,
    647                 title: PuzzleNotificationText.title(for: game),
    648                 completedAt: game.completedAt,
    649                 isAccessRevoked: game.isAccessRevoked
    650             )
    651         }
    652     }
    653 
    654     /// Hand-off called when the puzzle becomes active. Pulls any pending
    655     /// session-end tallies out of SessionMonitor and posts them as a
    656     /// transient announcement on the puzzle header, in lieu of the
    657     /// local notification that would otherwise have fired in a few
    658     /// minutes' time. No-op if nothing was accumulated.
    659     private func handlePuzzleOpened(gameID: UUID) {
    660         logLocalPauseDiagnostics(for: gameID)
    661         openClockSession(gameID: gameID)
    662         // Defer the banner so the open's `.appeared` grid freshen can land peer
    663         // moves first; otherwise it would diff against a half-synced grid and
    664         // under-report. The baseline is not touched here — it advances only on
    665         // leave (`handlePuzzleLeft`) — so this is a pure read and re-running it
    666         // on a later foreground is harmless.
    667         session(for: gameID).scheduleSummaryBanner(after: Self.sessionSummaryBannerDelay)
    668     }
    669 
    670     /// Called when the user leaves the puzzle (backgrounded or navigated away).
    671     /// Drops a still-pending banner timer and advances the local "last viewed"
    672     /// baseline — the user has now seen what's on screen, so the next open diffs
    673     /// against this moment — then ships that baseline to sibling devices on this
    674     /// account's own `Player.viewedAt`, so they converge on the latest
    675     /// view time rather than recomputing from their own view. The advance is
    676     /// monotonic, so it is harmless that `CrossmateApp`'s leave handler also
    677     /// stamps the baseline.
    678     private func handlePuzzleLeft(gameID: UUID) {
    679         sessions[gameID]?.cancelPendingSummaryBanner()
    680         sealClockSession(gameID: gameID)
    681         gameViewedStore.advance(Date(), forGame: gameID)
    682         guard let authorID = identity.currentID, !authorID.isEmpty,
    683               let viewedAt = gameViewedStore.lastViewed(forGame: gameID)
    684         else { return }
    685         // Write it onto our own Player record and enqueue the send. This also
    686         // rides the leave's read-cursor Player write, but enqueuing directly
    687         // guarantees it ships even when that write is a no-op.
    688         store.setViewedAt(viewedAt, gameID: gameID, authorID: authorID)
    689         let syncEngine = self.syncEngine
    690         // Leave-path Player write: enqueue durably but don't force a drain that
    691         // would race the suspension budget — siblings adopt the baseline on the
    692         // next CKSyncEngine sync.
    693         Task { await syncEngine.enqueuePlayer(gameID: gameID, authorID: authorID, reason: "viewedAt", drain: false) }
    694     }
    695 
    696     /// Opens (or, on resume, refreshes the heartbeat of) the local device's
    697     /// solve-time session and ships it on the Player record. Skipped once the
    698     /// game is finished — a solved puzzle's clock is frozen, so revisiting it
    699     /// must not start accruing again. Runs for solo games too: the Player record
    700     /// rides the same private-zone sync that already carries solo Moves across
    701     /// the owner's devices.
    702     private func openClockSession(gameID: UUID) {
    703         guard !store.isGameCompleted(gameID: gameID) else { return }
    704         // The first open of a game since launch reconciles a session left
    705         // dangling by a previous run's force-quit/crash; a resume within this run
    706         // continues the same sitting. `open` also refreshes the heartbeat, so a
    707         // resume re-arms liveness without a separate beat.
    708         let firstOpenThisLaunch = clockSessionsOpenedThisLaunch.insert(gameID).inserted
    709         guard store.openClockSession(
    710             gameID: gameID,
    711             authorID: localClockAuthorID,
    712             reconcileStale: firstOpenThisLaunch
    713         ) else { return }
    714         // Shared puzzle opens immediately run `activateSharing`, whose
    715         // Player-record burst sends the same row with read cursor, name,
    716         // selection, push address, and this freshly-written time log. Avoid a
    717         // separate clock enqueue that CKSyncEngine can ship as its own Player
    718         // save just before or after the burst.
    719         guard !store.isGameShared(gameID: gameID) else { return }
    720         enqueueClockIfSynced(gameID: gameID, reason: "clockOpen")
    721     }
    722 
    723     /// Periodic liveness heartbeat for the open solve session, ticked by the
    724     /// puzzle host while a *shared* game is on screen. Refreshes `beatAt` and
    725     /// ships it so a co-solver keeps extrapolating this still-open session toward
    726     /// now — without it, a continuous sitting longer than `TimeLog.openGrace`
    727     /// would be capped on peers' clocks and only catch up when this device leaves.
    728     /// A no-op once finished or when no session is open.
    729     func noteClockHeartbeat(gameID: UUID) {
    730         guard !store.isGameCompleted(gameID: gameID) else { return }
    731         guard store.beatClockSession(gameID: gameID, authorID: localClockAuthorID) else { return }
    732         enqueueClockIfSynced(gameID: gameID, reason: "clockBeat")
    733     }
    734 
    735     /// The author key for the local player's clock writes. Falls back to the
    736     /// CloudKit owner placeholder when no iCloud identity has resolved yet — a
    737     /// solo game on a device not signed into iCloud (or before the async
    738     /// `userRecordID` fetch lands) — so the clock still accumulates locally. The
    739     /// placeholder is the same value the roster and push planner already exclude
    740     /// from peer logic, and such writes are never enqueued for sync (see
    741     /// `enqueueClockIfSynced`).
    742     private var localClockAuthorID: String {
    743         identity.currentID ?? CKCurrentUserDefaultName
    744     }
    745 
    746     /// Enqueues the local player's Player record for sync, but only once a real
    747     /// iCloud identity is resolved — the placeholder-author local row must never
    748     /// be uploaded. When `currentID` is set, the clock wrote under it, so this
    749     /// ships the same record the write touched.
    750     private func enqueueClockIfSynced(gameID: UUID, reason: String) {
    751         guard let authorID = identity.currentID else { return }
    752         let syncEngine = self.syncEngine
    753         Task { await syncEngine.enqueuePlayer(gameID: gameID, authorID: authorID, reason: reason, drain: false) }
    754     }
    755 
    756     /// Seals the local device's open solve-time session on leave and ships it.
    757     /// Allowed even after completion so an in-progress session at the moment of
    758     /// the win is made durable (the display still freezes it at `completedAt`).
    759     private func sealClockSession(gameID: UUID) {
    760         guard store.sealClockSession(gameID: gameID, authorID: localClockAuthorID) else { return }
    761         enqueueClockIfSynced(gameID: gameID, reason: "clockSeal")
    762     }
    763 
    764     /// Seals the local solve session at the instant the game finished — win,
    765     /// observed solve, or resign — and ships it, so peers and sibling devices
    766     /// converge on the final time straight away rather than only when this device
    767     /// next leaves the puzzle. Sealing at the game's own completedAt keeps the
    768     /// final interval identical to what the display already freezes to. A no-op
    769     /// when no session is open.
    770     func noteClockCompleted(gameID: UUID) {
    771         let finishedAt = store.completedAt(forGame: gameID) ?? Date()
    772         guard store.sealClockSession(
    773             gameID: gameID,
    774             authorID: localClockAuthorID,
    775             at: finishedAt
    776         ) else { return }
    777         enqueueClockIfSynced(gameID: gameID, reason: "clockComplete")
    778     }
    779 
    780     /// Computes the receiver-side catch-up summary for `gameID` and, when a peer
    781     /// has unseen activity, posts (or replaces, by stable id) the "Puzzle
    782     /// Updated" banner. Read-only — the baseline advances on leave, not here —
    783     /// so it is safe to recompute on every foreground. Logs the per-peer counts
    784     /// it surfaces so a missing or wrong banner is diagnosable after the fact.
    785     private func postSessionSummaryBanner(gameID: UUID, reason: String) {
    786         // No baseline means a first-ever open: stay silent rather than flag the
    787         // whole grid, exactly as the border highlights do — the two surfaces
    788         // read this one cutoff so they always agree.
    789         guard let since = gameViewedStore.lastViewed(forGame: gameID) else {
    790             syncMonitor.note("session summary[\(gameID.uuidString.prefix(8))] \(reason): skipped (no baseline)")
    791             return
    792         }
    793         syncMonitor.note(
    794             "session summary[\(gameID.uuidString.prefix(8))] \(reason) diag: "
    795             + store.recentChangesDiagnosticSummary(forGame: gameID, since: since)
    796         )
    797         let summaries = sessionMonitor.summaries(for: gameID, since: since)
    798         guard !summaries.isEmpty else {
    799             syncMonitor.note("session summary[\(gameID.uuidString.prefix(8))] \(reason): skipped (no changes)")
    800             return
    801         }
    802         let detail = summaries.map { summary -> String in
    803             let who = summary.playerName.isEmpty
    804                 ? String(summary.authorID.prefix(8))
    805                 : summary.playerName
    806             return "\(who) +\(summary.added)/-\(summary.cleared)"
    807         }.joined(separator: ", ")
    808         syncMonitor.note(
    809             "session summary[\(gameID.uuidString.prefix(8))] \(reason): \(detail)"
    810         )
    811         announcements.post(Announcement(
    812             id: "session-summary-\(gameID.uuidString)",
    813             scope: .game(gameID),
    814             severity: .info,
    815             title: "Puzzle Updated",
    816             body: Self.formatSummaryBanner(summaries),
    817             dismissal: .transient(after: 6)
    818         ))
    819     }
    820 
    821     /// Logs this device's own view of each peer's Moves for `gameID`, using the
    822     /// same `movesDiagnostics` computation the sender embeds in a pause push.
    823     /// Pairs with the `pause-diagnostics` receipt the NSE records: a suspicious
    824     /// pushed count can be diffed field-for-field against local ground truth.
    825     /// Phantom cells that actually synced surface here too; ones that stayed
    826     /// local to the sender (un-uploaded churn) won't — which is itself the
    827     /// answer. `recipientPresenceUntil` carries this device's *actual* cursor, to
    828     /// compare against the value the peer's pushed diagnostics claimed it saw.
    829     private func logLocalPauseDiagnostics(for gameID: UUID) {
    830         let localAuthorID = identity.currentID
    831         let selfPresenceUntil = localAuthorID.flatMap { store.presenceUntil(for: gameID, by: $0) }
    832         for peerAuthorID in store.peerAuthorIDs(for: gameID, excluding: localAuthorID) {
    833             guard var diagnostics = store.movesDiagnostics(for: gameID, by: peerAuthorID)
    834             else { continue }
    835             diagnostics.senderNow = Date()
    836             diagnostics.recipientPresenceUntil = selfPresenceUntil
    837             syncMonitor.note(
    838                 "local pause diag peer=\(peerAuthorID.prefix(8)): \(diagnostics.summaryLine)"
    839             )
    840         }
    841     }
    842 
    843     nonisolated static func formatSummaryBanner(_ summaries: [SessionMonitor.SessionSummary]) -> String {
    844         guard !summaries.isEmpty else { return "" }
    845         let phrases: [String] = summaries.map { summary in
    846             let name = summary.playerName.isEmpty ? "A player" : summary.playerName
    847             var parts: [String] = []
    848             if summary.added > 0 {
    849                 parts.append("added \(summary.added) \(summary.added == 1 ? "letter" : "letters")")
    850             }
    851             if summary.cleared > 0 {
    852                 parts.append("cleared \(summary.cleared) \(summary.cleared == 1 ? "letter" : "letters")")
    853             }
    854             let action = parts.isEmpty ? "made changes" : parts.joined(separator: " and ")
    855             return "\(name) \(action)"
    856         }
    857         return "\(phrases.joined(separator: "; "))."
    858     }
    859 }