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