crossmate

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

SessionCoordinator.swift (37536B)


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