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 }