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 }