GameStore.swift (118760B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import Observation 5 import Security 6 7 /// Per-cell state for rendering a thumbnail. Plain value type so 8 /// SwiftUI can diff it cheaply. 9 enum GameThumbnailCell: Equatable { 10 case block 11 case empty 12 case filled 13 } 14 15 /// Value type backing a library row. Built from a `GameEntity` so that 16 /// SwiftUI's `@FetchRequest` can drive the list and still render through 17 /// an immutable, diff-friendly model. 18 struct GameSummary: Identifiable, Equatable { 19 let id: UUID 20 let title: String 21 let publisher: String? 22 let puzzleDate: Date? 23 let updatedAt: Date? 24 let completedAt: Date? 25 let gridWidth: Int 26 let gridHeight: Int 27 let thumbnailCells: [GameThumbnailCell] 28 /// `true` when the current user owns this game (`databaseScope == 0`). 29 let isOwned: Bool 30 /// `true` when this game has an active share (owner) or is joined via 31 /// a share (participant, `databaseScope == 1`). 32 let isShared: Bool 33 let isAccessRevoked: Bool 34 let hasUnreadOtherMoves: Bool 35 let allParticipants: [GameParticipantSummary] 36 37 /// The participants ordered for the Game List strip: highest scorer at the 38 /// leading edge. Derived from `allParticipants`, whose summaries already 39 /// carry each player's score, so the ordering lives in one place. 40 var stripParticipants: [GameParticipantSummary] { 41 ParticipantSummaries.sortedByScore( 42 allParticipants, 43 score: \.score, 44 name: \.name, 45 id: \.authorID 46 ) 47 } 48 49 init?( 50 entity: GameEntity, 51 localAuthorID: String? = nil, 52 localName: String = "Player", 53 localColor: PlayerColor = .blue 54 ) { 55 guard let id = entity.id else { return nil } 56 57 let width: Int 58 let height: Int 59 let publisher: String? 60 let puzzleDate: Date? 61 let blocks: [Bool] 62 63 if entity.gridWidth > 0, 64 entity.gridHeight > 0, 65 let mask = entity.blockMask, 66 mask.count == Int(entity.gridWidth) * Int(entity.gridHeight) { 67 // Fast path: derived data is cached on the entity, so the list 68 // can render without parsing XD on every keystroke-driven save. 69 width = Int(entity.gridWidth) 70 height = Int(entity.gridHeight) 71 publisher = entity.cachedPublisher 72 puzzleDate = entity.cachedPuzzleDate 73 blocks = mask.map { $0 != 0 } 74 } else { 75 // Fallback for legacy rows that haven't been backfilled yet, or 76 // test fixtures that bypass the creation helpers. The 77 // PersistenceController backfill should make this rare. 78 guard let source = entity.puzzleSource, 79 let xd = try? XD.parse(source) else { 80 return nil 81 } 82 let puzzle = Puzzle(xd: xd) 83 width = puzzle.width 84 height = puzzle.height 85 publisher = puzzle.publisher 86 puzzleDate = puzzle.date 87 var bs: [Bool] = [] 88 bs.reserveCapacity(puzzle.width * puzzle.height) 89 for r in 0..<puzzle.height { 90 for c in 0..<puzzle.width { 91 bs.append(puzzle.cells[r][c].isBlock) 92 } 93 } 94 blocks = bs 95 } 96 97 // A completed game is terminal and always renders solved (restore 98 // seals it to the solution), but the CellEntity cache mirrors the raw 99 // un-watermarked merge, which can permanently lack a winning letter — 100 // a clear stamped after the completion latch beats it on LWW forever. 101 // Derive the thumbnail from the latch, not the cache, so a finished 102 // game's thumbnail is full regardless of merge drift. 103 let isCompleted = entity.completedAt != nil 104 var filledSet: Set<Int> = [] 105 var scoreByAuthorID: [String: Int] = [:] 106 if !isCompleted { 107 let cellEntities = (entity.cells as? Set<CellEntity>) ?? [] 108 for ce in cellEntities where !(ce.letter ?? "").isEmpty { 109 let index = Int(ce.row) * width + Int(ce.col) 110 filledSet.insert(index) 111 guard blocks.indices.contains(index), !blocks[index] else { continue } 112 guard !CellMark(code: ce.markCode).isRevealed, 113 let authorID = ce.letterAuthorID, 114 !authorID.isEmpty, 115 authorID != CKCurrentUserDefaultName else { continue } 116 scoreByAuthorID[authorID, default: 0] += 1 117 } 118 } 119 120 var thumbCells: [GameThumbnailCell] = [] 121 thumbCells.reserveCapacity(width * height) 122 for r in 0..<height { 123 for c in 0..<width { 124 let idx = r * width + c 125 if blocks[idx] { 126 thumbCells.append(.block) 127 } else if isCompleted || filledSet.contains(idx) { 128 thumbCells.append(.filled) 129 } else { 130 thumbCells.append(.empty) 131 } 132 } 133 } 134 135 self.id = id 136 self.title = entity.title ?? "Untitled" 137 self.publisher = publisher 138 self.puzzleDate = puzzleDate 139 self.updatedAt = entity.updatedAt 140 self.completedAt = entity.completedAt 141 self.gridWidth = width 142 self.gridHeight = height 143 self.thumbnailCells = thumbCells 144 self.isOwned = entity.databaseScope == 0 145 self.isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 146 self.isAccessRevoked = entity.isAccessRevoked 147 self.allParticipants = Self.computeParticipants( 148 gameID: id, 149 entity: entity, 150 localAuthorID: localAuthorID, 151 localName: localName, 152 localColor: localColor, 153 scoreByAuthorID: scoreByAuthorID 154 ) 155 self.hasUnreadOtherMoves = Self.computeHasUnread( 156 isShared: self.isShared, 157 latest: entity.latestOtherMoveAt, 158 // The unread badge keys off the read *watermark*, not the presence 159 // lease. Rows created before the watermark existed only have the 160 // older cursor, so use a non-future legacy value as a migration 161 // fallback until the first real readThroughAt write lands. 162 readThrough: entity.readThroughAt, 163 legacyReadAt: entity.lastReadOtherMoveAt 164 ) 165 } 166 167 /// A game is unread when a peer's move is newer than this account's read 168 /// watermark. Completed games count too: a co-player finishing or resigning 169 /// is itself an unseen event (the badge ledger already flags it from the 170 /// completion push), and opening the finished game to review it advances 171 /// `readThroughAt` via `markOtherMovesRead`, clearing the dot like any other. 172 fileprivate static func computeHasUnread( 173 isShared: Bool, 174 latest: Date?, 175 readThrough: Date?, 176 legacyReadAt: Date? 177 ) -> Bool { 178 guard isShared, let latest else { return false } 179 if let readThrough { 180 return latest > readThrough 181 } 182 guard let legacyReadAt, legacyReadAt <= Date() else { return true } 183 return latest > legacyReadAt 184 } 185 186 private static func computeParticipants( 187 gameID: UUID, 188 entity: GameEntity, 189 localAuthorID: String?, 190 localName: String, 191 localColor: PlayerColor, 192 scoreByAuthorID: [String: Int] 193 ) -> [GameParticipantSummary] { 194 guard entity.ckShareRecordName != nil || entity.databaseScope == 1 else { 195 return [] 196 } 197 198 var namesByAuthor: [String: String] = [:] 199 let playerEntities = (entity.players as? Set<PlayerEntity>) ?? [] 200 for player in playerEntities { 201 guard let authorID = player.authorID, !authorID.isEmpty else { continue } 202 if let name = player.name?.trimmingCharacters(in: .whitespacesAndNewlines), 203 !name.isEmpty { 204 namesByAuthor[authorID] = name 205 } 206 } 207 208 let movesEntities = (entity.moves as? Set<MovesEntity>) ?? [] 209 var moveAuthorIDs: [String] = [] 210 for moves in movesEntities { 211 guard let authorID = moves.authorID, !authorID.isEmpty else { continue } 212 moveAuthorIDs.append(authorID) 213 } 214 215 let nicknames = friendNicknames(in: entity.managedObjectContext) 216 return ParticipantSummaries.allParticipants( 217 gameID: gameID, 218 namesByAuthor: namesByAuthor, 219 moveAuthorIDs: moveAuthorIDs, 220 nicknamesByAuthor: nicknames, 221 localAuthorID: localAuthorID, 222 localName: localName, 223 localColor: localColor, 224 scoreByAuthorID: scoreByAuthorID 225 ) 226 } 227 228 private static func friendNicknames(in context: NSManagedObjectContext?) -> [String: String] { 229 guard let context else { return [:] } 230 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 231 req.predicate = NSPredicate( 232 format: "isBlocked == NO AND nickname != nil AND nickname != %@", "" 233 ) 234 let friends = (try? context.fetch(req)) ?? [] 235 var nicknames: [String: String] = [:] 236 for friend in friends { 237 guard let authorID = friend.authorID, !authorID.isEmpty, 238 let nickname = friend.nickname?.trimmingCharacters(in: .whitespacesAndNewlines), 239 !nickname.isEmpty 240 else { continue } 241 nicknames[authorID] = nickname 242 } 243 return nicknames 244 } 245 } 246 247 /// CloudKit routing metadata captured before a game row is deleted locally. 248 /// The sync layer cannot look this up after the Core Data cascade completes. 249 struct GameCloudDeletion: Sendable, Equatable { 250 let gameID: UUID 251 let databaseScope: Int16 252 let ckZoneName: String 253 let ckZoneOwnerName: String 254 /// The private-DB archive backup zone (archive-<id>) to tear down alongside 255 /// the game, set only for a finished participant game whose backup lives in 256 /// a zone separate from its live (shared) one. nil otherwise — an unarchived 257 /// game, or a materialized archive whose own zone already is the archive and 258 /// is covered by ckZoneName. 259 let archiveZoneName: String? 260 } 261 262 /// Per-entity memoisation of `GameSummary`. The library list re-runs on 263 /// every Core Data save (i.e., every keystroke), but only the active 264 /// entity's fields actually change. The cache key intentionally uses fast 265 /// scalar/string fields so a hit never has to fault the `cells` 266 /// relationship; `MovesUpdater` bumps `updatedAt` atomically with cell 267 /// writes, so it acts as a faithful proxy for "the filled-cell thumbnail 268 /// might have changed". 269 /// 270 /// The puzzle-structure fields (`title`, cached publisher/date, grid dims, 271 /// block mask) are keyed directly because they are *not* proxied by 272 /// `updatedAt`: `replacePuzzleSource` rewrites them during an NYT-style 273 /// upgrade without bumping `updatedAt`, so the row would otherwise render 274 /// the old title/grid until unrelated cell activity nudged the proxy. 275 @MainActor 276 final class GameSummaryCache { 277 private struct Key: Equatable { 278 let updatedAt: Date? 279 let completedAt: Date? 280 let latestOther: Date? 281 let readThrough: Date? 282 let scope: Int16 283 let shareName: String? 284 let revoked: Bool 285 let title: String? 286 let publisher: String? 287 let puzzleDate: Date? 288 let gridWidth: Int16 289 let gridHeight: Int16 290 let blockMask: Data? 291 let localAuthorID: String? 292 let localName: String 293 let localColorID: String 294 let playersSignature: [String] 295 let movesAuthorIDs: [String] 296 let nicknamesSignature: [String] 297 } 298 private var entries: [NSManagedObjectID: (key: Key, summary: GameSummary)] = [:] 299 300 func summary( 301 for entity: GameEntity, 302 localAuthorID: String? = nil, 303 localName: String = "Player", 304 localColor: PlayerColor = .blue 305 ) -> GameSummary? { 306 let key = Key( 307 updatedAt: entity.updatedAt, 308 completedAt: entity.completedAt, 309 latestOther: entity.latestOtherMoveAt, 310 readThrough: entity.readThroughAt, 311 scope: entity.databaseScope, 312 shareName: entity.ckShareRecordName, 313 revoked: entity.isAccessRevoked, 314 title: entity.title, 315 publisher: entity.cachedPublisher, 316 puzzleDate: entity.cachedPuzzleDate, 317 gridWidth: entity.gridWidth, 318 gridHeight: entity.gridHeight, 319 blockMask: entity.blockMask, 320 localAuthorID: localAuthorID, 321 localName: localName, 322 localColorID: localColor.id, 323 playersSignature: Self.playersSignature(for: entity), 324 movesAuthorIDs: Self.movesAuthorIDs(for: entity), 325 nicknamesSignature: Self.nicknamesSignature(in: entity.managedObjectContext) 326 ) 327 if let hit = entries[entity.objectID], hit.key == key { 328 return hit.summary 329 } 330 guard let fresh = GameSummary( 331 entity: entity, 332 localAuthorID: localAuthorID, 333 localName: localName, 334 localColor: localColor 335 ) else { return nil } 336 entries[entity.objectID] = (key, fresh) 337 return fresh 338 } 339 340 private static func playersSignature(for entity: GameEntity) -> [String] { 341 let players = (entity.players as? Set<PlayerEntity>) ?? [] 342 return players.map { player in 343 "\(player.authorID ?? "")|\(player.name ?? "")|\(player.updatedAt?.timeIntervalSinceReferenceDate ?? 0)" 344 } 345 .sorted() 346 } 347 348 private static func movesAuthorIDs(for entity: GameEntity) -> [String] { 349 let moves = (entity.moves as? Set<MovesEntity>) ?? [] 350 return Array(Set(moves.compactMap { $0.authorID })).sorted() 351 } 352 353 private static func nicknamesSignature(in context: NSManagedObjectContext?) -> [String] { 354 guard let context else { return [] } 355 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 356 req.predicate = NSPredicate( 357 format: "isBlocked == NO AND nickname != nil AND nickname != %@", "" 358 ) 359 let friends = (try? context.fetch(req)) ?? [] 360 return friends.compactMap { friend in 361 guard let authorID = friend.authorID, !authorID.isEmpty else { return nil } 362 return "\(authorID)|\(friend.nickname ?? "")" 363 } 364 .sorted() 365 } 366 } 367 368 extension GameEntity { 369 /// Writes the derived puzzle data that `GameSummary` (and the library 370 /// list) needs into the entity, so the list path never has to call 371 /// `XD.parse` on every Core Data save. Block layout is encoded as one 372 /// byte per cell in row-major order. 373 func populateCachedSummaryFields(from puzzle: Puzzle) { 374 cachedPublisher = puzzle.publisher 375 cachedPuzzleDate = puzzle.date 376 gridWidth = Int16(puzzle.width) 377 gridHeight = Int16(puzzle.height) 378 379 var bytes = [UInt8]() 380 bytes.reserveCapacity(puzzle.width * puzzle.height) 381 for r in 0..<puzzle.height { 382 for c in 0..<puzzle.width { 383 bytes.append(puzzle.cells[r][c].isBlock ? 1 : 0) 384 } 385 } 386 blockMask = Data(bytes) 387 } 388 } 389 390 /// Repository over the local Core Data store. Manages the lifecycle of 391 /// games — loading a specific one, creating new ones from bundled puzzles, 392 /// and deleting them. The library list itself is driven by `@FetchRequest` 393 /// in `GameListView`, not this type. Persistence of individual cell 394 /// mutations is handled by `GameMutator`. 395 @MainActor 396 @Observable 397 final class GameStore { 398 let persistence: PersistenceController 399 private var context: NSManagedObjectContext { persistence.viewContext } 400 401 private(set) var currentGame: Game? 402 private(set) var currentMutator: GameMutator? 403 private(set) var currentEntity: GameEntity? 404 405 private let movesUpdater: MovesUpdater 406 private let movesJournal: MovesJournal 407 408 /// Returns the current iCloud author ID, or nil while the first 409 /// `userRecordID()` lookup is still pending. The inner Optional reflects 410 /// genuine "don't know yet" state on first install. 411 private let authorIDProvider: @MainActor () -> String? 412 413 /// Called when a new game's `ckRecordName` is ready to push. 414 private let onGameCreated: (String) -> Void 415 416 /// Called with CloudKit zone metadata after a game is removed locally. 417 private let onGameDeleted: (GameCloudDeletion) -> Void 418 419 /// Called when a mutable field on the `Game` record (e.g. `completedAt`) 420 /// changes and needs to be re-pushed. 421 private let onGameUpdated: (String) -> Void 422 423 /// Called once a game completes (win or resign) with `(gameID, authorID)`, 424 /// so this device's move journal can be uploaded for later replay (Phase 425 /// 2). Separate from `onGameUpdated`: that re-pushes the Game record, this 426 /// pushes the per-device Journal asset. Assigned post-init (like the other 427 /// UI-facing callbacks below) so the handler can reference `AppServices`. 428 @ObservationIgnored 429 var onJournalComplete: (@MainActor (UUID, String) -> Void)? 430 431 /// Fires when the count of shared games with unseen other-author moves 432 /// may have changed (inbound moves merged, a game opened, a game 433 /// deleted). Consumers refresh the app-icon badge from here. 434 @ObservationIgnored 435 var onUnreadOtherMovesChanged: (() -> Void)? 436 @ObservationIgnored 437 var onLocalCellEdit: (@MainActor (RealtimeCellEdit) -> Void)? 438 @ObservationIgnored 439 var onLocalCellEditBatch: (@MainActor ([RealtimeCellEdit]) -> Void)? 440 441 private let eventLog: EventLog? 442 443 init( 444 persistence: PersistenceController, 445 movesUpdater: MovesUpdater, 446 authorIDProvider: @escaping @MainActor () -> String?, 447 onGameCreated: @escaping (String) -> Void, 448 onGameUpdated: @escaping (String) -> Void, 449 onGameDeleted: @escaping (GameCloudDeletion) -> Void, 450 eventLog: EventLog? = nil 451 ) { 452 self.persistence = persistence 453 self.movesUpdater = movesUpdater 454 // The journal needs nothing but the local store, so the store owns it 455 // rather than having callers thread it in (unlike MovesUpdater, which 456 // depends on identity + the sync sink and so is built in AppServices). 457 self.movesJournal = MovesJournal(persistence: persistence) 458 self.authorIDProvider = authorIDProvider 459 self.onGameCreated = onGameCreated 460 self.onGameUpdated = onGameUpdated 461 self.onGameDeleted = onGameDeleted 462 self.eventLog = eventLog 463 } 464 465 private func saveContext(_ label: String) { 466 do { 467 try context.save() 468 } catch { 469 eventLog?.note("GameStore: \(label) save failed — \(error)", level: "error") 470 } 471 } 472 473 enum LoadError: Error { 474 case sampleResourceMissing 475 case persistedSourceMissing 476 case gameNotFound 477 } 478 479 // MARK: - Remote update 480 481 /// Re-replays the current game from its move log after remote moves have 482 /// been written into Core Data by the sync engine. 483 func refreshCurrentGame() { 484 guard let game = currentGame, let entity = currentEntity else { return } 485 // On this path the SyncEngine's inbound fetch has already replayed 486 // the CellEntity cache atomically with the inbound MovesEntity 487 // (see SyncEngine.replayCellCache); rewriting it here would do the 488 // same work against the main context, so we skip it to keep the 489 // main thread free during co-solve bursts. 490 restore(game: game, from: entity, updateCache: false) 491 } 492 493 /// Merges every device's `MovesEntity` rows for each game ID and updates 494 /// the `CellEntity` cache so that list thumbnails reflect local edits 495 /// immediately after a `MovesUpdater` flush, without waiting for the next 496 /// sync cycle. Runs on a background context to keep the main actor free. 497 func replayCellCaches(for gameIDs: Set<UUID>) async { 498 let bgCtx = persistence.container.newBackgroundContext() 499 bgCtx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 500 await bgCtx.perform { 501 for gameID in gameIDs { 502 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 503 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 504 req.fetchLimit = 1 505 guard let entity = try? bgCtx.fetch(req).first else { continue } 506 507 let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 508 movesReq.predicate = NSPredicate(format: "game == %@", entity) 509 let values: [MovesValue] = ((try? bgCtx.fetch(movesReq)) ?? []) 510 .compactMap { Self.movesValue(from: $0) } 511 let grid = GridStateMerger.merge(values) 512 Self.applyCellCache(to: entity, from: grid, in: bgCtx) 513 } 514 if bgCtx.hasChanges { 515 try? bgCtx.save() 516 } 517 } 518 } 519 520 /// Serial queue feeding the peer-change ledger writer. The continuation is 521 /// the producer end; a single long-lived consumer (started lazily on first 522 /// use) drains it one request at a time. One consumer is the whole safety 523 /// argument: builds never overlap, so the upsert-by-position in 524 /// `updatePeerChangeLedger` can't duplicate a row. 525 private var ledgerRequests: AsyncStream<Set<UUID>>.Continuation? 526 private var peerChangeLedgerBuildSerial = 0 527 528 /// Fire-and-forget request to refresh the peer-change ledger for `gameIDs`. 529 /// Returns immediately — the inbound-moves hot path must never wait on this 530 /// database write. The request is just buffered onto the serial queue; the 531 /// single consumer applies it when it gets there. 532 func enqueuePeerChangeLedgerUpdate(for gameIDs: Set<UUID>) { 533 guard !gameIDs.isEmpty else { return } 534 eventLog?.note( 535 "peer ledger enqueue: games=[\(gameIDs.map { String($0.uuidString.prefix(8)) }.sorted().joined(separator: ","))]" 536 ) 537 if ledgerRequests == nil { 538 let (stream, continuation) = AsyncStream<Set<UUID>>.makeStream() 539 ledgerRequests = continuation 540 // The sole consumer: serial by construction, so no two builds run at 541 // once. Inherits this `@MainActor`, hopping to a background context 542 // only inside `updatePeerChangeLedger`. 543 Task { [weak self] in 544 for await gameIDs in stream { 545 await self?.updatePeerChangeLedger(for: gameIDs) 546 } 547 } 548 } 549 ledgerRequests?.yield(gameIDs) 550 } 551 552 /// Maintains the device-local per-cell letter-change ledger 553 /// (`PeerChangeEntity`) for each game that just received inbound moves. For 554 /// every cell whose letter differs from what the ledger holds, upserts a row 555 /// stamped with the move's letter-change time; a check (a mark-only 556 /// re-stamp) leaves the letter unchanged and so writes nothing. A completed 557 /// game is terminal, so its rows are dropped and not rebuilt. Runs on a 558 /// background context, off the main actor. 559 /// 560 /// The ledger is what the "changed while you were away" borders and catch-up 561 /// banner read (`recentChanges(forGame:since:)`). Recording a letter-change 562 /// time — rather than trusting the synced cell's `updatedAt`, which a check 563 /// bumps — is what stops a peer's check sweep from flagging the whole board 564 /// on rejoin. A first build for a game (no rows yet) seeds every current 565 /// cell at `.distantPast`, a silent baseline that surfaces nothing. 566 /// 567 /// In the app this is driven through `enqueuePeerChangeLedgerUpdate`, whose 568 /// single serial consumer guarantees builds never overlap — so the 569 /// upsert-by-position below can't duplicate a row. Tests call it directly 570 /// and `await` it for determinism. 571 func updatePeerChangeLedger(for gameIDs: Set<UUID>) async { 572 guard !gameIDs.isEmpty else { return } 573 peerChangeLedgerBuildSerial += 1 574 let serial = peerChangeLedgerBuildSerial 575 let startedAt = Date() 576 eventLog?.note( 577 "peer ledger build #\(serial) start: games=[\(gameIDs.map { String($0.uuidString.prefix(8)) }.sorted().joined(separator: ","))]" 578 ) 579 let ctx = persistence.container.newBackgroundContext() 580 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 581 var diagnostics: [String] = [] 582 await ctx.perform { 583 for gameID in gameIDs { 584 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 585 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 586 gameReq.fetchLimit = 1 587 guard let game = try? ctx.fetch(gameReq).first else { 588 diagnostics.append("\(gameID.uuidString.prefix(8)) missing-game") 589 continue 590 } 591 592 let ledgerReq = NSFetchRequest<PeerChangeEntity>(entityName: "PeerChangeEntity") 593 ledgerReq.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg) 594 let existingRows = (try? ctx.fetch(ledgerReq)) ?? [] 595 596 // A completed game is terminal: its grid is sealed and the 597 // "changed while you were away" surfaces never read this ledger 598 // again, so drop the rows and stop maintaining it. The cleanup 599 // lives here, not only at the completion call site, because a 600 // late peer move can still arrive for a finished game. 601 if game.completedAt != nil { 602 for row in existingRows { ctx.delete(row) } 603 diagnostics.append( 604 "\(gameID.uuidString.prefix(8)) completed existing=\(existingRows.count) deleted" 605 ) 606 continue 607 } 608 609 let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 610 movesReq.predicate = NSPredicate(format: "game == %@", game) 611 let values: [MovesValue] = ((try? ctx.fetch(movesReq)) ?? []) 612 .compactMap { Self.movesValue(from: $0) } 613 let current = GridStateMerger.mergeWithProvenance(values) 614 615 var rowByPosition: [GridPosition: PeerChangeEntity] = [:] 616 for row in existingRows { 617 rowByPosition[GridPosition(row: Int(row.row), col: Int(row.col))] = row 618 } 619 let recorded = rowByPosition.mapValues { Self.peerChange(from: $0) } 620 621 let upserts = PeerChangeLedger.upserts( 622 current: current, 623 recorded: recorded, 624 seeding: recorded.isEmpty 625 ) 626 diagnostics.append( 627 "\(gameID.uuidString.prefix(8)) existing=\(existingRows.count) " 628 + "moves=\(values.count) current=\(current.count) " 629 + "seeding=\(recorded.isEmpty) upserts=\(upserts.count) " 630 + Self.peerChangeSampleSummary(upserts) 631 ) 632 for change in upserts { 633 let row = rowByPosition[change.position] ?? PeerChangeEntity(context: ctx) 634 row.gameID = gameID 635 row.row = Int16(change.position.row) 636 row.col = Int16(change.position.col) 637 row.letter = change.letter 638 row.authorID = change.authorID 639 row.changedAt = change.changedAt 640 row.game = game 641 } 642 } 643 guard ctx.hasChanges else { return } 644 do { 645 try ctx.save() 646 } catch { 647 let message = "GameStore: peer change ledger save failed — \(error)" 648 Task { @MainActor [weak self] in 649 self?.eventLog?.note(message, level: "error") 650 } 651 } 652 } 653 let elapsed = Date().timeIntervalSince(startedAt) 654 eventLog?.note( 655 "peer ledger build #\(serial) end: elapsed=\(String(format: "%.3f", elapsed))s " 656 + diagnostics.joined(separator: " | ") 657 ) 658 } 659 660 /// Updates `latestOtherMoveAt` for each game whose Moves record was just 661 /// updated by another iCloud user, driving the unread-badge heuristic. 662 /// `gameIDs` are the games that received an inbound `Moves` record in the 663 /// most recent sync batch; for each, we scan the now-persisted 664 /// `MovesEntity` rows and pick the latest `updatedAt` whose row is owned 665 /// by a different `authorID` than the local user. If the game is currently 666 /// open, `lastReadOtherMoveAt` is advanced in lockstep so the badge 667 /// doesn't appear for activity the user is already watching. 668 func noteIncomingMovesUpdate(gameIDs: Set<UUID>, currentAuthorID: String?) { 669 guard let currentAuthorID, !gameIDs.isEmpty else { return } 670 671 for gameID in gameIDs { 672 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 673 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 674 gameReq.fetchLimit = 1 675 guard let entity = try? context.fetch(gameReq).first else { continue } 676 677 let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 678 movesReq.predicate = NSPredicate( 679 format: "game == %@ AND authorID != %@", 680 entity, 681 currentAuthorID 682 ) 683 let rows = (try? context.fetch(movesReq)) ?? [] 684 guard let latest = rows.compactMap(\.updatedAt).max() else { continue } 685 686 if (entity.latestOtherMoveAt ?? .distantPast) < latest { 687 entity.latestOtherMoveAt = latest 688 } 689 // Use the foreground-visible signal rather than `currentEntity` — 690 // the latter stays set after a normal back-out from the puzzle, 691 // and would otherwise advance lastReadOtherMoveAt in lockstep 692 // even when the user is sitting on the library list, suppressing 693 // the badge that should appear there. 694 // Suppressed = viewing here (incl. the local leave-grace) — the 695 // user has eyes on these moves, so advance the *read watermark* 696 // (`readThroughAt`) to what they're looking at. This is monotonic 697 // and never forward-dated, so it cannot claim moves that arrive 698 // after the user backgrounds. Sibling devices learn about the read 699 // via `Player.readThrough`; the forward-dated presence lease 700 // (`lastReadOtherMoveAt`) is published separately by AppServices. 701 if NotificationState.isSuppressed(gameID: gameID), 702 (entity.readThroughAt ?? .distantPast) < latest { 703 entity.readThroughAt = latest 704 } 705 } 706 707 if context.hasChanges { 708 saveContext("mergeRemoteMoves") 709 } 710 onUnreadOtherMovesChanged?() 711 } 712 713 @discardableResult 714 func applyRealtimeCellEdit(_ edit: RealtimeCellEdit) -> Bool { 715 applyRealtimeCellEdits([edit]) > 0 716 } 717 718 /// Applies a batch of live cell edits with a single Core Data save and a 719 /// single UI refresh, regardless of cell count. Edits are grouped by their 720 /// owning Moves record (author + device), so each record is decoded and 721 /// re-encoded once even for a whole-grid gesture like "check puzzle". 722 /// Per-cell last-writer-wins is preserved. Returns the number of cells that 723 /// actually changed. 724 @discardableResult 725 func applyRealtimeCellEdits(_ edits: [RealtimeCellEdit]) -> Int { 726 let groups = Dictionary(grouping: edits) { edit in 727 RecordSerializer.recordName( 728 forMovesInGame: edit.gameID, 729 authorID: edit.authorID, 730 deviceID: edit.deviceID 731 ) 732 } 733 734 var applied = 0 735 var touchedGameIDs: Set<UUID> = [] 736 for (recordName, groupEdits) in groups { 737 guard let sample = groupEdits.first, 738 !sample.authorID.isEmpty, 739 !sample.deviceID.isEmpty, 740 sample.deviceID != RecordSerializer.localDeviceID, 741 let entity = fetchGameEntity(id: sample.gameID) 742 else { continue } 743 744 let movesEntity = ensureMovesEntity( 745 recordName: recordName, 746 game: entity, 747 authorID: sample.authorID, 748 deviceID: sample.deviceID 749 ) 750 751 var cells: [GridPosition: TimestampedCell] = [:] 752 if let data = movesEntity.cells, !data.isEmpty { 753 cells = (try? MovesCodec.decode(data)) ?? [:] 754 } 755 756 var latest = movesEntity.updatedAt ?? .distantPast 757 var changed = false 758 for edit in groupEdits { 759 let position = GridPosition(row: edit.row, col: edit.col) 760 let incoming = TimestampedCell( 761 letter: edit.letter, 762 mark: edit.mark, 763 updatedAt: edit.updatedAt, 764 authorID: edit.cellAuthorID 765 ) 766 if let current = cells[position], current.updatedAt > incoming.updatedAt { 767 continue 768 } 769 cells[position] = incoming 770 changed = true 771 applied += 1 772 if edit.updatedAt > latest { latest = edit.updatedAt } 773 } 774 775 guard changed else { continue } 776 movesEntity.cells = (try? MovesCodec.encode(cells)) ?? Data() 777 if (movesEntity.updatedAt ?? .distantPast) < latest { 778 movesEntity.updatedAt = latest 779 } 780 if (entity.updatedAt ?? .distantPast) < latest { 781 entity.updatedAt = latest 782 } 783 touchedGameIDs.insert(sample.gameID) 784 } 785 786 guard applied > 0 else { return 0 } 787 saveContext("applyRealtimeCellEdits") 788 if let openID = currentEntity?.id, touchedGameIDs.contains(openID) { 789 refreshCurrentGame() 790 } 791 onUnreadOtherMovesChanged?() 792 return applied 793 } 794 795 /// Number of shared games with unseen other-author moves — the same 796 /// `hasUnreadOtherMoves` heuristic the library list uses, aggregated as 797 /// a count for the app-icon badge. 798 func unreadOtherMovesGameCount() -> Int { 799 let request = NSFetchRequest<NSNumber>(entityName: "GameEntity") 800 request.resultType = .countResultType 801 request.predicate = unreadOtherMovesPredicate 802 return (try? context.count(for: request)) ?? 0 803 } 804 805 /// The same heuristic as `unreadOtherMovesGameCount`, returning the 806 /// individual game IDs so the App Group `BadgeState` set can be unioned 807 /// with NSE-added entries. 808 func unreadOtherMovesGameIDs() -> Set<UUID> { 809 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 810 request.predicate = unreadOtherMovesPredicate 811 request.propertiesToFetch = ["id"] 812 let rows = (try? context.fetch(request)) ?? [] 813 return Set(rows.compactMap(\.id)) 814 } 815 816 func hasUnreadOtherMoves(gameID: UUID) -> Bool { 817 let request = NSFetchRequest<NSNumber>(entityName: "GameEntity") 818 request.resultType = .countResultType 819 request.fetchLimit = 1 820 request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ 821 NSPredicate(format: "id == %@", gameID as CVarArg), 822 unreadOtherMovesPredicate 823 ]) 824 return ((try? context.count(for: request)) ?? 0) > 0 825 } 826 827 /// The same heuristic as `unreadOtherMovesGameIDs`, but paired with each 828 /// game's newest unseen other-author move time (`latestOtherMoveAt`, which 829 /// the predicate guarantees is non-nil). The app seeds these into the App 830 /// Group `BadgeState` ledger as `unreadAt` horizons so the Notification 831 /// Service Extension — which can't reach Core Data — inherits this ground 832 /// truth when it stamps the badge for a push that lands while the app is 833 /// suspended. The timestamp is what keeps the seed safe: a game the user 834 /// has since opened carries a newer `seenAt` and won't resurrect. 835 func unreadOtherMovesGameTimes() -> [UUID: Date] { 836 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 837 request.predicate = unreadOtherMovesPredicate 838 request.propertiesToFetch = ["id", "latestOtherMoveAt"] 839 let rows = (try? context.fetch(request)) ?? [] 840 var result: [UUID: Date] = [:] 841 for row in rows { 842 if let id = row.id, let at = row.latestOtherMoveAt { 843 result[id] = at 844 } 845 } 846 return result 847 } 848 849 /// Games this account has a pending (un-acted) invite to, excluding invites 850 /// from blocked collaborators — the same set the library's "Invited" section 851 /// shows (`GameListView` filters blocked inviters at display time). The app 852 /// publishes these into `BadgeState` so a pending invite counts toward the 853 /// app-icon badge. A pending `InviteEntity` is dropped once its `GameEntity` 854 /// exists, so this set is disjoint from the unread-other-moves set. 855 func pendingInviteGameIDs() -> Set<UUID> { 856 let blockedRequest = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 857 blockedRequest.predicate = NSPredicate(format: "isBlocked == YES") 858 blockedRequest.propertiesToFetch = ["authorID"] 859 let blocked = Set(((try? context.fetch(blockedRequest)) ?? []).compactMap(\.authorID)) 860 861 let request = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") 862 request.predicate = NSPredicate(format: "status == %@", "pending") 863 request.propertiesToFetch = ["gameID", "inviterAuthorID"] 864 let rows = (try? context.fetch(request)) ?? [] 865 return Set(rows.compactMap { invite in 866 guard let id = invite.gameID else { return nil } 867 if let inviter = invite.inviterAuthorID, blocked.contains(inviter) { return nil } 868 return id 869 }) 870 } 871 872 /// One-shot upgrade heal for the read-watermark split. Older installs only 873 /// had `lastReadOtherMoveAt`, which has since become a presence lease; rows 874 /// with missing or stale `readThroughAt` can therefore look unread after 875 /// upgrading. Backfill them to their current latest peer move unless a 876 /// delivered unread notification still represents that game. 877 @discardableResult 878 func backfillLegacyReadThrough(excluding excludedGameIDs: Set<UUID>) -> Int { 879 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 880 request.predicate = NSPredicate( 881 format: "(databaseScope == 1 OR ckShareRecordName != nil) " 882 + "AND latestOtherMoveAt != nil " 883 + "AND (readThroughAt == nil OR latestOtherMoveAt > readThroughAt)" 884 ) 885 request.propertiesToFetch = ["id", "latestOtherMoveAt", "readThroughAt"] 886 let rows = (try? context.fetch(request)) ?? [] 887 var changed = 0 888 for entity in rows { 889 guard let id = entity.id, 890 !excludedGameIDs.contains(id), 891 let latest = entity.latestOtherMoveAt 892 else { continue } 893 entity.readThroughAt = latest 894 changed += 1 895 } 896 guard changed > 0 else { return 0 } 897 saveContext("backfillLegacyReadThrough") 898 onUnreadOtherMovesChanged?() 899 return changed 900 } 901 902 private var unreadOtherMovesPredicate: NSPredicate { 903 // Keyed off the read *watermark* (`readThroughAt`), not the forward- 904 // dated presence lease (`lastReadOtherMoveAt`) — matches 905 // `GameSummary.computeHasUnread`. For pre-watermark rows, fall back to 906 // a non-future legacy cursor so an upgrade doesn't badge every 907 // already-seen shared puzzle whose readThroughAt starts nil. Future 908 // legacy values are active-session leases, not durable read watermarks. 909 NSPredicate( 910 format: "(databaseScope == 1 OR ckShareRecordName != nil) " 911 + "AND latestOtherMoveAt != nil " 912 + "AND (" 913 + " (readThroughAt != nil AND latestOtherMoveAt > readThroughAt) " 914 + " OR (readThroughAt == nil AND " 915 + " (lastReadOtherMoveAt == nil " 916 + " OR lastReadOtherMoveAt > %@ " 917 + " OR latestOtherMoveAt > lastReadOtherMoveAt))" 918 + ")", 919 Date() as NSDate 920 ) 921 } 922 923 // MARK: - Load a specific game 924 925 /// Loads a game by its entity ID. Sets it as the current game. 926 func loadGame(id: UUID) throws -> (Game, GameMutator) { 927 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 928 request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 929 request.fetchLimit = 1 930 931 guard let entity = try context.fetch(request).first else { 932 throw LoadError.gameNotFound 933 } 934 let puzzle = try preparePuzzleForLoad(from: entity) 935 let game = Game(puzzle: puzzle) 936 restore(game: game, from: entity) 937 938 let mutator = makeMutator(game: game, entity: entity) 939 940 currentGame = game 941 currentMutator = mutator 942 currentEntity = entity 943 markOtherMovesRead(for: entity) 944 945 return (game, mutator) 946 } 947 948 // MARK: - Duplicate detection 949 950 /// Returns the ID of an existing game for the same source. Exact source 951 /// matches win, then catalog resource ID/title matches catch older stored 952 /// copies of a packaged puzzle. 953 func findGameID(matching source: String) -> UUID? { 954 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 955 request.predicate = NSPredicate(format: "puzzleSource == %@", source) 956 request.fetchLimit = 1 957 if let exact = try? context.fetch(request).first?.id { 958 return exact 959 } 960 961 guard let xd = try? XD.parse(source) else { return nil } 962 let resourceID = PuzzleCatalog.resourceID(matching: source) 963 let fallback = NSFetchRequest<GameEntity>(entityName: "GameEntity") 964 if let resourceID { 965 fallback.predicate = NSPredicate(format: "puzzleResourceID == %@", resourceID) 966 fallback.fetchLimit = 1 967 if let match = try? context.fetch(fallback).first?.id { 968 return match 969 } 970 } 971 972 guard resourceID != nil, let title = xd.title else { return nil } 973 fallback.predicate = NSPredicate(format: "title == %@", title) 974 fallback.fetchLimit = 1 975 return (try? context.fetch(fallback).first?.id) 976 } 977 978 /// Returns NYT publication dates already present in the local library. 979 func nytPuzzleDatesInLibrary() -> Set<Date> { 980 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 981 request.predicate = NSPredicate( 982 format: "cachedPublisher == %@ AND cachedPuzzleDate != nil", 983 "New York Times" 984 ) 985 let dates = ((try? context.fetch(request)) ?? []).compactMap(\.cachedPuzzleDate) 986 987 var calendar = Calendar(identifier: .gregorian) 988 calendar.timeZone = TimeZone(identifier: "America/New_York") ?? .gmt 989 return Set(dates.map { calendar.startOfDay(for: $0) }) 990 } 991 992 /// Returns joined CloudKit-share games that have a usable puzzle payload. 993 /// Placeholders created from shared-zone discovery are intentionally 994 /// excluded until the root Game record has arrived. 995 func joinedSharedGameIDs() -> Set<UUID> { 996 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 997 request.predicate = NSPredicate( 998 format: "databaseScope == 1 AND puzzleSource != nil AND puzzleSource != %@", 999 "" 1000 ) 1001 return Set(((try? context.fetch(request)) ?? []).compactMap(\.id)) 1002 } 1003 1004 // MARK: - Create a new game 1005 1006 /// Creates a new game from XD source text. Returns the new game's UUID. 1007 func createGame(from source: String) throws -> UUID { 1008 let xd = try XD.parse(source) 1009 let puzzle = Puzzle(xd: xd) 1010 1011 let now = Date() 1012 let gameID = UUID() 1013 let entity = GameEntity(context: context) 1014 entity.id = gameID 1015 entity.title = puzzle.title 1016 entity.puzzleSource = source 1017 entity.puzzleCmVersion = Int64(XD.currentCmVersion) 1018 entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: source) 1019 entity.createdAt = now 1020 entity.updatedAt = now 1021 entity.ckRecordName = "game-\(gameID.uuidString)" 1022 entity.ckZoneName = "game-\(gameID.uuidString)" 1023 entity.databaseScope = 0 1024 entity.populateCachedSummaryFields(from: puzzle) 1025 1026 try context.save() 1027 onGameCreated("game-\(gameID.uuidString)") 1028 return gameID 1029 } 1030 1031 /// Builds a complete participant game (`databaseScope == 1`) from an 1032 /// invite's serialised XD source, so a freshly-accepted shared game is 1033 /// immediately playable and fully listed without waiting on the shared-zone 1034 /// fetch. Everything derives from the source exactly as `createGame` does; 1035 /// only the zone identity comes from the share. Unlike `createGame` it 1036 /// enqueues no push — the participant doesn't own this zone — and it leaves 1037 /// `ckSystemFields` nil, so the first canonical Game-record sync adopts the 1038 /// server etag and updates this row in place (matched by `ckRecordName` in 1039 /// `RecordSerializer.fetchOrCreate`) rather than creating a duplicate. 1040 /// No-ops if a row for the game already exists — a sibling device or an 1041 /// earlier sync got there first. 1042 func constructJoinedGame(gameID: UUID, zoneID: CKRecordZone.ID, source: String) throws { 1043 let recordName = "game-\(gameID.uuidString)" 1044 let existing = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1045 existing.predicate = NSPredicate(format: "ckRecordName == %@", recordName) 1046 existing.fetchLimit = 1 1047 if ((try? context.count(for: existing)) ?? 0) > 0 { return } 1048 1049 let xd = try XD.parse(source) 1050 let puzzle = Puzzle(xd: xd) 1051 let now = Date() 1052 let entity = GameEntity(context: context) 1053 entity.id = gameID 1054 entity.title = puzzle.title 1055 entity.puzzleSource = source 1056 entity.puzzleCmVersion = Int64(XD.currentCmVersion) 1057 entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: source) 1058 entity.createdAt = now 1059 entity.updatedAt = now 1060 entity.ckRecordName = recordName 1061 entity.ckZoneName = zoneID.zoneName 1062 entity.ckZoneOwnerName = 1063 zoneID.ownerName == CKCurrentUserDefaultName ? nil : zoneID.ownerName 1064 entity.databaseScope = 1 1065 entity.populateCachedSummaryFields(from: puzzle) 1066 1067 try context.save() 1068 } 1069 1070 // MARK: - Delete a game 1071 1072 func deleteGame(id: UUID) throws { 1073 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1074 request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 1075 request.fetchLimit = 1 1076 1077 guard let entity = try context.fetch(request).first else { return } 1078 1079 // A finished participant game (scope 1, archived, still a participant) 1080 // carries a separate private-DB archive backup under archive-<id>; 1081 // deleting the game outright should drop that backup too. A materialized 1082 // or revoked archive is excluded — its own zone already is the archive, 1083 // covered by ckZoneName above, so it needs no second teardown. 1084 let hasSeparateArchive = entity.databaseScope == 1 1085 && entity.archivedAt != nil 1086 && !entity.isAccessRevoked 1087 let deletion = GameCloudDeletion( 1088 gameID: id, 1089 databaseScope: entity.databaseScope, 1090 ckZoneName: entity.ckZoneName ?? "game-\(id.uuidString)", 1091 ckZoneOwnerName: entity.ckZoneOwnerName ?? CKCurrentUserDefaultName, 1092 archiveZoneName: hasSeparateArchive 1093 ? Archive.zoneID(forOriginalGameID: id).zoneName 1094 : nil 1095 ) 1096 1097 // Clear current references if this is the active game 1098 if currentEntity?.id == id { 1099 currentGame = nil 1100 currentMutator = nil 1101 currentEntity = nil 1102 } 1103 1104 context.delete(entity) 1105 try context.save() 1106 onGameDeleted(deletion) 1107 onUnreadOtherMovesChanged?() 1108 } 1109 1110 // MARK: - Resign a game 1111 1112 /// Reveals all cells and marks the game as completed (resigned). 1113 func resignGame(id: UUID) throws { 1114 let (game, mutator) = try loadGame(id: id) 1115 let allCells = game.puzzle.cells.flatMap { $0 } 1116 mutator.revealCells(allCells) 1117 1118 guard let entity = currentEntity else { return } 1119 entity.completedAt = Date() 1120 // Resignation: no solver, so `completedBy` stays nil — that's how a 1121 // resigned game is told apart from a win. 1122 entity.completedBy = nil 1123 entity.hasPendingSave = true 1124 try context.save() 1125 if let ckName = entity.ckRecordName { 1126 onGameUpdated(ckName) 1127 } 1128 Task { await movesUpdater.flush() } 1129 triggerJournalUpload(id: id) 1130 1131 // Clean up current references 1132 currentGame = nil 1133 currentMutator = nil 1134 currentEntity = nil 1135 } 1136 1137 /// Marks a game as completed after a normal win. Returns whether the 1138 /// entity changed; no-ops if already marked. 1139 /// Triggers a buffer flush so the completion snapshot is created promptly 1140 /// rather than waiting for the next keystroke or app-background event. 1141 @discardableResult 1142 func markCompleted(id: UUID) throws -> Bool { 1143 try persistCompletion(id: id, completedBy: authorIDProvider()) 1144 } 1145 1146 /// Marks a game completed after the visible grid became solved through 1147 /// observed state, e.g. a collaborator's realtime edit. Uses the writer of 1148 /// the latest winning cell as the solver when provenance is available. 1149 @discardableResult 1150 func markCompletedFromObservedSolvedState(id: UUID) throws -> Bool { 1151 let solver = inferredObservedCompletionAuthorID(for: id) ?? authorIDProvider() 1152 return try persistCompletion(id: id, completedBy: solver) 1153 } 1154 1155 @discardableResult 1156 private func persistCompletion(id: UUID, completedBy authorID: String?) throws -> Bool { 1157 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1158 request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 1159 request.fetchLimit = 1 1160 guard let entity = try context.fetch(request).first, 1161 entity.completedAt == nil else { return false } 1162 entity.completedAt = Date() 1163 // A win: stamp the solver so the Game record can distinguish wins 1164 // from resignations and the completion APN body can name them. 1165 entity.completedBy = authorID 1166 entity.hasPendingSave = true 1167 try context.save() 1168 // The game is now terminal, so its peer-change ledger is dead weight. 1169 // The writer drops the rows once it sees completedAt — schedule it. 1170 enqueuePeerChangeLedgerUpdate(for: [id]) 1171 // Lock the open session immediately so no further input lands on the 1172 // now-terminal game (the view also reflects this via `isSolved`). 1173 if currentEntity?.id == id { 1174 currentMutator?.isCompleted = true 1175 } 1176 if let ckName = entity.ckRecordName { 1177 onGameUpdated(ckName) 1178 } 1179 Task { await movesUpdater.flush() } 1180 triggerJournalUpload(id: id) 1181 return true 1182 } 1183 1184 /// Signals that a just-completed game's move journal should be uploaded 1185 /// (Phase 2). Fired synchronously on the main actor at completion so the 1186 /// app layer can take a background-execution assertion *before* any 1187 /// suspension point — the flush and CKSyncEngine enqueue then run under it 1188 /// via `flushJournal()`. Attributed to the local user (not the solver) — a 1189 /// resigner still has a log to upload. 1190 private func triggerJournalUpload(id: UUID) { 1191 guard let authorID = authorIDProvider(), !authorID.isEmpty else { return } 1192 onJournalComplete?(id, authorID) 1193 } 1194 1195 /// Drains the journal's async persistence queue so the upload's record 1196 /// builder, reading Core Data on its own context, sees every entry. Called 1197 /// by the app-layer upload path under its background assertion. 1198 func flushJournal() async { 1199 await movesJournal.flush() 1200 } 1201 1202 /// Drains both the cell-write buffer and the journal queue so a reader on a 1203 /// fresh background context sees the *finished* grid and this device's full 1204 /// local log — rather than buffered-but-unpersisted state. The completion 1205 /// archive snapshots Core Data directly, so it must run after this; the 1206 /// winning move in particular is still in flight when `persistCompletion` 1207 /// returns. 1208 func flushCompletionWrites() async { 1209 await movesUpdater.flush() 1210 await movesJournal.flush() 1211 } 1212 1213 /// This device's live journal for a game, tagged with its device key. The 1214 /// replay assembler overlays this over any uploaded copy of ourselves: the 1215 /// in-memory log is the session's authoritative copy and may be fresher than 1216 /// what's round-tripped to CloudKit. `nil` until the local author is known. 1217 func localReplaySource(gameID: UUID) -> DeviceJournal? { 1218 guard let authorID = authorIDProvider(), !authorID.isEmpty else { return nil } 1219 let key = JournalDeviceKey(authorID: authorID, deviceID: RecordSerializer.localDeviceID) 1220 return DeviceJournal(key: key, entries: movesJournal.recordedEntries(gameID: gameID)) 1221 } 1222 1223 /// This device's journal entries for a game, independent of iCloud identity. 1224 /// The journal is recorded locally as the player types, so it exists even 1225 /// with no signed-in account — the local replay path uses this directly 1226 /// rather than `localReplaySource`, which needs an authorID to form a key. 1227 func localJournalEntries(for gameID: UUID) -> [JournalValue] { 1228 movesJournal.recordedEntries(gameID: gameID) 1229 } 1230 1231 /// Other devices' journals cached locally for replay, grouped by source 1232 /// device — or `nil` if this game's cache isn't known-complete yet 1233 /// (`replayCacheComplete`), in which case the caller must fetch from 1234 /// CloudKit. These are stored as `JournalEntity` rows carrying a source key 1235 /// (`sourceDeviceID != nil`), kept out of this device's own log. A finished 1236 /// game's journals never change, so once cached they replay offline; the 1237 /// live local journal is overlaid separately by `localReplaySource`, so this 1238 /// deliberately excludes any cached copy of ourselves and may be empty (a 1239 /// solo game has no remote contributors but is still "complete"). 1240 func cachedRemoteJournals(forGameID gameID: UUID) async -> [DeviceJournal]? { 1241 let ctx = persistence.container.newBackgroundContext() 1242 return await ctx.perform { 1243 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1244 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1245 gameReq.fetchLimit = 1 1246 guard let game = try? ctx.fetch(gameReq).first, game.replayCacheComplete else { 1247 return nil 1248 } 1249 let req = NSFetchRequest<JournalEntity>(entityName: "JournalEntity") 1250 req.predicate = NSPredicate( 1251 format: "gameID == %@ AND sourceDeviceID != nil", gameID as CVarArg 1252 ) 1253 req.sortDescriptors = [NSSortDescriptor(key: "seq", ascending: true)] 1254 let rows = (try? ctx.fetch(req)) ?? [] 1255 var byDevice: [JournalDeviceKey: [JournalValue]] = [:] 1256 for row in rows { 1257 let key = JournalDeviceKey( 1258 authorID: row.sourceAuthorID ?? "", 1259 deviceID: row.sourceDeviceID ?? "" 1260 ) 1261 byDevice[key, default: []].append(MovesJournal.value(from: row)) 1262 } 1263 return byDevice.map { DeviceJournal(key: $0.key, entries: $0.value) } 1264 } 1265 } 1266 1267 /// Persists `journals` (other devices' logs) as this game's replay cache and 1268 /// marks it `replayCacheComplete`, so later opens replay from Core Data with 1269 /// no CloudKit round-trip. Safe because a completed game's journals are 1270 /// frozen by edit-lockout. Idempotent: replaces any existing cached rows for 1271 /// the game. Rows carry a source key so the local-log readers skip them. 1272 func cacheRemoteJournals(_ journals: [DeviceJournal], forGameID gameID: UUID) async { 1273 let ctx = persistence.container.newBackgroundContext() 1274 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 1275 await ctx.perform { 1276 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1277 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1278 gameReq.fetchLimit = 1 1279 guard let game = try? ctx.fetch(gameReq).first else { return } 1280 1281 let stale = NSFetchRequest<JournalEntity>(entityName: "JournalEntity") 1282 stale.predicate = NSPredicate( 1283 format: "gameID == %@ AND sourceDeviceID != nil", gameID as CVarArg 1284 ) 1285 for row in (try? ctx.fetch(stale)) ?? [] { ctx.delete(row) } 1286 1287 for journal in journals { 1288 for value in journal.entries { 1289 let row = JournalEntity(context: ctx) 1290 MovesJournal.assign(value, to: row, gameID: gameID) 1291 row.sourceAuthorID = journal.key.authorID 1292 row.sourceDeviceID = journal.key.deviceID 1293 row.game = game 1294 } 1295 } 1296 game.replayCacheComplete = true 1297 do { 1298 try ctx.save() 1299 } catch { 1300 let message = "GameStore: replay cache save failed — \(error)" 1301 Task { @MainActor [weak self] in 1302 self?.eventLog?.note(message, level: "error") 1303 } 1304 } 1305 } 1306 } 1307 1308 // MARK: - Engagement room 1309 1310 /// `true` once `gameID` has been completed (solved or resigned). A 1311 /// completed game is no longer a live collaborative session, so engagement 1312 /// is torn down and peer cursors are suppressed for it. 1313 func isCompleted(gameID: UUID) -> Bool { 1314 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1315 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1316 request.fetchLimit = 1 1317 return (try? context.fetch(request).first)?.completedAt != nil 1318 } 1319 1320 /// The shared live-engagement room creds for `gameID` (an encoded 1321 /// `EngagementRoomCredentials`), or nil if none has been minted yet. 1322 func engagement(for gameID: UUID) -> String? { 1323 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1324 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1325 request.fetchLimit = 1 1326 return (try? context.fetch(request).first)?.engagement 1327 } 1328 1329 /// Writes the engagement room creds for `gameID` and enqueues a Game-record 1330 /// push. No-op (returns false) when unchanged or the game isn't shared. 1331 /// Sets `hasPendingSave` so an inbound Game record can't clobber freshly 1332 /// minted creds before the push lands; record-level LWW then converges any 1333 /// concurrent mint by another participant. 1334 @discardableResult 1335 func setEngagement(_ encoded: String?, for gameID: UUID) -> Bool { 1336 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1337 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1338 request.fetchLimit = 1 1339 guard let entity = try? context.fetch(request).first else { return false } 1340 let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 1341 guard isShared, entity.engagement != encoded else { return false } 1342 entity.engagement = encoded 1343 entity.hasPendingSave = true 1344 saveContext("setEngagement") 1345 if let ckName = entity.ckRecordName { 1346 onGameUpdated(ckName) 1347 } 1348 return true 1349 } 1350 1351 /// The shared per-game push credential for `gameID` (an encoded 1352 /// `GamePushCredentials`), or nil if none has been minted yet. 1353 func notification(for gameID: UUID) -> String? { 1354 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1355 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1356 request.fetchLimit = 1 1357 return (try? context.fetch(request).first)?.notification 1358 } 1359 1360 /// Writes the notification credentials for `gameID` and enqueues a 1361 /// Game-record push, mirroring `setEngagement`. On a real change also 1362 /// re-mirrors the App Group content-key directory the NSE reads (the blob 1363 /// carries the content key). No-op (false) when unchanged or not shared. 1364 @discardableResult 1365 func setNotification(_ encoded: String?, for gameID: UUID) -> Bool { 1366 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1367 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1368 request.fetchLimit = 1 1369 guard let entity = try? context.fetch(request).first else { return false } 1370 let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 1371 guard isShared, entity.notification != encoded else { return false } 1372 entity.notification = encoded 1373 entity.hasPendingSave = true 1374 saveContext("setNotification") 1375 GameEntity.rebuildContentKeyDirectory(in: context) 1376 if let ckName = entity.ckRecordName { 1377 onGameUpdated(ckName) 1378 } 1379 return true 1380 } 1381 1382 /// Returns the shared notification credentials for `gameID`, minting and 1383 /// persisting a fresh one (and enqueuing the Game-record push, so 1384 /// participants converge) when the game is shared and none exists yet. A 1385 /// legacy credential minted before content keys existed is backfilled with a 1386 /// fresh one in place, preserving its `credID`/`secret` so worker 1387 /// registration stays valid. Any participant may mint; record-level LWW 1388 /// resolves concurrent mints. Returns nil for a non-shared or missing game, 1389 /// or if minting fails. 1390 @discardableResult 1391 func ensurePushCredentials(for gameID: UUID) -> GamePushCredentials? { 1392 if var existing = GamePushCredentials.decode(notification(for: gameID)) { 1393 if existing.contentKey != nil { return existing } 1394 // Backfill a content key onto a legacy credential, keeping its auth 1395 // material so the worker registration is unaffected. 1396 guard let key = try? GamePushCredentials.freshContentKey() else { return existing } 1397 existing.contentKey = key 1398 guard let encoded = try? existing.encoded(), setNotification(encoded, for: gameID) 1399 else { return existing } 1400 return existing 1401 } 1402 guard let fresh = try? GamePushCredentials.fresh(), 1403 let encoded = try? fresh.encoded(), 1404 setNotification(encoded, for: gameID) 1405 else { return nil } 1406 return fresh 1407 } 1408 1409 // MARK: - Reset 1410 1411 /// Deletes every game (and its cascaded moves, snapshots, and cells) plus 1412 /// the sync-state row. Used by the diagnostics reset button and by the 1413 /// account-switch purge (`CloudService.purgeLocalData`). 1414 func resetAllData() throws { 1415 for entity in try context.fetch(NSFetchRequest<GameEntity>(entityName: "GameEntity")) { 1416 context.delete(entity) 1417 } 1418 for entity in try context.fetch(NSFetchRequest<SyncStateEntity>(entityName: "SyncStateEntity")) { 1419 context.delete(entity) 1420 } 1421 // Friend zones themselves are removed by CloudService.resetAllData's 1422 // wholesale private-zone delete / shared-zone leave; clear the local 1423 // friendship + invite rows so a reset is a clean slate. 1424 for entity in try context.fetch(NSFetchRequest<FriendEntity>(entityName: "FriendEntity")) { 1425 context.delete(entity) 1426 } 1427 for entity in try context.fetch(NSFetchRequest<InviteEntity>(entityName: "InviteEntity")) { 1428 context.delete(entity) 1429 } 1430 // Replay journals are keyed to games; once every game is gone they are 1431 // orphaned and (on an account switch) hold the previous account's solve 1432 // history, so clear them too. 1433 for entity in try context.fetch(NSFetchRequest<JournalEntity>(entityName: "JournalEntity")) { 1434 context.delete(entity) 1435 } 1436 try context.save() 1437 currentGame = nil 1438 currentMutator = nil 1439 currentEntity = nil 1440 onUnreadOtherMovesChanged?() 1441 } 1442 1443 // MARK: - Legacy convenience 1444 1445 /// Returns the single current game and its mutator, creating from 1446 /// `sample.xd` on first launch. Subsequent launches rehydrate the 1447 /// in-memory `Game` from the stored `CellEntity` rows so any prior 1448 /// progress is restored. 1449 func loadOrCreateCurrentGame() throws -> (Game, GameMutator) { 1450 let entity: GameEntity 1451 let puzzle: Puzzle 1452 1453 if let existing = try fetchCurrentEntity() { 1454 entity = existing 1455 puzzle = try preparePuzzleForLoad(from: existing) 1456 } else { 1457 (entity, puzzle) = try seedFromSample() 1458 } 1459 1460 let game = Game(puzzle: puzzle) 1461 restore(game: game, from: entity) 1462 1463 let mutator = makeMutator(game: game, entity: entity) 1464 1465 currentGame = game 1466 currentMutator = mutator 1467 currentEntity = entity 1468 markOtherMovesRead(for: entity) 1469 1470 return (game, mutator) 1471 } 1472 1473 // MARK: - Loading 1474 1475 private func fetchCurrentEntity() throws -> GameEntity? { 1476 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1477 request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)] 1478 request.fetchLimit = 1 1479 return try context.fetch(request).first 1480 } 1481 1482 /// Applies this account's `Player.readAt` from a sibling device under 1483 /// last-writer-wins: SyncEngine has already accepted this record as the 1484 /// freshest server version, so adopt the value directly as the account's 1485 /// resolved read horizon. A leaving sibling can pull the horizon back below 1486 /// an active sibling's future lease, but that collapse is bounded and 1487 /// self-healing: the still-present device re-asserts its lease as soon as it 1488 /// processes the inbound close (`AppServices`'s incoming-cursor drain), and a 1489 /// foreground device marks inbound peer moves read on arrival regardless. 1490 /// Representing "A left while C is still here" without that collapse would 1491 /// require per-device Player rows, which do not exist (one row per author). 1492 /// Returns the cursor value that was in place before the write and whether 1493 /// the inbound value was actually adopted, so callers can log the 1494 /// adoption — cross-device cursor convergence is otherwise invisible in 1495 /// the device log. 1496 @discardableResult 1497 func noteIncomingReadCursor(gameID: UUID, readAt: Date) -> (previous: Date?, adopted: Bool) { 1498 let previous = fetchGameEntity(id: gameID)?.lastReadOtherMoveAt 1499 let adopted = setReadCursor(gameID: gameID, readAt: readAt) 1500 return (previous, adopted) 1501 } 1502 1503 /// Sets the per-account **presence lease** for `gameID` — the forward-dated 1504 /// "the user is actively present on this puzzle" horizon stored in 1505 /// `lastReadOtherMoveAt`. When `minimumExistingReadAt` is provided, the 1506 /// write is skipped if the current lease already reaches that floor; active 1507 /// sessions use this to refresh a future lease only when it is close to 1508 /// expiry. 1509 /// 1510 /// This is *not* the read watermark — see `advanceReadThrough`. The two were 1511 /// historically the same field, which is why `lastReadOtherMoveAt` ships on 1512 /// the wire as `Player.readAt`. 1513 /// TODO(v4): rename the `readAt` CloudKit field (and `lastReadOtherMoveAt`) 1514 /// to something like `presenceLeaseUntil` — it is a presence horizon, not a 1515 /// read position, now that `readThrough` carries the latter. 1516 @discardableResult 1517 func setReadCursor( 1518 gameID: UUID, 1519 readAt: Date, 1520 minimumExistingReadAt: Date? = nil 1521 ) -> Bool { 1522 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1523 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1524 request.fetchLimit = 1 1525 guard let entity = try? context.fetch(request).first else { return false } 1526 let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 1527 guard isShared else { return false } 1528 if let minimumExistingReadAt, 1529 let current = entity.lastReadOtherMoveAt, 1530 current >= minimumExistingReadAt { 1531 return false 1532 } 1533 guard entity.lastReadOtherMoveAt != readAt else { return false } 1534 entity.lastReadOtherMoveAt = readAt 1535 saveContext("updateReadAt") 1536 onUnreadOtherMovesChanged?() 1537 return true 1538 } 1539 1540 /// Advances the per-account **read watermark** (`readThroughAt`) to 1541 /// `through`, monotonically — the latest other-author move time this 1542 /// account has actually observed. Unlike the presence lease it is never 1543 /// forward-dated, so a peer computing what we've seen (and our own unread 1544 /// badge) never credits us with moves made after we stopped looking. 1545 /// Returns `true` if the watermark moved. 1546 @discardableResult 1547 func advanceReadThrough(gameID: UUID, through: Date) -> Bool { 1548 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1549 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1550 request.fetchLimit = 1 1551 guard let entity = try? context.fetch(request).first else { return false } 1552 let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 1553 guard isShared else { return false } 1554 guard (entity.readThroughAt ?? .distantPast) < through else { return false } 1555 entity.readThroughAt = through 1556 saveContext("advanceReadThrough") 1557 onUnreadOtherMovesChanged?() 1558 return true 1559 } 1560 1561 private func markOtherMovesRead(for entity: GameEntity) { 1562 let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 1563 guard isShared, let latest = entity.latestOtherMoveAt else { return } 1564 // Advances the *read watermark* (`readThroughAt`), not the presence 1565 // lease (`lastReadOtherMoveAt`). Opening the game means the user has now 1566 // seen every other-author move up to `latest`; the lease is a separate, 1567 // forward-dated "actively present" horizon owned by `setReadCursor`. 1568 if (entity.readThroughAt ?? .distantPast) < latest { 1569 entity.readThroughAt = latest 1570 saveContext("markOtherMovesRead") 1571 onUnreadOtherMovesChanged?() 1572 } 1573 } 1574 1575 private func seedFromSample() throws -> (GameEntity, Puzzle) { 1576 guard let url = Bundle.main.resourceURL? 1577 .appendingPathComponent("Puzzles/debug/sample.xd") else { 1578 throw LoadError.sampleResourceMissing 1579 } 1580 let source = try String(contentsOf: url, encoding: .utf8) 1581 let xd = try XD.parse(source) 1582 let puzzle = Puzzle(xd: xd) 1583 1584 let now = Date() 1585 let gameID = UUID() 1586 let entity = GameEntity(context: context) 1587 entity.id = gameID 1588 entity.title = puzzle.title 1589 entity.puzzleSource = source 1590 entity.puzzleCmVersion = Int64(XD.currentCmVersion) 1591 entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: source) 1592 entity.createdAt = now 1593 entity.updatedAt = now 1594 entity.ckRecordName = "game-\(gameID.uuidString)" 1595 entity.ckZoneName = "game-\(gameID.uuidString)" 1596 entity.databaseScope = 0 1597 entity.populateCachedSummaryFields(from: puzzle) 1598 1599 try context.save() 1600 onGameCreated("game-\(gameID.uuidString)") 1601 return (entity, puzzle) 1602 } 1603 1604 private func preparePuzzleForLoad(from entity: GameEntity) throws -> Puzzle { 1605 guard let source = entity.puzzleSource else { 1606 throw LoadError.persistedSourceMissing 1607 } 1608 1609 let currentVersion = Int64(XD.currentCmVersion) 1610 if entity.puzzleCmVersion != currentVersion { 1611 let catalogSource = PuzzleCatalog.source( 1612 matchingResourceID: entity.puzzleResourceID, 1613 title: try? XD.parse(source).title 1614 ) 1615 let nextSource = (catalogSource.flatMap { try? $0.loadSource() }) ?? source 1616 let nextXD = try XD.parse(nextSource) 1617 let puzzle = Puzzle(xd: nextXD) 1618 entity.title = puzzle.title 1619 entity.puzzleSource = nextSource 1620 entity.puzzleCmVersion = currentVersion 1621 if let catalogSource { 1622 entity.puzzleResourceID = catalogSource.id 1623 } else if entity.puzzleResourceID == nil { 1624 entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: nextSource) 1625 } 1626 entity.populateCachedSummaryFields(from: puzzle) 1627 try context.save() 1628 return puzzle 1629 } 1630 1631 if entity.puzzleResourceID == nil { 1632 entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: source) 1633 if context.hasChanges { try context.save() } 1634 } 1635 1636 return Puzzle(xd: try XD.parse(source)) 1637 } 1638 1639 /// Minimal read snapshot of a game's persisted puzzle metadata, sized for 1640 /// out-of-store decision logic (e.g. whether to run a converter upgrade) 1641 /// without exposing the underlying `GameEntity`. 1642 struct PuzzleInfo: Sendable { 1643 let gameID: UUID 1644 let source: String 1645 let isOwned: Bool 1646 } 1647 1648 func puzzleInfo(for id: UUID) -> PuzzleInfo? { 1649 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1650 request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 1651 request.fetchLimit = 1 1652 guard let entity = try? context.fetch(request).first, 1653 let source = entity.puzzleSource 1654 else { return nil } 1655 return PuzzleInfo( 1656 gameID: id, 1657 source: source, 1658 isOwned: entity.databaseScope == 0 1659 ) 1660 } 1661 1662 /// Persists `data` (an encoded `SeenBaseline`) onto this account's own 1663 /// `Player.sessionSnapshot` for `gameID` — the "last viewed" cutoff, 1664 /// shipped on the Player record so sibling devices adopt it rather than 1665 /// recomputing from their own view. Creates a stub PlayerEntity if none 1666 /// exists yet, keyed by the deterministic `ckRecordName`. No-op if the 1667 /// GameEntity is missing. 1668 func setSessionSnapshot(_ data: Data?, gameID: UUID, authorID: String) { 1669 let entity: PlayerEntity 1670 if let existing = fetchPlayerEntity(gameID: gameID, authorID: authorID) { 1671 entity = existing 1672 } else { 1673 let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1674 gameRequest.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1675 gameRequest.fetchLimit = 1 1676 guard let game = try? context.fetch(gameRequest).first else { return } 1677 entity = PlayerEntity(context: context) 1678 entity.game = game 1679 entity.authorID = authorID 1680 entity.ckRecordName = RecordSerializer.recordName( 1681 forPlayerInGame: gameID, 1682 authorID: authorID 1683 ) 1684 entity.updatedAt = Date() 1685 } 1686 entity.sessionSnapshot = data 1687 saveContext("setSessionSnapshot") 1688 } 1689 1690 // MARK: - Solve-time clock 1691 1692 /// Opens a solve session for the local device on `gameID` (idempotent across 1693 /// resumes within one sitting). `reconcileStale` — set on the first open of 1694 /// this game since launch — banks a session left dangling by a previous 1695 /// run's crash rather than counting the dead gap. Returns `true` if the 1696 /// stored log changed, so the caller can decide whether to enqueue a sync. 1697 @discardableResult 1698 func openClockSession( 1699 gameID: UUID, 1700 authorID: String, 1701 reconcileStale: Bool = false, 1702 at now: Date = Date() 1703 ) -> Bool { 1704 mutateTimeLog(gameID: gameID, authorID: authorID) { 1705 $0.open(deviceID: RecordSerializer.localDeviceID, at: now, reconcileStale: reconcileStale) 1706 } 1707 } 1708 1709 /// Seals the local device's open solve session into a sealed interval. 1710 @discardableResult 1711 func sealClockSession(gameID: UUID, authorID: String, at now: Date = Date()) -> Bool { 1712 mutateTimeLog(gameID: gameID, authorID: authorID) { 1713 $0.seal(deviceID: RecordSerializer.localDeviceID, at: now) 1714 } 1715 } 1716 1717 /// Refreshes the local device's liveness heartbeat so a peer keeps 1718 /// extrapolating an open session toward now. 1719 @discardableResult 1720 func beatClockSession(gameID: UUID, authorID: String, at now: Date = Date()) -> Bool { 1721 mutateTimeLog(gameID: gameID, authorID: authorID) { 1722 $0.beat(deviceID: RecordSerializer.localDeviceID, at: now) 1723 } 1724 } 1725 1726 /// The completion instant for `gameID` (win or resign), or `nil` while it is 1727 /// unfinished. Used to seal the clock at the moment of the finish. 1728 func completedAt(forGame gameID: UUID) -> Date? { 1729 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1730 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1731 request.fetchLimit = 1 1732 return (try? context.fetch(request).first)?.completedAt 1733 } 1734 1735 /// Whether `gameID` is finished (won or resigned). The clock stops opening 1736 /// new sessions once this is true. 1737 func isGameCompleted(gameID: UUID) -> Bool { 1738 completedAt(forGame: gameID) != nil 1739 } 1740 1741 /// Whether `gameID` is currently shared. Shared open-time Player writes are 1742 /// already batched by `PuzzleDisplayView.activateSharing`; solo games still 1743 /// need standalone Player enqueues so their clock syncs across this 1744 /// account's devices. 1745 func isGameShared(gameID: UUID) -> Bool { 1746 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1747 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1748 request.fetchLimit = 1 1749 guard let entity = try? context.fetch(request).first else { return false } 1750 return entity.ckShareRecordName != nil || entity.databaseScope == 1 1751 } 1752 1753 /// Reads, mutates, and re-persists the local author's `Player.timeLog`, 1754 /// creating a stub `PlayerEntity` if none exists (works for solo games — no 1755 /// `isShared` gate). `updatedAt` is left untouched on an existing row: the 1756 /// `timeLog` field is adopted on apply regardless of LWW freshness (see 1757 /// `RecordApplier`), so it need not win the selection's `updatedAt` race. 1758 /// Returns `true` when the encoded log actually changed. 1759 @discardableResult 1760 private func mutateTimeLog( 1761 gameID: UUID, 1762 authorID: String, 1763 _ change: (inout TimeLog) -> Void 1764 ) -> Bool { 1765 let entity: PlayerEntity 1766 if let existing = fetchPlayerEntity(gameID: gameID, authorID: authorID) { 1767 entity = existing 1768 } else { 1769 let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1770 gameRequest.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1771 gameRequest.fetchLimit = 1 1772 guard let game = try? context.fetch(gameRequest).first else { return false } 1773 entity = PlayerEntity(context: context) 1774 entity.game = game 1775 entity.authorID = authorID 1776 entity.ckRecordName = RecordSerializer.recordName( 1777 forPlayerInGame: gameID, 1778 authorID: authorID 1779 ) 1780 entity.updatedAt = Date() 1781 } 1782 var log = TimeLog.decode(entity.timeLog) 1783 change(&log) 1784 // Nothing to record — e.g. a heartbeat with no open session. Avoid 1785 // writing (and shipping) an empty `{"devices":{}}` blob. 1786 guard !log.devices.isEmpty else { return false } 1787 let encoded = TimeLog.encode(log) 1788 guard entity.timeLog != encoded else { return false } 1789 entity.timeLog = encoded 1790 saveContext("updateTimeLog") 1791 return true 1792 } 1793 1794 /// Stamps the local author's Player record for `gameID` with the address 1795 /// derived from `secret` (see `RecordSerializer.deriveGameAddress`), so it 1796 /// ships on the Player-record write the puzzle-open burst is already making. 1797 /// Returns the derived address, or `nil` if the GameEntity is missing. 1798 /// Creating the row here is safe because the open burst fills its `name` and 1799 /// `readAt` before the send — unlike the standalone registration sweep 1800 /// (`reconcileLocalPushAddresses`), which must never fabricate a bare row. 1801 @discardableResult 1802 func setPushAddress(gameID: UUID, authorID: String, secret: String) -> String? { 1803 let entity: PlayerEntity 1804 if let existing = fetchPlayerEntity(gameID: gameID, authorID: authorID) { 1805 entity = existing 1806 } else { 1807 let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1808 gameRequest.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1809 gameRequest.fetchLimit = 1 1810 guard let game = try? context.fetch(gameRequest).first else { return nil } 1811 entity = PlayerEntity(context: context) 1812 entity.game = game 1813 entity.authorID = authorID 1814 entity.ckRecordName = RecordSerializer.recordName( 1815 forPlayerInGame: gameID, 1816 authorID: authorID 1817 ) 1818 entity.updatedAt = Date() 1819 } 1820 let address = RecordSerializer.deriveGameAddress(secret: secret, gameID: gameID) 1821 guard entity.pushAddress != address else { return address } 1822 entity.pushAddress = address 1823 // Bump updatedAt so the derived address wins LWW and the outbound build 1824 // picks it up as a fresh write. 1825 entity.updatedAt = Date() 1826 saveContext("setPushAddress") 1827 return address 1828 } 1829 1830 /// Derives the local author's push address for every shared game the account 1831 /// participates in (`HMAC(secret, gameID)`) and pairs each with that game's 1832 /// shared push credential, minting the credential when absent (any 1833 /// participant may mint; record-level LWW converges concurrent mints). The 1834 /// caller registers the bindings with the push worker, which keys each 1835 /// game address under its `credID`. Also returns the games whose existing 1836 /// Player row was updated to a new derived address, so the caller can 1837 /// republish those rows for peers. The address write-back never fabricates a 1838 /// bare Player row (which would clobber `name`/`readAt` server-side); a game 1839 /// with no local row still contributes its binding to the registration set. 1840 func reconcileLocalPushAddresses( 1841 authorID: String, 1842 secret: String 1843 ) -> (bindings: [PushAddressBinding], republishGameIDs: [UUID]) { 1844 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1845 request.predicate = NSPredicate( 1846 format: "databaseScope == 1 OR ckShareRecordName != nil" 1847 ) 1848 let games = (try? context.fetch(request)) ?? [] 1849 var bindings: [PushAddressBinding] = [] 1850 var republishGameIDs: [UUID] = [] 1851 var gameRecordUpdates: [String] = [] 1852 var didChange = false 1853 for game in games { 1854 guard let gameID = game.id else { continue } 1855 // Mint the shared push credential in place when absent, so it ships 1856 // on the next Game-record push and peers converge on it. 1857 var creds = GamePushCredentials.decode(game.notification) 1858 if creds == nil, 1859 let fresh = try? GamePushCredentials.fresh(), 1860 let encoded = try? fresh.encoded() { 1861 game.notification = encoded 1862 game.hasPendingSave = true 1863 creds = fresh 1864 didChange = true 1865 if let ckName = game.ckRecordName { gameRecordUpdates.append(ckName) } 1866 } 1867 guard let credentials = creds else { continue } 1868 let address = RecordSerializer.deriveGameAddress(secret: secret, gameID: gameID) 1869 bindings.append( 1870 PushAddressBinding(gameID: gameID, address: address, credentials: credentials) 1871 ) 1872 // Update an existing row in place; never create one here. 1873 guard let player = fetchPlayerEntity(gameID: gameID, authorID: authorID), 1874 player.pushAddress != address 1875 else { continue } 1876 player.pushAddress = address 1877 player.updatedAt = Date() 1878 republishGameIDs.append(gameID) 1879 didChange = true 1880 } 1881 if didChange { 1882 saveContext("reconcileLocalPushAddresses") 1883 } 1884 // Enqueue Game-record pushes for freshly-minted credentials after the 1885 // save, mirroring `setNotification`. 1886 for ckName in gameRecordUpdates { 1887 onGameUpdated(ckName) 1888 } 1889 return (bindings, republishGameIDs) 1890 } 1891 1892 /// Player record `updatedAt` for `(gameID, authorID)` — `nil` if no row 1893 /// exists yet. Used as the peer-device liveness probe during the pause 1894 /// grace window: a value newer than `pauseStart` means a sibling device 1895 /// of the same author wrote to Player after we started pausing, i.e. 1896 /// that device is still active and will publish its own pause later. 1897 func playerUpdatedAt(for gameID: UUID, by authorID: String) -> Date? { 1898 fetchPlayerEntity(gameID: gameID, authorID: authorID)?.updatedAt 1899 } 1900 1901 /// Sender-local "notified through" watermark for `(gameID, authorID)` — 1902 /// the latest authored move we've told this recipient about via a pause, 1903 /// or `nil` if we never have. Paired with `Player.readAt` to window the 1904 /// next session-end diff (see `SessionPushPlanner.sessionEndAddressees`). 1905 func notifiedThrough(for gameID: UUID, by authorID: String) -> Date? { 1906 fetchPlayerEntity(gameID: gameID, authorID: authorID)?.notifiedThrough 1907 } 1908 1909 /// Advances the notified-through watermark for each peer in `authorIDs` to 1910 /// `through`, the latest move the pause we just sent them covered. Purely 1911 /// local bookkeeping: it stamps no `updatedAt` and enqueues no push, so it 1912 /// never rides a CloudKit Player record — `RecordSerializer.playerRecord` 1913 /// deliberately omits the field. Monotonic; a backward `through` (clock 1914 /// wobble) is ignored. Rows are expected to exist already, since we only 1915 /// notify recipients read out of `pushPlan`. 1916 func recordNotified(gameID: UUID, authorIDs: [String], through: Date) { 1917 guard !authorIDs.isEmpty else { return } 1918 var didChange = false 1919 for authorID in authorIDs { 1920 guard let player = fetchPlayerEntity(gameID: gameID, authorID: authorID) else { 1921 continue 1922 } 1923 if let current = player.notifiedThrough, current >= through { continue } 1924 player.notifiedThrough = through 1925 didChange = true 1926 } 1927 if didChange { 1928 saveContext("recordNotified") 1929 } 1930 } 1931 1932 /// Merged-across-devices author cells for `(gameID, authorID)`. Returns 1933 /// each touched grid position's winning `TimestampedCell` after the 1934 /// usual LWW merge across the author's devices, including cleared cells 1935 /// (empty `letter` with a non-default `updatedAt`). The pause-push 1936 /// per-recipient diff iterates this list, counting cells whose 1937 /// `updatedAt` is newer than that recipient's last-known `Player.readAt`. 1938 func mergedAuthorCells(for gameID: UUID, by authorID: String) -> [TimestampedCell] { 1939 let request = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 1940 request.predicate = NSPredicate( 1941 format: "game.id == %@ AND authorID == %@", 1942 gameID as CVarArg, 1943 authorID 1944 ) 1945 let entities = (try? context.fetch(request)) ?? [] 1946 let values: [MovesValue] = entities.compactMap { Self.movesValue(from: $0) } 1947 guard !values.isEmpty else { return [] } 1948 return GridStateMerger.mergeWithProvenance(values).values.map(\.cell) 1949 } 1950 1951 /// Grid cells a *peer* filled or cleared since `since`, each mapped to the 1952 /// author who wrote the change — the data behind the "changed while you were 1953 /// away" borders. Merges every contributor's moves (not just one author's), 1954 /// so a peer's clear is attributed to them even though it leaves no 1955 /// preserved cell author. The local player's own edits are excluded. Returns 1956 /// empty when the local author is unknown (nothing to compare against). 1957 func recentlyChangedCells(forGame gameID: UUID, since: Date) -> [GridPosition: String] { 1958 recentChanges(forGame: gameID, since: since).cells 1959 } 1960 1961 /// The full `RecentChanges.Changes` for `gameID` since `since`: the cell 1962 /// map behind the borders *and* the per-author counts behind the catch-up 1963 /// banner, from one pass over the per-cell letter-change ledger so the two 1964 /// surfaces always agree. Empty when the local author is unknown, or when 1965 /// the ledger holds nothing newer than `since` (including a game whose 1966 /// ledger has not been seeded yet — a first open shows no banner). 1967 func recentChanges(forGame gameID: UUID, since: Date) -> RecentChanges.Changes { 1968 guard let localAuthorID = authorIDProvider() else { return .empty } 1969 let request = NSFetchRequest<PeerChangeEntity>(entityName: "PeerChangeEntity") 1970 request.predicate = NSPredicate( 1971 format: "gameID == %@ AND changedAt > %@", 1972 gameID as CVarArg, 1973 since as NSDate 1974 ) 1975 let rows = (try? context.fetch(request)) ?? [] 1976 guard !rows.isEmpty else { return .empty } 1977 let entries = rows.map { Self.peerChange(from: $0) } 1978 return RecentChanges.changes(in: entries, since: since, excludingAuthor: localAuthorID) 1979 } 1980 1981 /// Diagnostic snapshot for the catch-up banner and border-highlight reads. 1982 /// This is intentionally read-only: it reports the ledger as it stands at 1983 /// the instant the UI asks for recent changes, so a stale/asynchronous build 1984 /// can be distinguished from a bad reduction. 1985 func recentChangesDiagnosticSummary(forGame gameID: UUID, since: Date) -> String { 1986 let localAuthorID = authorIDProvider() 1987 let allReq = NSFetchRequest<PeerChangeEntity>(entityName: "PeerChangeEntity") 1988 allReq.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg) 1989 let allRows = (try? context.fetch(allReq)) ?? [] 1990 1991 let newerRows = allRows.filter { ($0.changedAt ?? .distantPast) > since } 1992 let entries = newerRows.map { Self.peerChange(from: $0) } 1993 let changes = localAuthorID.map { 1994 RecentChanges.changes(in: entries, since: since, excludingAuthor: $0) 1995 } ?? .empty 1996 let counted = changes.counts.values.reduce(0) { $0 + $1.added + $1.cleared } 1997 let byAuthor = changes.counts.keys.sorted().map { authorID in 1998 let count = changes.counts[authorID] ?? RecentChanges.Count(added: 0, cleared: 0) 1999 return "\(authorID.prefix(8))=+\(count.added)/-\(count.cleared)" 2000 }.joined(separator: ",") 2001 let newest = allRows.compactMap(\.changedAt).max() 2002 2003 return "since=\(since.ISO8601Format()) " 2004 + "local=\(Self.shortAuthorID(localAuthorID)) " 2005 + "ledgerRows=\(allRows.count) newer=\(newerRows.count) " 2006 + "counted=\(counted) cells=\(changes.cells.count) " 2007 + "byAuthor=[\(byAuthor)] " 2008 + "newest=\(newest?.ISO8601Format() ?? "nil") " 2009 + Self.peerChangeEntitySampleSummary(newerRows) 2010 } 2011 2012 /// Sender-side measurements describing *why* the pause-push counts for 2013 /// `(gameID, authorID)` came out as they did. Mirrors the set the count 2014 /// path (`mergedAuthorCells`) iterates, then breaks it down against the 2015 /// current grid: how many merged positions fall inside the bounds and on 2016 /// playable squares, the coordinate range, the contributing device count, 2017 /// and the edit window. Diagnostic-only; never affects badge or body. The 2018 /// time-of-day and per-recipient fields are filled by the caller — only 2019 /// the store-derived measurements are populated here. 2020 func movesDiagnostics(for gameID: UUID, by authorID: String) -> PushPayload.Diagnostics? { 2021 let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2022 gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 2023 gReq.fetchLimit = 1 2024 guard let game = try? context.fetch(gReq).first else { return nil } 2025 2026 let mReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 2027 mReq.predicate = NSPredicate( 2028 format: "game.id == %@ AND authorID == %@", 2029 gameID as CVarArg, 2030 authorID 2031 ) 2032 let entities = (try? context.fetch(mReq)) ?? [] 2033 let values: [MovesValue] = entities.compactMap { Self.movesValue(from: $0) } 2034 let merged = GridStateMerger.mergeWithProvenance(values) 2035 2036 let width = Int(game.gridWidth) 2037 let height = Int(game.gridHeight) 2038 let puzzle = game.puzzleSource 2039 .flatMap { try? XD.parse($0) } 2040 .map(Puzzle.init(xd:)) 2041 2042 var inBounds = 0 2043 var playable = 0 2044 var minRow = Int.max, maxRow = Int.min, minCol = Int.max, maxCol = Int.min 2045 var earliest: Date? 2046 var latest: Date? 2047 for (position, provenance) in merged { 2048 minRow = min(minRow, position.row); maxRow = max(maxRow, position.row) 2049 minCol = min(minCol, position.col); maxCol = max(maxCol, position.col) 2050 let updatedAt = provenance.cell.updatedAt 2051 if earliest == nil || updatedAt < earliest! { earliest = updatedAt } 2052 if latest == nil || updatedAt > latest! { latest = updatedAt } 2053 let within = position.row >= 0 && position.row < height 2054 && position.col >= 0 && position.col < width 2055 guard within else { continue } 2056 inBounds += 1 2057 if let puzzle, !puzzle.cells[position.row][position.col].isBlock { 2058 playable += 1 2059 } 2060 } 2061 let deviceCount = Set(entities.compactMap { $0.deviceID }).count 2062 2063 return PushPayload.Diagnostics( 2064 gridWidth: width, 2065 gridHeight: height, 2066 cmVersion: Int(game.puzzleCmVersion), 2067 mergedCells: merged.count, 2068 inBounds: inBounds, 2069 playable: playable, 2070 minRow: merged.isEmpty ? nil : minRow, 2071 maxRow: merged.isEmpty ? nil : maxRow, 2072 minCol: merged.isEmpty ? nil : minCol, 2073 maxCol: merged.isEmpty ? nil : maxCol, 2074 deviceCount: deviceCount, 2075 earliestEdit: earliest, 2076 latestEdit: latest 2077 ) 2078 } 2079 2080 /// Every `(author, device)` that has written a `MovesEntity` for `gameID` — 2081 /// i.e. every device whose grid letters are present locally. Peers' and this 2082 /// account's *other* devices' Moves sync in as their own per-device rows, so 2083 /// this is the authoritative local answer to "did anyone else contribute" 2084 /// (the roster can't tell, being keyed by author alone). Replay needs a 2085 /// journal from each; when the set is just this device the local journal 2086 /// already explains the whole grid and no CloudKit fetch is needed. 2087 func contributingDevices(for gameID: UUID) -> Set<JournalDeviceKey> { 2088 let request = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 2089 request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) 2090 let entities = (try? context.fetch(request)) ?? [] 2091 var devices: Set<JournalDeviceKey> = [] 2092 for entity in entities { 2093 guard let authorID = entity.authorID, !authorID.isEmpty, 2094 let deviceID = entity.deviceID, !deviceID.isEmpty else { continue } 2095 devices.insert(JournalDeviceKey(authorID: authorID, deviceID: deviceID)) 2096 } 2097 return devices 2098 } 2099 2100 /// Distinct authorIDs that have written a `MovesEntity` for `gameID`, 2101 /// with `excluding` filtered out. The session-summary banner uses this 2102 /// to enumerate peers whose activity it should diff. 2103 func peerAuthorIDs(for gameID: UUID, excluding localAuthorID: String?) -> [String] { 2104 let request = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 2105 request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) 2106 let entities = (try? context.fetch(request)) ?? [] 2107 var unique: Set<String> = [] 2108 for entity in entities { 2109 guard let authorID = entity.authorID, !authorID.isEmpty else { continue } 2110 if let localAuthorID, authorID == localAuthorID { continue } 2111 unique.insert(authorID) 2112 } 2113 return Array(unique) 2114 } 2115 2116 /// Formatted title used by notifications and the in-app session banner 2117 /// for `gameID`. Returns an empty string when the game can't be found. 2118 func puzzleTitleForNotification(for gameID: UUID) -> String { 2119 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2120 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 2121 request.fetchLimit = 1 2122 let entity = try? context.fetch(request).first 2123 return PuzzleNotificationText.title(for: entity) 2124 } 2125 2126 /// Display name persisted on the PlayerEntity for `(gameID, authorID)`, 2127 /// or an empty string when no row exists. The session banner falls back 2128 /// to "A player" via `SessionMonitor.bodyText` when empty. 2129 func playerName(for gameID: UUID, by authorID: String) -> String { 2130 return fetchPlayerEntity(gameID: gameID, authorID: authorID)?.name ?? "" 2131 } 2132 2133 /// The user's private nickname for `authorID` (`FriendEntity.nickname`), 2134 /// or `nil` when none is set. The same override `resolvedDisplayName` 2135 /// applies, exposed here for surfaces hydrated through `GameStore` 2136 /// rather than from a `FriendEntity` row — currently the catch-up 2137 /// banner's `SessionMonitor.summaries`. 2138 func friendNickname(for authorID: String) -> String? { 2139 guard !authorID.isEmpty else { return nil } 2140 let request = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 2141 request.predicate = NSPredicate(format: "authorID == %@", authorID) 2142 request.fetchLimit = 1 2143 guard let nickname = (try? context.fetch(request).first)?.nickname? 2144 .trimmingCharacters(in: .whitespacesAndNewlines), 2145 !nickname.isEmpty 2146 else { return nil } 2147 return nickname 2148 } 2149 2150 /// The read cursor persisted for `(gameID, authorID)`, or `nil` when no row 2151 /// exists or none has been stamped. Used by the local pause-diagnostics 2152 /// mirror to compare this device's actual cursor against the value a peer's 2153 /// pushed diagnostics claim it saw. 2154 func readAt(for gameID: UUID, by authorID: String) -> Date? { 2155 return fetchPlayerEntity(gameID: gameID, authorID: authorID)?.readAt 2156 } 2157 2158 /// The local author's own derived push address for `gameID`, read off the 2159 /// local Player row, or nil if one hasn't been stamped yet. Used to keep a 2160 /// room broadcast from notifying the sender's own other devices. 2161 func localPushAddress(gameID: UUID, authorID: String) -> String? { 2162 fetchPlayerEntity(gameID: gameID, authorID: authorID)?.pushAddress 2163 } 2164 2165 private func fetchPlayerEntity(gameID: UUID, authorID: String) -> PlayerEntity? { 2166 let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 2167 request.predicate = NSPredicate( 2168 format: "game.id == %@ AND authorID == %@", 2169 gameID as CVarArg, 2170 authorID 2171 ) 2172 request.fetchLimit = 1 2173 return try? context.fetch(request).first 2174 } 2175 2176 /// Replaces a game's persisted XD source with a re-converted equivalent, 2177 /// stamps the current CmVer, raises `hasPushPending` so the next outbound 2178 /// Game record re-includes the `puzzleSource` asset, and enqueues the push 2179 /// via `onGameUpdated`. Callers (currently the NYT upgrade flow) are 2180 /// responsible for verifying that `newSource` is structurally compatible 2181 /// with the player's in-progress moves before invoking this. 2182 func replacePuzzleSource(id: UUID, with newSource: String) { 2183 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2184 request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 2185 request.fetchLimit = 1 2186 guard let entity = try? context.fetch(request).first, 2187 let parsed = try? XD.parse(newSource) else { return } 2188 let puzzle = Puzzle(xd: parsed) 2189 entity.puzzleSource = newSource 2190 entity.title = puzzle.title 2191 entity.puzzleCmVersion = Int64(XD.currentCmVersion) 2192 entity.hasPushPending = true 2193 entity.hasPendingSave = true 2194 entity.populateCachedSummaryFields(from: puzzle) 2195 saveContext("upgradePuzzleSource") 2196 if let ckName = entity.ckRecordName { 2197 onGameUpdated(ckName) 2198 } 2199 } 2200 2201 /// Stamps the current CmVer onto a game without other changes. Used when 2202 /// an attempted upgrade decided not to replace the source (e.g. structural 2203 /// mismatch) but the caller still wants to retire the stale version so the 2204 /// upgrade isn't reattempted every launch. 2205 func bumpPuzzleCmVersion(for id: UUID) { 2206 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2207 request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 2208 request.fetchLimit = 1 2209 guard let entity = try? context.fetch(request).first else { return } 2210 entity.puzzleCmVersion = Int64(XD.currentCmVersion) 2211 saveContext("bumpPuzzleCmVersion") 2212 } 2213 2214 private func restore(game: Game, from entity: GameEntity, updateCache: Bool = true) { 2215 let movesRequest = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 2216 movesRequest.predicate = NSPredicate(format: "game == %@", entity) 2217 let movesEntities = (try? context.fetch(movesRequest)) ?? [] 2218 let values: [MovesValue] = movesEntities.compactMap { Self.movesValue(from: $0) } 2219 let grid = GridStateMerger.merge(values) 2220 2221 // A completed game (won or resigned) is terminal; its grid is, by 2222 // definition, the solution. The merge is watermarked at `completedAt`: 2223 // a collaborator's letter typed just before the win still merges in 2224 // when it reaches us afterward (carrying its author), but anything 2225 // stamped after the latch is ignored — nothing re-opens or rewrites a 2226 // finished puzzle. Whatever's still empty at the cutoff is sealed to 2227 // the solution so a completed game always renders solved, independent 2228 // of merge drift (a late clear, an edit that reached a peer over 2229 // engagement but never synced — see the realtime/durable decoupling). 2230 // Input is separately locked (`GameMutator.isCompleted`); the 2231 // CellEntity cache still mirrors the raw (un-watermarked) merge. 2232 if let completedAt = entity.completedAt { 2233 let sealedGrid = GridStateMerger.merge(values, notAfter: completedAt) 2234 sealToSolution(game: game, mergedGrid: sealedGrid) 2235 if updateCache { 2236 updateCellCache(for: entity, from: grid) 2237 } 2238 return 2239 } 2240 2241 // The local device's own row. Used to decide whether a buffered edit 2242 // (flagged by `Square.enqueuedAt`) has landed durably yet: once the 2243 // flush writes it, this row carries the cell with `updatedAt` equal 2244 // to the flag's timestamp (`MovesUpdater` persists `enqueuedAt` as the 2245 // cell's `updatedAt`). 2246 let localDeviceID = RecordSerializer.localDeviceID 2247 let localAuthorID = authorIDProvider() 2248 let localCells: [GridPosition: TimestampedCell] = values.first { 2249 $0.deviceID == localDeviceID 2250 && (localAuthorID == nil || $0.authorID == localAuthorID) 2251 }?.cells ?? [:] 2252 2253 // Apply the merge as a diff: an inbound catch-up usually carries one 2254 // peer keystroke, yet the merged grid spans the whole board. Writing 2255 // every square unconditionally fires the `@Observable squares` 2256 // hundreds of times per catch-up — invalidating the grid view and 2257 // re-running the completion scan — even when the merged result is 2258 // identical to what's already on screen. Touch only the cells whose 2259 // value actually changed, and rebuild the completion cache only if at 2260 // least one did, so a redundant catch-up becomes a true no-op on the 2261 // main actor (the co-solve hot path). 2262 var changed = false 2263 for (position, cell) in grid { 2264 let r = position.row 2265 let c = position.col 2266 guard r >= 0, r < game.puzzle.height, c >= 0, c < game.puzzle.width else { continue } 2267 let current = game.squares[r][c] 2268 // A non-nil `enqueuedAt` means the user typed here and the edit 2269 // may still be buffered in `MovesUpdater`. Retire the flag only 2270 // once the local row shows this cell at a timestamp >= the flag 2271 // (the edit, or a newer one, has landed); until then leave the 2272 // value fields alone so the just-typed letter stays on screen. 2273 var clearEnqueued = false 2274 if let stamp = current.enqueuedAt { 2275 guard let landed = localCells[position], 2276 landed.updatedAt >= stamp 2277 else { continue } 2278 clearEnqueued = true 2279 } 2280 guard clearEnqueued 2281 || current.entry != cell.letter 2282 || current.mark != cell.mark 2283 || current.letterAuthorID != cell.authorID 2284 else { continue } 2285 // Build the new square locally and assign once, so the cell fires 2286 // a single `squares` mutation rather than one per field. 2287 var updated = current 2288 updated.enqueuedAt = clearEnqueued ? nil : current.enqueuedAt 2289 updated.entry = cell.letter 2290 updated.mark = cell.mark 2291 updated.letterAuthorID = cell.authorID 2292 game.squares[r][c] = updated 2293 changed = true 2294 } 2295 if changed { 2296 game.recomputeCompletionCache() 2297 } 2298 2299 if updateCache { 2300 updateCellCache(for: entity, from: grid) 2301 } 2302 } 2303 2304 /// Populates `game.squares` from the puzzle solution for a completed 2305 /// (terminal) game. A cell the merge already resolved to an accepted answer 2306 /// keeps that entry, its author, and its mark; a hole or a stray 2307 /// post-completion letter is overwritten with the canonical solution and a 2308 /// clean mark. Cells with no known solution fall back to the merged value. 2309 /// The result is always `.solved`, so the finish presentation is shown. 2310 private func sealToSolution(game: Game, mergedGrid: GridState) { 2311 for r in 0..<game.puzzle.height { 2312 for c in 0..<game.puzzle.width { 2313 let cell = game.puzzle.cells[r][c] 2314 guard !cell.isBlock else { continue } 2315 game.squares[r][c].enqueuedAt = nil 2316 let merged = mergedGrid[GridPosition(row: r, col: c)] 2317 if let merged, cell.accepts(merged.letter) { 2318 // Correctly filled — preserve who filled it and its mark 2319 // (a stale wrong-mark on a correct letter is contradictory, 2320 // so force it off). 2321 game.squares[r][c].entry = merged.letter 2322 game.squares[r][c].mark = merged.mark.withoutWrongCheck 2323 game.squares[r][c].letterAuthorID = merged.authorID 2324 } else if let solution = cell.solution { 2325 // Hole or stray post-completion letter — seal to solution. 2326 game.squares[r][c].entry = solution 2327 game.squares[r][c].mark = .none 2328 game.squares[r][c].letterAuthorID = merged?.authorID 2329 } else if let merged { 2330 // No known solution — show whatever the merge resolved. 2331 game.squares[r][c].entry = merged.letter 2332 game.squares[r][c].mark = merged.mark 2333 game.squares[r][c].letterAuthorID = merged.authorID 2334 } 2335 } 2336 } 2337 game.recomputeCompletionCache() 2338 } 2339 2340 private func updateCellCache(for gameEntity: GameEntity, from grid: GridState) { 2341 Self.applyCellCache(to: gameEntity, from: grid, in: context) 2342 saveContext("updateCellCache") 2343 } 2344 2345 /// Hydrates a `MovesValue` from a `MovesEntity`. Returns `nil` if the row 2346 /// is missing required fields. 2347 fileprivate nonisolated static func movesValue(from entity: MovesEntity) -> MovesValue? { 2348 guard let gameID = entity.game?.id, 2349 let authorID = entity.authorID, 2350 let deviceID = entity.deviceID, 2351 let updatedAt = entity.updatedAt 2352 else { return nil } 2353 let cells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:] 2354 return MovesValue( 2355 gameID: gameID, 2356 authorID: authorID, 2357 deviceID: deviceID, 2358 cells: cells, 2359 updatedAt: updatedAt 2360 ) 2361 } 2362 2363 fileprivate nonisolated static func peerChange(from entity: PeerChangeEntity) -> PeerChange { 2364 PeerChange( 2365 position: GridPosition(row: Int(entity.row), col: Int(entity.col)), 2366 letter: entity.letter ?? "", 2367 authorID: entity.authorID, 2368 changedAt: entity.changedAt ?? .distantPast 2369 ) 2370 } 2371 2372 private nonisolated static func peerChangeSampleSummary(_ changes: [PeerChange]) -> String { 2373 guard !changes.isEmpty else { return "sample=[]" } 2374 let sample = changes 2375 .sorted { lhs, rhs in 2376 if lhs.changedAt != rhs.changedAt { return lhs.changedAt < rhs.changedAt } 2377 if lhs.position.row != rhs.position.row { return lhs.position.row < rhs.position.row } 2378 return lhs.position.col < rhs.position.col 2379 } 2380 .prefix(5) 2381 .map { 2382 "r\($0.position.row)c\($0.position.col):" 2383 + "\($0.letter.isEmpty ? "-" : $0.letter)" 2384 + "@\($0.changedAt.ISO8601Format())" 2385 + "#\(Self.shortAuthorID($0.authorID))" 2386 } 2387 .joined(separator: ",") 2388 return "sample=[\(sample)]" 2389 } 2390 2391 private nonisolated static func peerChangeEntitySampleSummary(_ rows: [PeerChangeEntity]) -> String { 2392 peerChangeSampleSummary(rows.map { peerChange(from: $0) }) 2393 } 2394 2395 private nonisolated static func shortAuthorID(_ authorID: String?) -> String { 2396 guard let authorID else { return "nil" } 2397 return String(authorID.prefix(8)) 2398 } 2399 2400 private func inferredObservedCompletionAuthorID(for id: UUID) -> String? { 2401 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2402 request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 2403 request.fetchLimit = 1 2404 guard let entity = try? context.fetch(request).first, 2405 let source = entity.puzzleSource, 2406 let xd = try? XD.parse(source) 2407 else { return nil } 2408 2409 let puzzle = Puzzle(xd: xd) 2410 let movesRequest = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 2411 movesRequest.predicate = NSPredicate(format: "game == %@", entity) 2412 let movesEntities = (try? context.fetch(movesRequest)) ?? [] 2413 let values: [MovesValue] = movesEntities.compactMap { Self.movesValue(from: $0) } 2414 let provenance = GridStateMerger.mergeWithProvenance(values) 2415 2416 var latest: (date: Date, authorID: String)? 2417 for row in puzzle.cells { 2418 for cell in row { 2419 guard !cell.isBlock, cell.solution != nil else { continue } 2420 let position = GridPosition(row: cell.row, col: cell.col) 2421 guard let winner = provenance[position], 2422 !winner.cell.letter.isEmpty, 2423 cell.accepts(winner.cell.letter) 2424 else { return nil } 2425 2426 if latest.map({ winner.cell.updatedAt > $0.date }) ?? true { 2427 latest = (winner.cell.updatedAt, winner.writerAuthorID) 2428 } 2429 } 2430 } 2431 return latest?.authorID 2432 } 2433 2434 /// Reconciles a `GameEntity`'s `CellEntity` cache against `grid` inside 2435 /// `ctx`. Caller is responsible for saving `ctx`. Used from both the 2436 /// main-context `updateCellCache` and the background-context 2437 /// `replayCellCaches`. 2438 fileprivate nonisolated static func applyCellCache( 2439 to gameEntity: GameEntity, 2440 from grid: GridState, 2441 in ctx: NSManagedObjectContext 2442 ) { 2443 let cellEntities = (gameEntity.cells as? Set<CellEntity>) ?? [] 2444 var existing: [GridPosition: CellEntity] = [:] 2445 for ce in cellEntities { 2446 existing[GridPosition(row: Int(ce.row), col: Int(ce.col))] = ce 2447 } 2448 2449 for (position, cell) in grid { 2450 let ce: CellEntity 2451 if let found = existing[position] { 2452 ce = found 2453 } else { 2454 ce = CellEntity(context: ctx) 2455 ce.row = Int16(position.row) 2456 ce.col = Int16(position.col) 2457 ce.game = gameEntity 2458 } 2459 ce.letter = cell.letter 2460 ce.markCode = cell.mark.code 2461 ce.letterAuthorID = cell.authorID 2462 } 2463 2464 for (position, ce) in existing where grid[position] == nil { 2465 ce.letter = "" 2466 ce.markCode = 0 2467 ce.letterAuthorID = nil 2468 } 2469 } 2470 2471 /// Marks the active game read-only when the sync engine sees its shared 2472 /// zone disappear from the shared database (owner revoked access). 2473 func markAccessRevoked(gameID: UUID) { 2474 guard currentEntity?.id == gameID else { return } 2475 currentMutator?.isAccessRevoked = true 2476 } 2477 2478 /// Called after the sync engine deletes a `GameEntity` in response to a 2479 /// remote private-DB zone deletion (the user removed this game on another 2480 /// device). The deletion itself has already been merged into the view 2481 /// context; this method's job is to drop the active references if the 2482 /// open puzzle is the one that just disappeared, so the UI doesn't 2483 /// dereference a deleted managed object. Returns whether the removed game 2484 /// was the one currently open, so the caller can surface an in-puzzle 2485 /// notice only when there is a puzzle on screen to host it. 2486 @discardableResult 2487 func handleRemoteRemoval(gameID: UUID) -> Bool { 2488 let wasOpen = currentEntity?.id == gameID 2489 if wasOpen { 2490 currentGame = nil 2491 currentMutator = nil 2492 currentEntity = nil 2493 } 2494 onUnreadOtherMovesChanged?() 2495 return wasOpen 2496 } 2497 2498 /// Flips the active game's mutator to shared after `ShareController` 2499 /// saves a `CKShare`, so an open `PuzzleView` reacts (builds the roster, 2500 /// starts publishing the local selection) without requiring the user to re-open. 2501 func markShared(gameID: UUID) { 2502 guard currentEntity?.id == gameID else { return } 2503 currentMutator?.isShared = true 2504 } 2505 2506 private func makeMutator(game: Game, entity: GameEntity) -> GameMutator { 2507 guard let gameID = entity.id else { 2508 fatalError("GameEntity missing id — data model invariant violated") 2509 } 2510 return GameMutator( 2511 game: game, 2512 gameID: gameID, 2513 movesUpdater: movesUpdater, 2514 movesJournal: movesJournal, 2515 authorIDProvider: authorIDProvider, 2516 onLocalCellEdit: { [weak self] edit in 2517 self?.onLocalCellEdit?(edit) 2518 }, 2519 onLocalCellEditBatch: { [weak self] edits in 2520 self?.onLocalCellEditBatch?(edits) 2521 }, 2522 isOwned: entity.databaseScope == 0, 2523 isShared: entity.ckShareRecordName != nil || entity.databaseScope == 1, 2524 isAccessRevoked: entity.isAccessRevoked, 2525 isCompleted: entity.completedAt != nil 2526 ) 2527 } 2528 2529 private func fetchGameEntity(id gameID: UUID) -> GameEntity? { 2530 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2531 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 2532 req.fetchLimit = 1 2533 return try? context.fetch(req).first 2534 } 2535 2536 private func ensureMovesEntity( 2537 recordName: String, 2538 game: GameEntity, 2539 authorID: String, 2540 deviceID: String 2541 ) -> MovesEntity { 2542 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 2543 req.predicate = NSPredicate(format: "ckRecordName == %@", recordName) 2544 req.fetchLimit = 1 2545 if let existing = try? context.fetch(req).first { 2546 return existing 2547 } 2548 2549 let entity = MovesEntity(context: context) 2550 entity.game = game 2551 entity.ckRecordName = recordName 2552 entity.authorID = authorID 2553 entity.deviceID = deviceID 2554 entity.cells = Data() 2555 entity.updatedAt = Date() 2556 return entity 2557 } 2558 2559 }