GameStore.swift (152487B)
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 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 /// Establishes the silent starting snapshot for a shared game's peer-change 841 /// ledger. The durable flag is separate from the rows because an empty grid 842 /// is still a valid seed; without it, the first later peer fill would be 843 /// mistaken for pre-existing content and stamped at `.distantPast`. 844 func ensurePeerChangeLedgerSeeded(for gameID: UUID) { 845 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 846 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 847 request.fetchLimit = 1 848 guard let game = try? context.fetch(request).first, 849 game.completedAt == nil, 850 !game.hasSeededPeerChanges else { return } 851 enqueuePeerChangeLedgerUpdate(for: [gameID]) 852 } 853 854 /// Fire-and-forget request to refresh the peer-change ledger for `gameIDs`. 855 /// Returns immediately — the inbound-moves hot path must never wait on this 856 /// database write. The request is just buffered onto the serial queue; the 857 /// single consumer applies it when it gets there. 858 func enqueuePeerChangeLedgerUpdate(for gameIDs: Set<UUID>) { 859 guard !gameIDs.isEmpty else { return } 860 eventLog?.note( 861 "peer ledger enqueue: games=[\(gameIDs.map { String($0.uuidString.prefix(8)) }.sorted().joined(separator: ","))]" 862 ) 863 if ledgerRequests == nil { 864 let (stream, continuation) = AsyncStream<Set<UUID>>.makeStream() 865 ledgerRequests = continuation 866 // The sole consumer: serial by construction, so no two builds run at 867 // once. Inherits this `@MainActor`, hopping to a background context 868 // only inside `updatePeerChangeLedger`. 869 Task { [weak self] in 870 for await gameIDs in stream { 871 await self?.updatePeerChangeLedger(for: gameIDs) 872 } 873 } 874 } 875 ledgerRequests?.yield(gameIDs) 876 } 877 878 /// Maintains the device-local per-cell letter-change ledger 879 /// (`PeerChangeEntity`) for each game that just received inbound moves. For 880 /// every cell whose letter differs from what the ledger holds, upserts a row 881 /// stamped with the move's letter-change time; a check (a mark-only 882 /// re-stamp) leaves the letter unchanged and so writes nothing. A completed 883 /// game is terminal, so its rows are dropped and not rebuilt. Runs on a 884 /// background context, off the main actor. 885 /// 886 /// The ledger is what the "changed while you were away" borders and catch-up 887 /// banner read (`recentChanges(forGame:since:)`). Recording a letter-change 888 /// time — rather than trusting the synced cell's `updatedAt`, which a check 889 /// bumps — is what stops a peer's check sweep from flagging the whole board 890 /// on rejoin. A game with neither the durable seed flag nor legacy ledger 891 /// rows records every current cell at `.distantPast`, a silent baseline that 892 /// surfaces nothing; the separate flag lets an empty grid count as seeded. 893 /// 894 /// In the app this is driven through `enqueuePeerChangeLedgerUpdate`, whose 895 /// single serial consumer guarantees builds never overlap — so the 896 /// upsert-by-position below can't duplicate a row. Tests call it directly 897 /// and `await` it for determinism. 898 func updatePeerChangeLedger(for gameIDs: Set<UUID>) async { 899 guard !gameIDs.isEmpty else { return } 900 peerChangeLedgerBuildSerial += 1 901 let serial = peerChangeLedgerBuildSerial 902 let startedAt = Date() 903 eventLog?.note( 904 "peer ledger build #\(serial) start: games=[\(gameIDs.map { String($0.uuidString.prefix(8)) }.sorted().joined(separator: ","))]" 905 ) 906 let ctx = persistence.container.newBackgroundContext() 907 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 908 let result: (diagnostics: [String], errorMessage: String?) = await ctx.perform { 909 var diagnostics: [String] = [] 910 var errorMessage: String? 911 for gameID in gameIDs { 912 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 913 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 914 gameReq.fetchLimit = 1 915 guard let game = try? ctx.fetch(gameReq).first else { 916 diagnostics.append("\(gameID.uuidString.prefix(8)) missing-game") 917 continue 918 } 919 920 let ledgerReq = NSFetchRequest<PeerChangeEntity>(entityName: "PeerChangeEntity") 921 ledgerReq.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg) 922 let existingRows = (try? ctx.fetch(ledgerReq)) ?? [] 923 924 // A completed game is terminal: its grid is sealed and the 925 // "changed while you were away" surfaces never read this ledger 926 // again, so drop the rows and stop maintaining it. The cleanup 927 // lives here, not only at the completion call site, because a 928 // late peer move can still arrive for a finished game. 929 if game.completedAt != nil { 930 for row in existingRows { ctx.delete(row) } 931 diagnostics.append( 932 "\(gameID.uuidString.prefix(8)) completed existing=\(existingRows.count) deleted" 933 ) 934 continue 935 } 936 937 let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 938 movesReq.predicate = NSPredicate(format: "game == %@", game) 939 let values: [MovesValue] = ((try? ctx.fetch(movesReq)) ?? []) 940 .compactMap { Self.movesValue(from: $0) } 941 let current = GridStateMerger.mergeWithProvenance(values) 942 // Rows written by an older app already prove that game was 943 // seeded; honour them when migrating the new durable flag. 944 let isSeeding = !game.hasSeededPeerChanges && existingRows.isEmpty 945 946 var rowByPosition: [GridPosition: PeerChangeEntity] = [:] 947 for row in existingRows { 948 rowByPosition[GridPosition(row: Int(row.row), col: Int(row.col))] = row 949 } 950 let recorded = rowByPosition.mapValues { Self.peerChange(from: $0) } 951 952 let upserts = PeerChangeLedger.upserts( 953 current: current, 954 recorded: recorded, 955 seeding: isSeeding 956 ) 957 game.hasSeededPeerChanges = true 958 diagnostics.append( 959 "\(gameID.uuidString.prefix(8)) existing=\(existingRows.count) " 960 + "moves=\(values.count) current=\(current.count) " 961 + "seeding=\(isSeeding) upserts=\(upserts.count) " 962 + Self.peerChangeSampleSummary(upserts) 963 ) 964 for change in upserts { 965 // Positions originate in peer-controlled Moves payloads; 966 // the codec guarantees Int16 representability, and this 967 // keeps out-of-grid leftovers from becoming ledger rows. 968 guard change.position.isPersistable( 969 gridWidth: game.gridWidth, 970 gridHeight: game.gridHeight 971 ) else { continue } 972 let row = rowByPosition[change.position] ?? PeerChangeEntity(context: ctx) 973 row.gameID = gameID 974 row.row = Int16(change.position.row) 975 row.col = Int16(change.position.col) 976 row.letter = change.letter 977 row.authorID = change.authorID 978 row.changedAt = change.changedAt 979 row.game = game 980 } 981 } 982 guard ctx.hasChanges else { 983 return (diagnostics: diagnostics, errorMessage: errorMessage) 984 } 985 do { 986 try ctx.save() 987 } catch { 988 errorMessage = "GameStore: peer change ledger save failed — \(error)" 989 } 990 return (diagnostics: diagnostics, errorMessage: errorMessage) 991 } 992 if let errorMessage = result.errorMessage { 993 eventLog?.note(errorMessage, level: "error") 994 } 995 let elapsed = Date().timeIntervalSince(startedAt) 996 eventLog?.note( 997 "peer ledger build #\(serial) end: elapsed=\(String(format: "%.3f", elapsed))s " 998 + result.diagnostics.joined(separator: " | ") 999 ) 1000 } 1001 1002 /// Updates `latestOtherMoveAt` for each game whose Moves record was just 1003 /// updated by another iCloud user, driving the unread-badge heuristic. 1004 /// `gameIDs` are the games that received an inbound `Moves` record in the 1005 /// most recent sync batch; for each, we scan the now-persisted 1006 /// `MovesEntity` rows and pick the latest `updatedAt` whose row is owned 1007 /// by a different `authorID` than the local user. If the game is currently 1008 /// open, `lastReadOtherMoveAt` is advanced in lockstep so the badge 1009 /// doesn't appear for activity the user is already watching. 1010 func noteIncomingMovesUpdate(gameIDs: Set<UUID>, currentAuthorID: String?) { 1011 guard let currentAuthorID, !gameIDs.isEmpty else { return } 1012 1013 for gameID in gameIDs { 1014 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1015 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1016 gameReq.fetchLimit = 1 1017 guard let entity = try? context.fetch(gameReq).first else { continue } 1018 1019 let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 1020 movesReq.predicate = NSPredicate( 1021 format: "game == %@ AND authorID != %@", 1022 entity, 1023 currentAuthorID 1024 ) 1025 let rows = (try? context.fetch(movesReq)) ?? [] 1026 guard let latest = rows.compactMap(\.updatedAt).max() else { continue } 1027 1028 if (entity.latestOtherMoveAt ?? .distantPast) < latest { 1029 entity.latestOtherMoveAt = latest 1030 } 1031 // Use the foreground-visible signal rather than `currentEntity` — 1032 // the latter stays set after a normal back-out from the puzzle, 1033 // and would otherwise advance lastReadOtherMoveAt in lockstep 1034 // even when the user is sitting on the library list, suppressing 1035 // the badge that should appear there. 1036 // Suppressed = viewing here (incl. the local leave-grace) — the 1037 // user has eyes on these moves, so advance the *read watermark* 1038 // (`readThroughAt`) to what they're looking at. This is monotonic 1039 // and never forward-dated, so it cannot claim moves that arrive 1040 // after the user backgrounds. Sibling devices learn about the read 1041 // via `Player.readThrough`; the forward-dated presence lease 1042 // (`lastReadOtherMoveAt`) is published separately by AppServices. 1043 if NotificationState.isSuppressed(gameID: gameID), 1044 (entity.readThroughAt ?? .distantPast) < latest { 1045 entity.readThroughAt = latest 1046 } 1047 mirrorReadStateToChronicle(from: entity) 1048 } 1049 1050 if context.hasChanges { 1051 saveContext("mergeRemoteMoves") 1052 } 1053 onUnreadOtherMovesChanged?() 1054 } 1055 1056 @discardableResult 1057 func applyRealtimeCellEdit(_ edit: RealtimeCellEdit) -> Bool { 1058 applyRealtimeCellEdits([edit]) > 0 1059 } 1060 1061 /// Applies a batch of live cell edits with a single Core Data save and a 1062 /// single UI refresh, regardless of cell count. Edits are grouped by their 1063 /// owning Moves record (author + device), so each record is decoded and 1064 /// re-encoded once even for a whole-grid gesture like "check puzzle". 1065 /// Per-cell last-writer-wins is preserved. Returns the number of cells that 1066 /// actually changed. 1067 @discardableResult 1068 func applyRealtimeCellEdits(_ edits: [RealtimeCellEdit]) -> Int { 1069 // A live edit's `updatedAt` is attacker-controlled over the relay 1070 // channel. Clamp it to a bounded skew past now so a crafted far-future 1071 // timestamp can't win per-cell LWW permanently (and can't poison the 1072 // persisted Moves/Game `updatedAt`, which would then sync to CloudKit). 1073 // A genuine later edit always reclaims the cell once this is bounded. 1074 let maxAcceptableUpdatedAt = Date().addingTimeInterval(Self.realtimeCellEditMaxFutureSkew) 1075 let groups = Dictionary(grouping: edits) { edit in 1076 RecordSerializer.recordName( 1077 forMovesInGame: edit.gameID, 1078 authorID: edit.authorID, 1079 deviceID: edit.deviceID 1080 ) 1081 } 1082 1083 var applied = 0 1084 var rejected = 0 1085 var touchedGameIDs: Set<UUID> = [] 1086 for (recordName, groupEdits) in groups { 1087 guard let sample = groupEdits.first, 1088 !sample.authorID.isEmpty, 1089 !sample.deviceID.isEmpty, 1090 sample.deviceID != RecordSerializer.localDeviceID, 1091 let entity = fetchGameEntity(id: sample.gameID) 1092 else { continue } 1093 1094 // A live edit's coordinates are attacker-controlled over the 1095 // relay channel, like its `updatedAt` below. Reject anything 1096 // outside the grid before it reaches the persisted Moves state, 1097 // whose Int16 cache sinks trap on overflow — and before an 1098 // all-rejected batch can insert an empty MovesEntity stub. 1099 let validEdits = groupEdits.filter { edit in 1100 GridPosition(row: edit.row, col: edit.col).isPersistable( 1101 gridWidth: entity.gridWidth, 1102 gridHeight: entity.gridHeight 1103 ) 1104 } 1105 rejected += groupEdits.count - validEdits.count 1106 guard !validEdits.isEmpty else { continue } 1107 1108 let movesEntity = ensureMovesEntity( 1109 recordName: recordName, 1110 game: entity, 1111 authorID: sample.authorID, 1112 deviceID: sample.deviceID 1113 ) 1114 1115 var cells: [GridPosition: TimestampedCell] = [:] 1116 if let data = movesEntity.cells, !data.isEmpty { 1117 cells = (try? MovesCodec.decode(data)) ?? [:] 1118 } 1119 1120 var latest = movesEntity.updatedAt ?? .distantPast 1121 var changed = false 1122 for edit in validEdits { 1123 let position = GridPosition(row: edit.row, col: edit.col) 1124 let clampedUpdatedAt = min(edit.updatedAt, maxAcceptableUpdatedAt) 1125 let incoming = TimestampedCell( 1126 letter: edit.letter, 1127 mark: edit.mark, 1128 updatedAt: clampedUpdatedAt, 1129 authorID: edit.cellAuthorID, 1130 tick: edit.tick 1131 ) 1132 if let current = cells[position], 1133 current.compareRevision(to: incoming) == .orderedDescending { 1134 continue 1135 } 1136 cells[position] = incoming 1137 changed = true 1138 applied += 1 1139 if clampedUpdatedAt > latest { latest = clampedUpdatedAt } 1140 } 1141 1142 guard changed else { continue } 1143 movesEntity.cells = (try? MovesCodec.encode(cells)) ?? Data() 1144 if (movesEntity.updatedAt ?? .distantPast) < latest { 1145 movesEntity.updatedAt = latest 1146 } 1147 if (entity.updatedAt ?? .distantPast) < latest { 1148 entity.updatedAt = latest 1149 } 1150 touchedGameIDs.insert(sample.gameID) 1151 } 1152 1153 if rejected > 0 { 1154 eventLog?.note( 1155 "GameStore: rejected \(rejected) out-of-grid realtime cell edit(s)", 1156 level: "error" 1157 ) 1158 } 1159 guard applied > 0 else { return 0 } 1160 saveContext("applyRealtimeCellEdits") 1161 if let openID = currentEntity?.id, touchedGameIDs.contains(openID) { 1162 refreshCurrentGame() 1163 } 1164 onUnreadOtherMovesChanged?() 1165 return applied 1166 } 1167 1168 /// Number of shared games with unseen other-author moves — the same 1169 /// `hasUnreadOtherMoves` heuristic the library list uses, aggregated as 1170 /// a count for the app-icon badge. 1171 func unreadOtherMovesGameCount() -> Int { 1172 unreadOtherMovesGameTimes().count 1173 } 1174 1175 /// The same heuristic as `unreadOtherMovesGameCount`, returning the 1176 /// individual game IDs so the App Group `BadgeState` set can be unioned 1177 /// with NSE-added entries. 1178 func unreadOtherMovesGameIDs() -> Set<UUID> { 1179 Set(unreadOtherMovesGameTimes().keys) 1180 } 1181 1182 func hasUnreadOtherMoves(gameID: UUID) -> Bool { 1183 unreadOtherMovesGameIDs().contains(canonicalGameID(for: gameID)) 1184 } 1185 1186 /// The same heuristic as `unreadOtherMovesGameIDs`, but paired with each 1187 /// game's newest unseen other-author move time (`latestOtherMoveAt`, which 1188 /// the predicate guarantees is non-nil). The app seeds these into the App 1189 /// Group `BadgeState` ledger as `unreadAt` horizons so the Notification 1190 /// Service Extension — which can't reach Core Data — inherits this ground 1191 /// truth when it stamps the badge for a push that lands while the app is 1192 /// suspended. The timestamp is what keeps the seed safe: a game the user 1193 /// has since opened carries a newer `seenAt` and won't resurrect. 1194 func unreadOtherMovesGameTimes() -> [UUID: Date] { 1195 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1196 request.predicate = unreadOtherMovesPredicate 1197 request.propertiesToFetch = ["id", "latestOtherMoveAt"] 1198 let rows = (try? context.fetch(request)) ?? [] 1199 var result: [UUID: Date] = [:] 1200 for row in rows { 1201 if let id = row.id, let at = row.latestOtherMoveAt { 1202 let canonicalID = row.ckRecordName.flatMap( 1203 Archive.originalGameID(fromName:) 1204 ) ?? id 1205 result[canonicalID] = max(result[canonicalID] ?? .distantPast, at) 1206 } 1207 } 1208 return result 1209 } 1210 1211 /// Games this account has a pending (un-acted) invite to, excluding invites 1212 /// from blocked collaborators — the same set the library's "Invited" section 1213 /// shows (`GameListView` filters blocked inviters at display time). The app 1214 /// publishes these into `BadgeState` so a pending invite counts toward the 1215 /// app-icon badge. A pending `InviteEntity` is dropped once its `GameEntity` 1216 /// exists, so this set is disjoint from the unread-other-moves set. 1217 func pendingInviteGameIDs() -> Set<UUID> { 1218 let blockedRequest = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 1219 blockedRequest.predicate = NSPredicate(format: "isBlocked == YES") 1220 blockedRequest.propertiesToFetch = ["authorID"] 1221 let blocked = Set(((try? context.fetch(blockedRequest)) ?? []).compactMap(\.authorID)) 1222 1223 let request = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") 1224 request.predicate = NSPredicate(format: "status == %@", "pending") 1225 request.propertiesToFetch = ["gameID", "inviterAuthorID"] 1226 let rows = (try? context.fetch(request)) ?? [] 1227 return Set(rows.compactMap { invite in 1228 guard let id = invite.gameID else { return nil } 1229 if let inviter = invite.inviterAuthorID, blocked.contains(inviter) { return nil } 1230 return id 1231 }) 1232 } 1233 1234 private var unreadOtherMovesPredicate: NSPredicate { 1235 // Keyed off the read *watermark* (`readThroughAt`), not the forward- 1236 // dated presence lease (`lastReadOtherMoveAt`) — matches 1237 // `GameSummary.computeHasUnread`. A shared Chronicle is locally owned 1238 // (`databaseScope == 0`) but still participates after retirement 1239 // deletes its live row; `unreadOtherMovesGameTimes` canonicalizes the 1240 // overlapping rows to one original game ID before counting. 1241 NSPredicate( 1242 format: "(databaseScope == 1 OR ckShareRecordName != nil " 1243 + "OR archiveParticipants != nil) " 1244 + "AND latestOtherMoveAt != nil " 1245 + "AND (readThroughAt == nil OR latestOtherMoveAt > readThroughAt)" 1246 ) 1247 } 1248 1249 // MARK: - Load a specific game 1250 1251 /// Loads a game by its entity ID. Sets it as the current game. 1252 func loadGame(id: UUID) throws -> (Game, GameMutator) { 1253 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1254 request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 1255 request.fetchLimit = 1 1256 1257 guard let entity = try context.fetch(request).first else { 1258 throw LoadError.gameNotFound 1259 } 1260 try upgradeOwnedGameSyncVersionIfNeeded(entity) 1261 let puzzle = try preparePuzzleForLoad(from: entity) 1262 let game = Game(puzzle: puzzle) 1263 restore(game: game, from: entity) 1264 1265 let mutator = makeMutator(game: game, entity: entity) 1266 1267 currentGame = game 1268 currentMutator = mutator 1269 currentEntity = entity 1270 markOtherMovesRead(for: entity) 1271 1272 return (game, mutator) 1273 } 1274 1275 /// The stable identity used by notifications and unread state. A 1276 /// materialized Chronicle has its own Core Data ID so it can coexist with 1277 /// the retained live row, but its record name preserves the original game 1278 /// ID that pushes and Player records use. 1279 func canonicalGameID(for storedGameID: UUID) -> UUID { 1280 guard let entity = fetchGameEntity(id: storedGameID), 1281 let recordName = entity.ckRecordName, 1282 let originalID = Archive.originalGameID(fromName: recordName) 1283 else { return storedGameID } 1284 return originalID 1285 } 1286 1287 /// Whether either persisted representation in a live-game/Chronicle family 1288 /// is terminal. Used only for account-level read receipts: unlike an active 1289 /// puzzle, a completed game cannot gain a later move after a sibling says 1290 /// it has been opened, so the receiving device can safely advance its local 1291 /// Chronicle watermark immediately. 1292 func isCompletedGameFamily(gameID: UUID) -> Bool { 1293 let canonicalID = canonicalGameID(for: gameID) 1294 return readStateEntities(canonicalGameID: canonicalID).contains { 1295 $0.completedAt != nil 1296 } 1297 } 1298 1299 // MARK: - Duplicate detection 1300 1301 /// Returns the ID of an existing game for the same source. Exact source 1302 /// matches win, then catalog resource ID/title matches catch older stored 1303 /// copies of a packaged puzzle. 1304 func findGameID(matching source: String) -> UUID? { 1305 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1306 request.predicate = NSPredicate(format: "puzzleSource == %@", source) 1307 request.fetchLimit = 1 1308 if let exact = try? context.fetch(request).first?.id { 1309 return exact 1310 } 1311 1312 guard let xd = try? XD.parse(source) else { return nil } 1313 let resourceID = PuzzleCatalog.resourceID(matching: source) 1314 let fallback = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1315 if let resourceID { 1316 fallback.predicate = NSPredicate(format: "puzzleResourceID == %@", resourceID) 1317 fallback.fetchLimit = 1 1318 if let match = try? context.fetch(fallback).first?.id { 1319 return match 1320 } 1321 } 1322 1323 guard resourceID != nil, let title = xd.title else { return nil } 1324 fallback.predicate = NSPredicate(format: "title == %@", title) 1325 fallback.fetchLimit = 1 1326 return (try? context.fetch(fallback).first?.id) 1327 } 1328 1329 /// Returns NYT publication dates already present in the local library. 1330 /// Games hidden by the block table are excluded — those aren't something 1331 /// the browser should report back as "you have this" — but a live row 1332 /// superseded by its Chronicle is *not*: the user still holds that puzzle, 1333 /// and whether the pair has been compacted yet is invisible to them. Both 1334 /// rows in a live/Chronicle family carry the same publication date, and 1335 /// the returned `Set` collapses the duplicate. 1336 /// 1337 /// Rows only cover what is currently local, which is live games plus the 1338 /// recently completed: everything older has been compacted into a Chronicle 1339 /// and evicted. `ChronicleLedgerEntity` carries those dates, so the two are 1340 /// unioned — each half derived from its own authority, and the `Set` 1341 /// collapses a date that both report. 1342 /// 1343 /// Fetched as dictionaries rather than managed objects: the only column 1344 /// wanted is a `Date`, and materialising `GameEntity` rows would drag the 1345 /// whole library's `puzzleSource` XD text through the row cache with it. 1346 /// Dictionary results read the store rather than the context, which is 1347 /// safe here because `createGame` saves before it returns. 1348 func nytPuzzleDatesInLibrary() -> Set<Date> { 1349 let request = NSFetchRequest<NSDictionary>(entityName: "GameEntity") 1350 request.resultType = .dictionaryResultType 1351 request.propertiesToFetch = ["cachedPuzzleDate"] 1352 request.returnsDistinctResults = true 1353 request.includesPendingChanges = false 1354 request.predicate = NSPredicate( 1355 format: "isHidden == NO AND cachedPublisher == %@ AND cachedPuzzleDate != nil", 1356 "New York Times" 1357 ) 1358 let rows = (try? context.fetch(request)) ?? [] 1359 var dates = rows.compactMap { $0["cachedPuzzleDate"] as? Date } 1360 dates.append( 1361 contentsOf: ChronicleLedgerEntity.puzzleDates( 1362 publisher: "New York Times", 1363 blockedAuthorIDs: GameEntity.blockedAuthorIDs(in: context), 1364 in: context 1365 ) 1366 ) 1367 1368 var calendar = Calendar(identifier: .gregorian) 1369 calendar.timeZone = TimeZone(identifier: "America/New_York") ?? .gmt 1370 return Set(dates.map { calendar.startOfDay(for: $0) }) 1371 } 1372 1373 /// Returns joined CloudKit-share games that have a usable puzzle payload. 1374 /// Placeholders created from shared-zone discovery are intentionally 1375 /// excluded until the root Game record has arrived. 1376 func joinedSharedGameIDs() -> Set<UUID> { 1377 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1378 request.predicate = NSPredicate( 1379 format: "databaseScope == 1 AND puzzleSource != nil AND puzzleSource != %@", 1380 "" 1381 ) 1382 return Set(((try? context.fetch(request)) ?? []).compactMap(\.id)) 1383 } 1384 1385 // MARK: - Create a new game 1386 1387 /// Creates a new game from XD source text. Returns the new game's UUID. 1388 func createGame(from source: String) throws -> UUID { 1389 let xd = try XD.parse(source) 1390 let puzzle = Puzzle(xd: xd) 1391 1392 let now = Date() 1393 let gameID = UUID() 1394 let entity = GameEntity(context: context) 1395 entity.id = gameID 1396 entity.title = puzzle.title 1397 entity.puzzleSource = source 1398 entity.puzzleParserVersion = Int64(XD.currentParserVersion) 1399 entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: source) 1400 entity.createdAt = now 1401 entity.updatedAt = now 1402 entity.ckRecordName = "game-\(gameID.uuidString)" 1403 entity.ckZoneName = "game-\(gameID.uuidString)" 1404 entity.databaseScope = 0 1405 entity.syncVersion = GameSyncVersion.current 1406 entity.populateCachedSummaryFields(from: puzzle) 1407 1408 try context.save() 1409 onGameCreated("game-\(gameID.uuidString)") 1410 return gameID 1411 } 1412 1413 /// Builds a complete participant game (`databaseScope == 1`) from an 1414 /// invite's serialised XD source, so a freshly-accepted shared game is 1415 /// immediately playable and fully listed without waiting on the shared-zone 1416 /// fetch. Everything derives from the source exactly as `createGame` does; 1417 /// only the zone identity comes from the share. Unlike `createGame` it 1418 /// enqueues no push — the participant doesn't own this zone — and it leaves 1419 /// `ckSystemFields` nil, so the first canonical Game-record sync adopts the 1420 /// server etag and updates this row in place (matched by `ckRecordName` in 1421 /// `RecordSerializer.fetchOrCreate`) rather than creating a duplicate. 1422 /// No-ops if a row for the game already exists — a sibling device or an 1423 /// earlier sync got there first. 1424 func constructJoinedGame( 1425 gameID: UUID, 1426 zoneID: CKRecordZone.ID, 1427 source: String, 1428 notification: String? = nil 1429 ) throws { 1430 // A share's zone always belongs to its owner, so an own-owner zone 1431 // here is a mis-routed identity. Refuse it rather than normalising the 1432 // owner to nil: that spelling means "private zone" everywhere else, and 1433 // a `databaseScope == 1` row carrying it can never be matched by either 1434 // scope's `gameIdentityPredicate` again. The caller falls back to the 1435 // ordinary shared-zone fetch. 1436 guard zoneID.ownerName != CKCurrentUserDefaultName else { 1437 throw LoadError.sharedZoneOwnedByCurrentUser 1438 } 1439 let recordName = "game-\(gameID.uuidString)" 1440 let existing = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1441 existing.predicate = NSPredicate(format: "ckRecordName == %@", recordName) 1442 existing.fetchLimit = 1 1443 if let existingEntity = try? context.fetch(existing).first { 1444 if existingEntity.notification == nil, let notification { 1445 existingEntity.notification = notification 1446 GameEntity.rebuildContentKeyDirectory(in: context) 1447 try context.save() 1448 } 1449 return 1450 } 1451 1452 let xd = try XD.parse(source) 1453 let puzzle = Puzzle(xd: xd) 1454 let now = Date() 1455 let entity = GameEntity(context: context) 1456 entity.id = gameID 1457 entity.title = puzzle.title 1458 entity.puzzleSource = source 1459 entity.puzzleParserVersion = Int64(XD.currentParserVersion) 1460 entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: source) 1461 entity.createdAt = now 1462 entity.updatedAt = now 1463 entity.ckRecordName = recordName 1464 entity.ckZoneName = zoneID.zoneName 1465 entity.ckZoneOwnerName = zoneID.ownerName 1466 entity.databaseScope = 1 1467 entity.syncVersion = GameSyncVersion.legacy 1468 entity.notification = notification 1469 entity.populateCachedSummaryFields(from: puzzle) 1470 if notification != nil { 1471 GameEntity.rebuildContentKeyDirectory(in: context) 1472 } 1473 1474 try context.save() 1475 } 1476 1477 // MARK: - Delete a game 1478 1479 func deleteGame(id: UUID) throws { 1480 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1481 request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 1482 request.fetchLimit = 1 1483 1484 guard let entity = try context.fetch(request).first else { return } 1485 1486 let materializedOriginalID = entity.ckRecordName.flatMap( 1487 Archive.originalGameID(fromName:) 1488 ) 1489 let originalGameID = materializedOriginalID ?? id 1490 let isMaterializedArchive = materializedOriginalID != nil 1491 let hasArchive = isMaterializedArchive || entity.archivedAt != nil 1492 let deletion = GameCloudDeletion( 1493 gameID: id, 1494 databaseScope: DatabaseScope(entityValue: entity.databaseScope), 1495 ckZoneName: entity.ckZoneName ?? "game-\(id.uuidString)", 1496 ckZoneOwnerName: entity.ckZoneOwnerName ?? CKCurrentUserDefaultName, 1497 deletesLiveZone: !isMaterializedArchive, 1498 archiveRecordName: hasArchive 1499 ? Archive.recordName(forOriginalGameID: originalGameID) 1500 : nil, 1501 legacyArchiveZoneName: hasArchive 1502 ? Archive.legacyZoneID(forOriginalGameID: originalGameID).zoneName 1503 : nil 1504 ) 1505 1506 // Clear current references if this is the active game 1507 if currentEntity?.id == id { 1508 currentGame = nil 1509 currentMutator = nil 1510 currentEntity = nil 1511 } 1512 1513 // The archive record goes with the game, so its index entry goes too. 1514 // Deletions made on another device are caught by the reconcile instead. 1515 if hasArchive { 1516 ChronicleLedgerEntity.remove(originalGameID: originalGameID, in: context) 1517 } 1518 1519 context.delete(entity) 1520 try context.save() 1521 onGameDeleted(deletion) 1522 onPushRegistrationMayNeedRefresh?() 1523 onUnreadOtherMovesChanged?() 1524 } 1525 1526 // MARK: - Resign a game 1527 1528 /// Reveals all cells and marks the game as completed (resigned). A 1529 /// revoked participant cannot resign: that would turn the read-only 1530 /// revoked copy into a terminal completed game. 1531 func resignGame(id: UUID) throws { 1532 guard let existing = fetchGameEntity(id: id), 1533 !existing.isAccessRevoked, 1534 GameSyncVersion.supports(existing.syncVersion) 1535 else { return } 1536 let (game, mutator) = try loadGame(id: id) 1537 let allCells = game.puzzle.cells.flatMap { $0 } 1538 mutator.revealCells(allCells) 1539 1540 guard let entity = currentEntity else { return } 1541 let completedAt = Date() 1542 entity.completedAt = completedAt 1543 markObservedCompletionRead(for: entity, at: completedAt) 1544 // Resignation: no solver, so `completedBy` stays nil — that's how a 1545 // resigned game is told apart from a win. 1546 entity.completedBy = nil 1547 entity.hasPendingSave = true 1548 try context.save() 1549 if let ckName = entity.ckRecordName { 1550 onGameUpdated(ckName) 1551 } 1552 Task { await movesUpdater.flush() } 1553 triggerJournalUpload(id: id, resigned: true, notifyPeers: true) 1554 1555 // Clean up current references 1556 currentGame = nil 1557 currentMutator = nil 1558 currentEntity = nil 1559 } 1560 1561 /// Rewrites every local row authored under a device-local fallback identity 1562 /// to the resolved iCloud author id. Offline play stamps a `local-<UUID>` 1563 /// author so writes can persist without an account (the moves/cell layers 1564 /// refuse to flush without one); when the user signs in, this realigns that 1565 /// data — including the author-embedding CloudKit record names — before the 1566 /// sync engine ever pushes it. Safe precisely because fallback-authored rows 1567 /// were never synced: no account existed, so no server record predates them. 1568 func remapAuthorID(from oldID: String, to newID: String) { 1569 guard oldID != newID, !oldID.isEmpty, !newID.isEmpty else { return } 1570 1571 func fetch<T: NSManagedObject>(_ entityName: String, where predicate: NSPredicate) -> [T] { 1572 let request = NSFetchRequest<T>(entityName: entityName) 1573 request.predicate = predicate 1574 return (try? context.fetch(request)) ?? [] 1575 } 1576 1577 let matchesOld = NSPredicate(format: "authorID == %@", oldID) 1578 1579 // Plain author fields — no author in their record name. 1580 for game: GameEntity in fetch("GameEntity", where: NSPredicate(format: "completedBy == %@", oldID)) { 1581 game.completedBy = newID 1582 } 1583 for cell: CellEntity in fetch("CellEntity", where: NSPredicate(format: "letterAuthorID == %@", oldID)) { 1584 cell.letterAuthorID = newID 1585 } 1586 for entry: JournalEntity in fetch("JournalEntity", where: NSPredicate( 1587 format: "actingAuthorID == %@ OR cellAuthorID == %@ OR beforeCellAuthorID == %@ OR sourceAuthorID == %@", 1588 oldID, oldID, oldID, oldID 1589 )) { 1590 if entry.actingAuthorID == oldID { entry.actingAuthorID = newID } 1591 if entry.cellAuthorID == oldID { entry.cellAuthorID = newID } 1592 if entry.beforeCellAuthorID == oldID { entry.beforeCellAuthorID = newID } 1593 if entry.sourceAuthorID == oldID { entry.sourceAuthorID = newID } 1594 } 1595 1596 // Author-embedded record names — rewrite the name and drop any cached 1597 // system fields so the row pushes fresh under the real id. 1598 for moves: MovesEntity in fetch("MovesEntity", where: matchesOld) { 1599 moves.authorID = newID 1600 if let gameID = moves.game?.id, let deviceID = moves.deviceID { 1601 moves.ckRecordName = RecordSerializer.recordName( 1602 forMovesInGame: gameID, authorID: newID, deviceID: deviceID 1603 ) 1604 moves.ckSystemFields = nil 1605 } 1606 } 1607 for player: PlayerEntity in fetch("PlayerEntity", where: matchesOld) { 1608 player.authorID = newID 1609 if let gameID = player.game?.id { 1610 player.ckRecordName = RecordSerializer.recordName( 1611 forPlayerInGame: gameID, authorID: newID 1612 ) 1613 player.ckSystemFields = nil 1614 } 1615 } 1616 1617 saveContext("remapAuthorID") 1618 } 1619 1620 /// Marks a game as completed after a normal win. Returns whether the 1621 /// entity changed; no-ops if already marked. 1622 /// Triggers a buffer flush so the completion snapshot is created promptly 1623 /// rather than waiting for the next keystroke or app-background event. 1624 @discardableResult 1625 func markCompleted(id: UUID) throws -> Bool { 1626 try persistCompletion(id: id, completedBy: authorIDProvider(), notifyPeers: true) 1627 } 1628 1629 /// Marks a game completed after the visible grid became solved through 1630 /// observed state, e.g. a collaborator's realtime edit. Uses the writer of 1631 /// the latest winning cell as the solver when provenance is available. 1632 @discardableResult 1633 func markCompletedFromObservedSolvedState(id: UUID) throws -> Bool { 1634 let solver = inferredObservedCompletionAuthorID(for: id) ?? authorIDProvider() 1635 return try persistCompletion(id: id, completedBy: solver, notifyPeers: false) 1636 } 1637 1638 @discardableResult 1639 private func persistCompletion( 1640 id: UUID, 1641 completedBy authorID: String?, 1642 notifyPeers: Bool 1643 ) throws -> Bool { 1644 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1645 request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 1646 request.fetchLimit = 1 1647 guard let entity = try context.fetch(request).first, 1648 entity.completedAt == nil, 1649 // A revoked participant can't legitimately finish the game, so 1650 // never latch a revoked copy into a terminal completed state. 1651 !entity.isAccessRevoked, 1652 GameSyncVersion.supports(entity.syncVersion) 1653 else { return false } 1654 let completedAt = Date() 1655 entity.completedAt = completedAt 1656 markObservedCompletionRead(for: entity, at: completedAt) 1657 // A win: stamp the solver so the Game record can distinguish wins 1658 // from resignations and the completion APN body can name them. 1659 entity.completedBy = authorID 1660 entity.hasPendingSave = true 1661 try context.save() 1662 // The game is now terminal, so its peer-change ledger is dead weight. 1663 // The writer drops the rows once it sees completedAt — schedule it. 1664 enqueuePeerChangeLedgerUpdate(for: [id]) 1665 // Lock the open session immediately so no further input lands on the 1666 // now-terminal game (the view also reflects this via `isSolved`). 1667 if currentEntity?.id == id { 1668 currentMutator?.isCompleted = true 1669 } 1670 if let ckName = entity.ckRecordName { 1671 onGameUpdated(ckName) 1672 } 1673 Task { await movesUpdater.flush() } 1674 triggerJournalUpload(id: id, resigned: false, notifyPeers: notifyPeers) 1675 return true 1676 } 1677 1678 /// Seals the read watermark when this device observes a game becoming 1679 /// terminal. Every legitimate move in the finished grid precedes this 1680 /// instant, so a collaborator's final Moves snapshot may arrive after the 1681 /// user leaves without turning the game unread again. A device that learns 1682 /// about completion later through sync does not take this path and still 1683 /// surfaces the completed game as unread. 1684 private func markObservedCompletionRead(for entity: GameEntity, at completedAt: Date) { 1685 let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 1686 guard isShared else { return } 1687 if (entity.readThroughAt ?? .distantPast) < completedAt { 1688 entity.readThroughAt = completedAt 1689 } 1690 mirrorReadStateToChronicle(from: entity) 1691 } 1692 1693 /// Signals that a just-completed game's move journal should be uploaded 1694 /// (Phase 2). Fired synchronously on the main actor at completion so the 1695 /// app layer can take a background-execution assertion *before* any 1696 /// suspension point — the flush and CKSyncEngine enqueue then run under it 1697 /// via `flushJournal()`. Attributed to the local user (not the solver) — a 1698 /// resigner still has a log to upload. 1699 private func triggerJournalUpload(id: UUID, resigned: Bool, notifyPeers: Bool) { 1700 guard let authorID = authorIDProvider(), !authorID.isEmpty else { return } 1701 onJournalComplete?(id, authorID, resigned, notifyPeers) 1702 } 1703 1704 /// Drains the journal's async persistence queue so the upload's record 1705 /// builder, reading Core Data on its own context, sees every entry. Called 1706 /// by the app-layer upload path under its background assertion. 1707 func flushJournal() async { 1708 await movesJournal.flush() 1709 } 1710 1711 /// Drains both the cell-write buffer and the journal queue so a reader on a 1712 /// fresh background context sees the *finished* grid and this device's full 1713 /// local log — rather than buffered-but-unpersisted state. The completion 1714 /// archive snapshots Core Data directly, so it must run after this; the 1715 /// winning move in particular is still in flight when `persistCompletion` 1716 /// returns. 1717 func flushCompletionWrites() async { 1718 await movesUpdater.flush() 1719 await movesJournal.flush() 1720 } 1721 1722 /// This device's live journal for a game, tagged with its device key. The 1723 /// replay assembler overlays this over any uploaded copy of ourselves: the 1724 /// in-memory log is the session's authoritative copy and may be fresher than 1725 /// what's round-tripped to CloudKit. `nil` until the local author is known. 1726 func localReplaySource(gameID: UUID) -> DeviceJournal? { 1727 guard let authorID = authorIDProvider(), !authorID.isEmpty else { return nil } 1728 let key = JournalDeviceKey(authorID: authorID, deviceID: RecordSerializer.localDeviceID) 1729 return DeviceJournal(key: key, entries: movesJournal.recordedEntries(gameID: gameID)) 1730 } 1731 1732 /// This device's journal entries for a game, independent of iCloud identity. 1733 /// The journal is recorded locally as the player types, so it exists even 1734 /// with no signed-in account — the local replay path uses this directly 1735 /// rather than `localReplaySource`, which needs an authorID to form a key. 1736 func localJournalEntries(for gameID: UUID) -> [JournalValue] { 1737 movesJournal.recordedEntries(gameID: gameID) 1738 } 1739 1740 /// A Chronicle with no embedded journals is either still waiting for its 1741 /// live zone to collect every device's history, or is the terminal fallback 1742 /// written when that retry window expires. 1743 func archivedReplayBlocker(forGameID gameID: UUID) async -> JournalReplayResult? { 1744 let ctx = persistence.container.newBackgroundContext() 1745 return await ctx.perform { 1746 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1747 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1748 req.fetchLimit = 1 1749 guard let game = try? ctx.fetch(req).first else { return nil } 1750 if game.replayUnavailable { return .unavailable } 1751 if let missing = game.replayMissingDeviceCount?.intValue, missing > 0 { 1752 return .waiting(missing: missing) 1753 } 1754 return nil 1755 } 1756 } 1757 1758 /// Other devices' journals cached locally for replay, grouped by source 1759 /// device — or `nil` if this game's cache isn't known-complete yet 1760 /// (`replayCacheComplete`), in which case the caller must fetch from 1761 /// CloudKit. These are stored as `JournalEntity` rows carrying a source key 1762 /// (`sourceDeviceID != nil`), kept out of this device's own log. A finished 1763 /// game's journals never change, so once cached they replay offline; the 1764 /// live local journal is overlaid separately by `localReplaySource`, so this 1765 /// deliberately excludes any cached copy of ourselves and may be empty (a 1766 /// solo game has no remote contributors but is still "complete"). 1767 func cachedRemoteJournals(forGameID gameID: UUID) async -> [DeviceJournal]? { 1768 let ctx = persistence.container.newBackgroundContext() 1769 return await ctx.perform { 1770 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1771 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1772 gameReq.fetchLimit = 1 1773 guard let game = try? ctx.fetch(gameReq).first, game.replayCacheComplete else { 1774 return nil 1775 } 1776 let req = NSFetchRequest<JournalEntity>(entityName: "JournalEntity") 1777 req.predicate = NSPredicate( 1778 format: "gameID == %@ AND sourceDeviceID != nil", gameID as CVarArg 1779 ) 1780 req.sortDescriptors = [NSSortDescriptor(key: "seq", ascending: true)] 1781 let rows = (try? ctx.fetch(req)) ?? [] 1782 var byDevice: [JournalDeviceKey: [JournalValue]] = [:] 1783 for row in rows { 1784 let key = JournalDeviceKey( 1785 authorID: row.sourceAuthorID ?? "", 1786 deviceID: row.sourceDeviceID ?? "" 1787 ) 1788 byDevice[key, default: []].append(MovesJournal.value(from: row)) 1789 } 1790 return byDevice.map { DeviceJournal(key: $0.key, entries: $0.value) } 1791 } 1792 } 1793 1794 /// Persists `journals` (other devices' logs) as this game's replay cache and 1795 /// marks it `replayCacheComplete`, so later opens replay from Core Data with 1796 /// no CloudKit round-trip. Safe because a completed game's journals are 1797 /// frozen by edit-lockout. Idempotent: replaces any existing cached rows for 1798 /// the game. Rows carry a source key so the local-log readers skip them. 1799 func cacheRemoteJournals(_ journals: [DeviceJournal], forGameID gameID: UUID) async { 1800 let ctx = persistence.container.newBackgroundContext() 1801 let eventLog = eventLog 1802 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 1803 await ctx.perform { 1804 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1805 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1806 gameReq.fetchLimit = 1 1807 guard let game = try? ctx.fetch(gameReq).first else { return } 1808 1809 let stale = NSFetchRequest<JournalEntity>(entityName: "JournalEntity") 1810 stale.predicate = NSPredicate( 1811 format: "gameID == %@ AND sourceDeviceID != nil", gameID as CVarArg 1812 ) 1813 for row in (try? ctx.fetch(stale)) ?? [] { ctx.delete(row) } 1814 1815 // Remote journal entries are peer-controlled; the codec already 1816 // dropped Int16-unrepresentable coordinates, and this skips 1817 // anything outside the recorded grid before it becomes a cached 1818 // replay row. 1819 var skippedOutOfGrid = 0 1820 for journal in journals { 1821 for value in journal.entries { 1822 guard value.position.isPersistable( 1823 gridWidth: game.gridWidth, 1824 gridHeight: game.gridHeight 1825 ) else { 1826 skippedOutOfGrid += 1 1827 continue 1828 } 1829 let row = JournalEntity(context: ctx) 1830 MovesJournal.assign(value, to: row, gameID: gameID) 1831 row.sourceAuthorID = journal.key.authorID 1832 row.sourceDeviceID = journal.key.deviceID 1833 row.game = game 1834 } 1835 } 1836 if skippedOutOfGrid > 0 { 1837 let message = "GameStore: replay cache skipped \(skippedOutOfGrid) " 1838 + "out-of-grid remote journal entr(ies)" 1839 Task { @MainActor in 1840 eventLog?.note(message, level: "error") 1841 } 1842 } 1843 game.replayCacheComplete = true 1844 do { 1845 try ctx.save() 1846 } catch { 1847 let message = "GameStore: replay cache save failed — \(error)" 1848 Task { @MainActor in 1849 eventLog?.note(message, level: "error") 1850 } 1851 } 1852 } 1853 } 1854 1855 // MARK: - Engagement room 1856 1857 /// `true` once `gameID` has been completed (solved or resigned). A 1858 /// completed game is no longer a live collaborative session, so engagement 1859 /// is torn down and peer cursors are suppressed for it. 1860 func isCompleted(gameID: UUID) -> Bool { 1861 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1862 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1863 request.fetchLimit = 1 1864 return (try? context.fetch(request).first)?.completedAt != nil 1865 } 1866 1867 /// The shared live-engagement room creds for `gameID` (an encoded 1868 /// `EngagementRoomCredentials`), or nil if none has been minted yet. 1869 func engagement(for gameID: UUID) -> String? { 1870 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1871 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1872 request.fetchLimit = 1 1873 return (try? context.fetch(request).first)?.engagement 1874 } 1875 1876 /// Writes the engagement room creds for `gameID` and enqueues a Game-record 1877 /// push. No-op (returns false) when unchanged or the game isn't shared. 1878 /// Sets `hasPendingSave` so an inbound Game record can't clobber freshly 1879 /// minted creds before the push lands; record-level LWW then converges any 1880 /// concurrent mint by another participant. 1881 @discardableResult 1882 func setEngagement(_ encoded: String?, for gameID: UUID) -> Bool { 1883 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1884 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1885 request.fetchLimit = 1 1886 guard let entity = try? context.fetch(request).first else { return false } 1887 let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 1888 guard isShared, entity.engagement != encoded else { return false } 1889 entity.engagement = encoded 1890 entity.hasPendingSave = true 1891 saveContext("setEngagement") 1892 if let ckName = entity.ckRecordName { 1893 onGameUpdated(ckName) 1894 } 1895 return true 1896 } 1897 1898 /// The shared per-game push credential for `gameID` (an encoded 1899 /// `GamePushCredentials`), or nil if none has been minted yet. 1900 func notification(for gameID: UUID) -> String? { 1901 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1902 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1903 request.fetchLimit = 1 1904 return (try? context.fetch(request).first)?.notification 1905 } 1906 1907 /// Writes the notification credentials for `gameID` and enqueues a 1908 /// Game-record push, mirroring `setEngagement`. On a real change also 1909 /// re-mirrors the App Group content-key directory the NSE reads (the blob 1910 /// carries the content key). No-op (false) when unchanged or not shared. 1911 @discardableResult 1912 func setNotification(_ encoded: String?, for gameID: UUID) -> Bool { 1913 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1914 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1915 request.fetchLimit = 1 1916 guard let entity = try? context.fetch(request).first else { return false } 1917 let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 1918 guard isShared, entity.notification != encoded else { return false } 1919 entity.notification = encoded 1920 entity.hasPendingSave = true 1921 saveContext("setNotification") 1922 GameEntity.rebuildContentKeyDirectory(in: context) 1923 if let ckName = entity.ckRecordName { 1924 onGameUpdated(ckName) 1925 } 1926 return true 1927 } 1928 1929 /// Returns the shared notification credentials for `gameID`, minting and 1930 /// persisting a fresh one (and enqueuing the Game-record push, so 1931 /// participants converge) when the game is shared and none exists yet. A 1932 /// legacy credential minted before content keys existed is backfilled with a 1933 /// fresh one in place, preserving its `credID`/`secret` so worker 1934 /// registration stays valid. Any participant may mint; record-level LWW 1935 /// resolves concurrent mints. Returns nil for a non-shared or missing game, 1936 /// or if minting fails. 1937 @discardableResult 1938 func ensurePushCredentials(for gameID: UUID) -> GamePushCredentials? { 1939 if var existing = GamePushCredentials.decode(notification(for: gameID)) { 1940 if existing.contentKey != nil { return existing } 1941 // Backfill a content key onto a legacy credential, keeping its auth 1942 // material so the worker registration is unaffected. 1943 guard let key = try? GamePushCredentials.freshContentKey() else { return existing } 1944 existing.contentKey = key 1945 guard let encoded = try? existing.encoded(), setNotification(encoded, for: gameID) 1946 else { return existing } 1947 return existing 1948 } 1949 guard let fresh = try? GamePushCredentials.fresh(), 1950 let encoded = try? fresh.encoded(), 1951 setNotification(encoded, for: gameID) 1952 else { return nil } 1953 return fresh 1954 } 1955 1956 /// Replaces the game's push credentials wholesale — new `credID`, worker 1957 /// secret, and content key at the next rotation generation — because a 1958 /// participant left or was removed. The departed device holds every field 1959 /// of the old credential, so only full replacement revokes its ability to 1960 /// receive, publish, or re-subscribe; the generation is what stops a stale 1961 /// device's Game-record re-push from resurrecting the old one. Any 1962 /// remaining device may rotate; record-level LWW converges concurrent 1963 /// rotations. No-op for a game with no credentials yet (nothing to revoke) 1964 /// — `ensurePushCredentials` will mint at the current roster on demand. 1965 @discardableResult 1966 func rotatePushCredentials(for gameID: UUID) -> GamePushCredentials? { 1967 guard let current = GamePushCredentials.decode(notification(for: gameID)), 1968 let fresh = try? GamePushCredentials.rotated(after: current), 1969 let encoded = try? fresh.encoded(), 1970 setNotification(encoded, for: gameID) 1971 else { return nil } 1972 return fresh 1973 } 1974 1975 /// Whether any game rows remain in the local store — the cheap, offline 1976 /// signal that this build carried data over from a previous CloudKit 1977 /// generation (used by the v3→v4 transition ahead of any network probe). 1978 func hasAnyGames() -> Bool { 1979 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1980 return ((try? context.count(for: request)) ?? 0) > 0 1981 } 1982 1983 // MARK: - Reset 1984 1985 /// Deletes every game (and its cascaded moves, snapshots, and cells) plus 1986 /// the sync-state row. Used by the diagnostics reset button and by the 1987 /// account-switch purge (`CloudService.purgeLocalData`). 1988 func resetAllData() throws { 1989 for entity in try context.fetch(NSFetchRequest<GameEntity>(entityName: "GameEntity")) { 1990 context.delete(entity) 1991 } 1992 for entity in try context.fetch(NSFetchRequest<SyncStateEntity>(entityName: "SyncStateEntity")) { 1993 context.delete(entity) 1994 } 1995 // Friend zones themselves are removed by CloudService.resetAllData's 1996 // wholesale private-zone delete / shared-zone leave; clear the local 1997 // friendship + invite rows so a reset is a clean slate. 1998 for entity in try context.fetch(NSFetchRequest<FriendEntity>(entityName: "FriendEntity")) { 1999 context.delete(entity) 2000 } 2001 for entity in try context.fetch(NSFetchRequest<InviteEntity>(entityName: "InviteEntity")) { 2002 context.delete(entity) 2003 } 2004 // Replay journals are keyed to games; once every game is gone they are 2005 // orphaned and (on an account switch) hold the previous account's solve 2006 // history, so clear them too. 2007 for entity in try context.fetch(NSFetchRequest<JournalEntity>(entityName: "JournalEntity")) { 2008 context.delete(entity) 2009 } 2010 try context.save() 2011 currentGame = nil 2012 currentMutator = nil 2013 currentEntity = nil 2014 onUnreadOtherMovesChanged?() 2015 } 2016 2017 // MARK: - Legacy convenience 2018 2019 /// Returns the single current game and its mutator, creating from 2020 /// `sample.xd` on first launch. Subsequent launches rehydrate the 2021 /// in-memory `Game` from the stored `CellEntity` rows so any prior 2022 /// progress is restored. 2023 func loadOrCreateCurrentGame() throws -> (Game, GameMutator) { 2024 let entity: GameEntity 2025 let puzzle: Puzzle 2026 2027 if let existing = try fetchCurrentEntity() { 2028 entity = existing 2029 try upgradeOwnedGameSyncVersionIfNeeded(existing) 2030 puzzle = try preparePuzzleForLoad(from: existing) 2031 } else { 2032 (entity, puzzle) = try seedFromSample() 2033 } 2034 2035 let game = Game(puzzle: puzzle) 2036 restore(game: game, from: entity) 2037 2038 let mutator = makeMutator(game: game, entity: entity) 2039 2040 currentGame = game 2041 currentMutator = mutator 2042 currentEntity = entity 2043 markOtherMovesRead(for: entity) 2044 2045 return (game, mutator) 2046 } 2047 2048 // MARK: - Loading 2049 2050 private func fetchCurrentEntity() throws -> GameEntity? { 2051 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2052 request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)] 2053 request.fetchLimit = 1 2054 return try context.fetch(request).first 2055 } 2056 2057 /// Applies this account's `Player.presenceUntil` from a sibling device under 2058 /// last-writer-wins: SyncEngine has already accepted this record as the 2059 /// freshest server version, so adopt the value directly as the account's 2060 /// resolved read horizon. A leaving sibling can pull the horizon back below 2061 /// an active sibling's future lease, but that collapse is bounded and 2062 /// self-healing: the still-present device re-asserts its lease as soon as it 2063 /// processes the inbound close (`AppServices`'s incoming-cursor drain), and a 2064 /// foreground device marks inbound peer moves read on arrival regardless. 2065 /// Representing "A left while C is still here" without that collapse would 2066 /// require per-device Player rows, which do not exist (one row per author). 2067 /// Returns the cursor value that was in place before the write and whether 2068 /// the inbound value was actually adopted, so callers can log the 2069 /// adoption — cross-device cursor convergence is otherwise invisible in 2070 /// the device log. 2071 @discardableResult 2072 func noteIncomingReadCursor(gameID: UUID, presenceUntil: Date) -> (previous: Date?, adopted: Bool) { 2073 let previous = fetchGameEntity(id: gameID)?.lastReadOtherMoveAt 2074 let adopted = setReadCursor(gameID: gameID, presenceUntil: presenceUntil) 2075 return (previous, adopted) 2076 } 2077 2078 /// Sets the per-account **presence lease** for `gameID` — the forward-dated 2079 /// "the user is actively present on this puzzle" horizon stored in 2080 /// `lastReadOtherMoveAt`. When `minimumExistingPresenceUntil` is provided, the 2081 /// write is skipped if the current lease already reaches that floor; active 2082 /// sessions use this to refresh a future lease only when it is close to 2083 /// expiry. 2084 /// 2085 /// This is *not* the read watermark — see `advanceReadThrough`. The two were 2086 /// historically the same field, which is why `lastReadOtherMoveAt` ships on 2087 /// the wire as the `presenceUntil` CKRecord field (renamed from `readAt` in 2088 /// the v4 schema). 2089 /// TODO: the local `lastReadOtherMoveAt` Core Data attribute still carries 2090 /// the misleading old name — an internal-only rename to match `presenceUntil` 2091 /// was deferred (it collides with unrelated identifiers and the store is 2092 /// wiped on the v4 transition anyway, so it has no external effect). 2093 @discardableResult 2094 func setReadCursor( 2095 gameID: UUID, 2096 presenceUntil: Date, 2097 minimumExistingPresenceUntil: Date? = nil 2098 ) -> Bool { 2099 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2100 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 2101 request.fetchLimit = 1 2102 guard let entity = try? context.fetch(request).first else { return false } 2103 let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 2104 guard isShared else { return false } 2105 if let minimumExistingPresenceUntil, 2106 let current = entity.lastReadOtherMoveAt, 2107 current >= minimumExistingPresenceUntil { 2108 return false 2109 } 2110 guard entity.lastReadOtherMoveAt != presenceUntil else { return false } 2111 entity.lastReadOtherMoveAt = presenceUntil 2112 saveContext("updatePresenceUntil") 2113 onUnreadOtherMovesChanged?() 2114 return true 2115 } 2116 2117 /// Advances the per-account **read watermark** (`readThroughAt`) to 2118 /// `through`, monotonically — the latest other-author move time this 2119 /// account has actually observed. Unlike the presence lease it is never 2120 /// forward-dated, so a peer computing what we've seen (and our own unread 2121 /// badge) never credits us with moves made after we stopped looking. 2122 /// Returns `true` if the watermark moved. 2123 @discardableResult 2124 func advanceReadThrough(gameID: UUID, through: Date) -> Bool { 2125 let canonicalID = canonicalGameID(for: gameID) 2126 let entities = readStateEntities(canonicalGameID: canonicalID) 2127 guard entities.contains(where: { 2128 $0.ckShareRecordName != nil 2129 || $0.databaseScope == 1 2130 || isArchivedSharedGame($0) 2131 }) else { return false } 2132 var changed = false 2133 for entity in entities where (entity.readThroughAt ?? .distantPast) < through { 2134 entity.readThroughAt = through 2135 changed = true 2136 } 2137 guard changed else { return false } 2138 saveContext("advanceReadThrough") 2139 onUnreadOtherMovesChanged?() 2140 return true 2141 } 2142 2143 private func markOtherMovesRead(for entity: GameEntity) { 2144 guard let storedID = entity.id else { return } 2145 let canonicalID = canonicalGameID(for: storedID) 2146 let entities = readStateEntities(canonicalGameID: canonicalID) 2147 let isShared = entities.contains { 2148 $0.ckShareRecordName != nil 2149 || $0.databaseScope == 1 2150 || isArchivedSharedGame($0) 2151 } 2152 guard isShared, 2153 let latest = entities.compactMap(\.latestOtherMoveAt).max() 2154 else { return } 2155 // Advances the *read watermark* (`readThroughAt`), not the presence 2156 // lease (`lastReadOtherMoveAt`). Opening the game means the user has now 2157 // seen every other-author move up to `latest`; the lease is a separate, 2158 // forward-dated "actively present" horizon owned by `setReadCursor`. 2159 var changed = false 2160 for candidate in entities where (candidate.readThroughAt ?? .distantPast) < latest { 2161 candidate.readThroughAt = latest 2162 changed = true 2163 } 2164 guard changed else { return } 2165 saveContext("markOtherMovesRead") 2166 onUnreadOtherMovesChanged?() 2167 } 2168 2169 private func readStateEntities(canonicalGameID: UUID) -> [GameEntity] { 2170 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2171 request.predicate = NSPredicate( 2172 format: "id IN %@", 2173 [ 2174 canonicalGameID, 2175 Archive.archiveGameID(for: canonicalGameID) 2176 ] 2177 ) 2178 return (try? context.fetch(request)) ?? [] 2179 } 2180 2181 private func mirrorReadStateToChronicle(from live: GameEntity) { 2182 guard let liveID = live.id else { return } 2183 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2184 request.predicate = NSPredicate( 2185 format: "id == %@", 2186 Archive.archiveGameID(for: liveID) as CVarArg 2187 ) 2188 request.fetchLimit = 1 2189 guard let chronicle = try? context.fetch(request).first else { return } 2190 Archive.mirrorReadState(from: live, to: chronicle) 2191 } 2192 2193 private func seedFromSample() throws -> (GameEntity, Puzzle) { 2194 guard let url = Bundle.main.resourceURL? 2195 .appendingPathComponent("Puzzles/debug/sample.xd") else { 2196 throw LoadError.sampleResourceMissing 2197 } 2198 let source = try String(contentsOf: url, encoding: .utf8) 2199 let xd = try XD.parse(source) 2200 let puzzle = Puzzle(xd: xd) 2201 2202 let now = Date() 2203 let gameID = UUID() 2204 let entity = GameEntity(context: context) 2205 entity.id = gameID 2206 entity.title = puzzle.title 2207 entity.puzzleSource = source 2208 entity.puzzleParserVersion = Int64(XD.currentParserVersion) 2209 entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: source) 2210 entity.createdAt = now 2211 entity.updatedAt = now 2212 entity.ckRecordName = "game-\(gameID.uuidString)" 2213 entity.ckZoneName = "game-\(gameID.uuidString)" 2214 entity.databaseScope = 0 2215 entity.syncVersion = GameSyncVersion.current 2216 entity.populateCachedSummaryFields(from: puzzle) 2217 2218 try context.save() 2219 onGameCreated("game-\(gameID.uuidString)") 2220 return (entity, puzzle) 2221 } 2222 2223 private func preparePuzzleForLoad(from entity: GameEntity) throws -> Puzzle { 2224 guard let source = entity.puzzleSource else { 2225 throw LoadError.persistedSourceMissing 2226 } 2227 2228 let currentVersion = Int64(XD.currentParserVersion) 2229 if entity.puzzleParserVersion != currentVersion { 2230 let catalogSource = PuzzleCatalog.source( 2231 matchingResourceID: entity.puzzleResourceID, 2232 title: try? XD.parse(source).title 2233 ) 2234 let nextSource = (catalogSource.flatMap { try? $0.loadSource() }) ?? source 2235 let nextXD = try XD.parse(nextSource) 2236 let puzzle = Puzzle(xd: nextXD) 2237 entity.title = puzzle.title 2238 entity.puzzleSource = nextSource 2239 entity.puzzleParserVersion = currentVersion 2240 if let catalogSource { 2241 entity.puzzleResourceID = catalogSource.id 2242 } else if entity.puzzleResourceID == nil { 2243 entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: nextSource) 2244 } 2245 entity.populateCachedSummaryFields(from: puzzle) 2246 try context.save() 2247 return puzzle 2248 } 2249 2250 if entity.puzzleResourceID == nil { 2251 entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: source) 2252 if context.hasChanges { try context.save() } 2253 } 2254 2255 return Puzzle(xd: try XD.parse(source)) 2256 } 2257 2258 /// Minimal read snapshot of a game's persisted puzzle metadata, sized for 2259 /// out-of-store decision logic (e.g. whether to run a converter upgrade) 2260 /// without exposing the underlying `GameEntity`. 2261 struct PuzzleInfo: Sendable { 2262 let gameID: UUID 2263 let source: String 2264 let isOwned: Bool 2265 } 2266 2267 func puzzleInfo(for id: UUID) -> PuzzleInfo? { 2268 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2269 request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 2270 request.fetchLimit = 1 2271 guard let entity = try? context.fetch(request).first, 2272 let source = entity.puzzleSource 2273 else { return nil } 2274 return PuzzleInfo( 2275 gameID: id, 2276 source: source, 2277 // Completed puzzles are immutable history. In particular, updating 2278 // a Chronicle's disposable local projection would be undone by its 2279 // next materialisation. 2280 isOwned: entity.databaseScope == 0 2281 && entity.completedAt == nil 2282 && !isMaterializedArchive(entity) 2283 ) 2284 } 2285 2286 /// Persists `viewedAt` onto this account's own `Player.viewedAt` for 2287 /// `gameID` — the "last viewed" cutoff, shipped on the Player record so 2288 /// sibling devices adopt it rather than recomputing from their own view. 2289 /// Creates a stub PlayerEntity if none exists yet, keyed by the 2290 /// deterministic `ckRecordName`. No-op if the GameEntity is missing. 2291 func setViewedAt(_ viewedAt: Date?, gameID: UUID, authorID: String) { 2292 let entity: PlayerEntity 2293 if let existing = fetchPlayerEntity(gameID: gameID, authorID: authorID) { 2294 entity = existing 2295 } else { 2296 let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2297 gameRequest.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 2298 gameRequest.fetchLimit = 1 2299 guard let game = try? context.fetch(gameRequest).first else { return } 2300 entity = PlayerEntity(context: context) 2301 entity.game = game 2302 entity.authorID = authorID 2303 entity.ckRecordName = RecordSerializer.recordName( 2304 forPlayerInGame: gameID, 2305 authorID: authorID 2306 ) 2307 entity.updatedAt = Date() 2308 } 2309 entity.viewedAt = viewedAt 2310 saveContext("setViewedAt") 2311 } 2312 2313 // MARK: - Solve-time clock 2314 2315 /// Opens a solve session for the local device on `gameID` (idempotent across 2316 /// resumes within one sitting). `reconcileStale` — set on the first open of 2317 /// this game since launch — banks a session left dangling by a previous 2318 /// run's crash rather than counting the dead gap. Returns `true` if the 2319 /// stored log changed, so the caller can decide whether to enqueue a sync. 2320 @discardableResult 2321 func openClockSession( 2322 gameID: UUID, 2323 authorID: String, 2324 reconcileStale: Bool = false, 2325 at now: Date = Date() 2326 ) -> Bool { 2327 mutateTimeLog(gameID: gameID, authorID: authorID) { 2328 $0.open(deviceID: RecordSerializer.localDeviceID, at: now, reconcileStale: reconcileStale) 2329 } 2330 } 2331 2332 /// Seals the local device's open solve session into a sealed interval. 2333 @discardableResult 2334 func sealClockSession(gameID: UUID, authorID: String, at now: Date = Date()) -> Bool { 2335 mutateTimeLog(gameID: gameID, authorID: authorID) { 2336 $0.seal(deviceID: RecordSerializer.localDeviceID, at: now) 2337 } 2338 } 2339 2340 /// Refreshes the local device's liveness heartbeat so a peer keeps 2341 /// extrapolating an open session toward now. 2342 @discardableResult 2343 func beatClockSession(gameID: UUID, authorID: String, at now: Date = Date()) -> Bool { 2344 mutateTimeLog(gameID: gameID, authorID: authorID) { 2345 $0.beat(deviceID: RecordSerializer.localDeviceID, at: now) 2346 } 2347 } 2348 2349 /// The completion instant for `gameID` (win or resign), or `nil` while it is 2350 /// unfinished. Used to seal the clock at the moment of the finish. 2351 func completedAt(forGame gameID: UUID) -> Date? { 2352 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2353 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 2354 request.fetchLimit = 1 2355 return (try? context.fetch(request).first)?.completedAt 2356 } 2357 2358 /// Whether `gameID` is finished (won or resigned). The clock stops opening 2359 /// new sessions once this is true. 2360 func isGameCompleted(gameID: UUID) -> Bool { 2361 completedAt(forGame: gameID) != nil 2362 } 2363 2364 /// Whether `gameID` is currently shared. Shared open-time Player writes are 2365 /// already batched by `PuzzleDisplayView.activateSharing`; solo games still 2366 /// need standalone Player enqueues so their clock syncs across this 2367 /// account's devices. 2368 func isGameShared(gameID: UUID) -> Bool { 2369 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2370 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 2371 request.fetchLimit = 1 2372 guard let entity = try? context.fetch(request).first else { return false } 2373 return entity.ckShareRecordName != nil || entity.databaseScope == 1 2374 } 2375 2376 /// Whether `gameID` is the local read-only projection of a Chronicle. 2377 /// 2378 /// This is also what routes replay: a materialised Chronicle carries its 2379 /// history as cached Journal rows regardless of whether the original game 2380 /// was shared, so it must use the merged replay loader rather than the live 2381 /// game's local-journal shortcut. 2382 func isGameArchived(gameID: UUID) -> Bool { 2383 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2384 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 2385 request.fetchLimit = 1 2386 guard let entity = try? context.fetch(request).first else { return false } 2387 return isGameArchived(entity) 2388 } 2389 2390 /// Entity-taking form, for callers that already hold the row and would 2391 /// otherwise re-fetch it by ID. 2392 func isGameArchived(_ entity: GameEntity) -> Bool { 2393 isMaterializedArchive(entity) 2394 } 2395 2396 /// Reads, mutates, and re-persists the local author's `Player.timeLog`, 2397 /// creating a stub `PlayerEntity` if none exists (works for solo games — no 2398 /// `isShared` gate). `updatedAt` is left untouched on an existing row: the 2399 /// `timeLog` field is adopted on apply regardless of LWW freshness (see 2400 /// `RecordApplier`), so it need not win the selection's `updatedAt` race. 2401 /// Returns `true` when the encoded log actually changed. 2402 @discardableResult 2403 private func mutateTimeLog( 2404 gameID: UUID, 2405 authorID: String, 2406 _ change: (inout TimeLog) -> Void 2407 ) -> Bool { 2408 let entity: PlayerEntity 2409 if let existing = fetchPlayerEntity(gameID: gameID, authorID: authorID) { 2410 entity = existing 2411 } else { 2412 let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2413 gameRequest.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 2414 gameRequest.fetchLimit = 1 2415 guard let game = try? context.fetch(gameRequest).first else { return false } 2416 entity = PlayerEntity(context: context) 2417 entity.game = game 2418 entity.authorID = authorID 2419 entity.ckRecordName = RecordSerializer.recordName( 2420 forPlayerInGame: gameID, 2421 authorID: authorID 2422 ) 2423 entity.updatedAt = Date() 2424 } 2425 var log = TimeLog.decode(entity.timeLog) 2426 change(&log) 2427 // Nothing to record — e.g. a heartbeat with no open session. Avoid 2428 // writing (and shipping) an empty `{"devices":{}}` blob. 2429 guard !log.devices.isEmpty else { return false } 2430 let encoded = TimeLog.encode(log) 2431 guard entity.timeLog != encoded else { return false } 2432 entity.timeLog = encoded 2433 saveContext("updateTimeLog") 2434 return true 2435 } 2436 2437 /// Stamps the local author's Player record for `gameID` with the address 2438 /// derived from `secret` (see `RecordSerializer.deriveGameAddress`), so it 2439 /// ships on the Player-record write the puzzle-open burst is already making. 2440 /// Returns the derived address, or `nil` if the GameEntity is missing. 2441 /// Creating the row here is safe because the open burst fills its `name` and 2442 /// `presenceUntil` before the send — unlike the standalone registration sweep 2443 /// (`reconcileLocalPushAddresses`), which must never fabricate a bare row. 2444 @discardableResult 2445 func setPushAddress(gameID: UUID, authorID: String, secret: String) -> String? { 2446 let entity: PlayerEntity 2447 if let existing = fetchPlayerEntity(gameID: gameID, authorID: authorID) { 2448 entity = existing 2449 } else { 2450 let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2451 gameRequest.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 2452 gameRequest.fetchLimit = 1 2453 guard let game = try? context.fetch(gameRequest).first else { return nil } 2454 entity = PlayerEntity(context: context) 2455 entity.game = game 2456 entity.authorID = authorID 2457 entity.ckRecordName = RecordSerializer.recordName( 2458 forPlayerInGame: gameID, 2459 authorID: authorID 2460 ) 2461 entity.updatedAt = Date() 2462 } 2463 let address = RecordSerializer.deriveGameAddress(secret: secret, gameID: gameID) 2464 guard entity.pushAddress != address else { return address } 2465 entity.pushAddress = address 2466 // Bump updatedAt so the derived address wins LWW and the outbound build 2467 // picks it up as a fresh write. 2468 entity.updatedAt = Date() 2469 saveContext("setPushAddress") 2470 return address 2471 } 2472 2473 /// Derives the local author's push address for every shared game the account 2474 /// participates in (`HMAC(secret, gameID)`) and pairs each with that game's 2475 /// shared push credential, minting the credential when absent (any 2476 /// participant may mint; record-level LWW converges concurrent mints). The 2477 /// caller registers the bindings with the push worker, which keys each 2478 /// game address under its `credID`. Also returns the games whose existing 2479 /// Player row was updated to a new derived address, so the caller can 2480 /// republish those rows for peers. The address write-back never fabricates a 2481 /// bare Player row (which would clobber `name`/`presenceUntil` server-side); a game 2482 /// with no local row still contributes its binding to the registration set. 2483 func reconcileLocalPushAddresses( 2484 authorID: String, 2485 secret: String, 2486 republishPlayerRows: Bool = true 2487 ) -> (bindings: [PushAddressBinding], republishGameIDs: [UUID]) { 2488 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2489 request.predicate = NSPredicate( 2490 format: "databaseScope == 1 OR ckShareRecordName != nil" 2491 ) 2492 let games = (try? context.fetch(request)) ?? [] 2493 var bindings: [PushAddressBinding] = [] 2494 var republishGameIDs: [UUID] = [] 2495 var gameRecordUpdates: [String] = [] 2496 var notificationCredentialsChanged = false 2497 var didChange = false 2498 for game in games { 2499 guard let gameID = game.id else { continue } 2500 // Mint the shared push credential in place when absent, so it ships 2501 // on the next Game-record push and peers converge on it. 2502 var creds = GamePushCredentials.decode(game.notification) 2503 if creds == nil, 2504 let fresh = try? GamePushCredentials.fresh(), 2505 let encoded = try? fresh.encoded() { 2506 game.notification = encoded 2507 game.hasPendingSave = true 2508 creds = fresh 2509 notificationCredentialsChanged = true 2510 didChange = true 2511 if let ckName = game.ckRecordName { gameRecordUpdates.append(ckName) } 2512 } 2513 guard let credentials = creds else { continue } 2514 let address = RecordSerializer.deriveGameAddress(secret: secret, gameID: gameID) 2515 bindings.append( 2516 PushAddressBinding(gameID: gameID, address: address, credentials: credentials) 2517 ) 2518 // Update an existing row in place; never create one here. 2519 guard republishPlayerRows, 2520 let player = fetchPlayerEntity(gameID: gameID, authorID: authorID), 2521 player.pushAddress != address 2522 else { continue } 2523 player.pushAddress = address 2524 player.updatedAt = Date() 2525 republishGameIDs.append(gameID) 2526 didChange = true 2527 } 2528 if didChange { 2529 saveContext("reconcileLocalPushAddresses") 2530 if notificationCredentialsChanged { 2531 GameEntity.rebuildContentKeyDirectory(in: context) 2532 } 2533 } 2534 // Enqueue Game-record pushes for freshly-minted credentials after the 2535 // save, mirroring `setNotification`. 2536 for ckName in gameRecordUpdates { 2537 onGameUpdated(ckName) 2538 } 2539 return (bindings, republishGameIDs) 2540 } 2541 2542 /// Player record `updatedAt` for `(gameID, authorID)` — `nil` if no row 2543 /// exists yet. Used as the peer-device liveness probe during the pause 2544 /// grace window: a value newer than `pauseStart` means a sibling device 2545 /// of the same author wrote to Player after we started pausing, i.e. 2546 /// that device is still active and will publish its own pause later. 2547 func playerUpdatedAt(for gameID: UUID, by authorID: String) -> Date? { 2548 fetchPlayerEntity(gameID: gameID, authorID: authorID)?.updatedAt 2549 } 2550 2551 /// Sender-local "notified through" watermark for `(gameID, authorID)` — 2552 /// the latest authored move we've told this recipient about via a pause, 2553 /// or `nil` if we never have. Paired with `Player.presenceUntil` to window the 2554 /// next session-end diff (see `SessionPushPlanner.sessionEndAddressees`). 2555 func notifiedThrough(for gameID: UUID, by authorID: String) -> Date? { 2556 fetchPlayerEntity(gameID: gameID, authorID: authorID)?.notifiedThrough 2557 } 2558 2559 /// Advances the notified-through watermark for each peer in `authorIDs` to 2560 /// `through`, the latest move the pause we just sent them covered. Purely 2561 /// local bookkeeping: it stamps no `updatedAt` and enqueues no push, so it 2562 /// never rides a CloudKit Player record — `RecordSerializer.playerRecord` 2563 /// deliberately omits the field. Monotonic; a backward `through` (clock 2564 /// wobble) is ignored. Rows are expected to exist already, since we only 2565 /// notify recipients read out of `pushPlan`. 2566 func recordNotified(gameID: UUID, authorIDs: [String], through: Date) { 2567 guard !authorIDs.isEmpty else { return } 2568 var didChange = false 2569 for authorID in authorIDs { 2570 guard let player = fetchPlayerEntity(gameID: gameID, authorID: authorID) else { 2571 continue 2572 } 2573 if let current = player.notifiedThrough, current >= through { continue } 2574 player.notifiedThrough = through 2575 didChange = true 2576 } 2577 if didChange { 2578 saveContext("recordNotified") 2579 } 2580 } 2581 2582 /// Merged-across-devices author cells for `(gameID, authorID)`. Returns 2583 /// each touched grid position's winning `TimestampedCell` after the 2584 /// usual LWW merge across the author's devices, including cleared cells 2585 /// (empty `letter` with a non-default `updatedAt`). The pause-push 2586 /// per-recipient diff iterates this list, counting cells whose 2587 /// `updatedAt` is newer than that recipient's last-known `Player.presenceUntil`. 2588 func mergedAuthorCells(for gameID: UUID, by authorID: String) -> [TimestampedCell] { 2589 let request = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 2590 request.predicate = NSPredicate( 2591 format: "game.id == %@ AND authorID == %@", 2592 gameID as CVarArg, 2593 authorID 2594 ) 2595 let entities = (try? context.fetch(request)) ?? [] 2596 let values: [MovesValue] = entities.compactMap { Self.movesValue(from: $0) } 2597 guard !values.isEmpty else { return [] } 2598 return GridStateMerger.mergeWithProvenance(values).values.map(\.cell) 2599 } 2600 2601 /// Grid cells a *peer* filled or cleared since `since`, each mapped to the 2602 /// author who wrote the change — the data behind the "changed while you were 2603 /// away" borders. Merges every contributor's moves (not just one author's), 2604 /// so a peer's clear is attributed to them even though it leaves no 2605 /// preserved cell author. The local player's own edits are excluded. Returns 2606 /// empty when the local author is unknown (nothing to compare against). 2607 func recentlyChangedCells(forGame gameID: UUID, since: Date) -> [GridPosition: String] { 2608 recentChanges(forGame: gameID, since: since).cells 2609 } 2610 2611 /// The full `RecentChanges.Changes` for `gameID` since `since`: the cell 2612 /// map behind the borders *and* the per-author counts behind the catch-up 2613 /// banner, from one pass over the per-cell letter-change ledger so the two 2614 /// surfaces always agree. Empty when the local author is unknown, or when 2615 /// the ledger holds nothing newer than `since` (including a game whose 2616 /// ledger has not been seeded yet — a first open shows no banner). 2617 func recentChanges(forGame gameID: UUID, since: Date) -> RecentChanges.Changes { 2618 guard let localAuthorID = authorIDProvider() else { return .empty } 2619 let request = NSFetchRequest<PeerChangeEntity>(entityName: "PeerChangeEntity") 2620 request.predicate = NSPredicate( 2621 format: "gameID == %@ AND changedAt > %@", 2622 gameID as CVarArg, 2623 since as NSDate 2624 ) 2625 let rows = (try? context.fetch(request)) ?? [] 2626 guard !rows.isEmpty else { return .empty } 2627 let entries = rows.map { Self.peerChange(from: $0) } 2628 return RecentChanges.changes(in: entries, since: since, excludingAuthor: localAuthorID) 2629 } 2630 2631 /// Diagnostic snapshot for the catch-up banner and border-highlight reads. 2632 /// This is intentionally read-only: it reports the ledger as it stands at 2633 /// the instant the UI asks for recent changes, so a stale/asynchronous build 2634 /// can be distinguished from a bad reduction. 2635 func recentChangesDiagnosticSummary(forGame gameID: UUID, since: Date) -> String { 2636 let localAuthorID = authorIDProvider() 2637 let allReq = NSFetchRequest<PeerChangeEntity>(entityName: "PeerChangeEntity") 2638 allReq.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg) 2639 let allRows = (try? context.fetch(allReq)) ?? [] 2640 2641 let newerRows = allRows.filter { ($0.changedAt ?? .distantPast) > since } 2642 let entries = newerRows.map { Self.peerChange(from: $0) } 2643 let changes = localAuthorID.map { 2644 RecentChanges.changes(in: entries, since: since, excludingAuthor: $0) 2645 } ?? .empty 2646 let counted = changes.counts.values.reduce(0) { $0 + $1.added + $1.cleared } 2647 let byAuthor = changes.counts.keys.sorted().map { authorID in 2648 let count = changes.counts[authorID] ?? RecentChanges.Count(added: 0, cleared: 0) 2649 return "\(authorID.prefix(8))=+\(count.added)/-\(count.cleared)" 2650 }.joined(separator: ",") 2651 let newest = allRows.compactMap(\.changedAt).max() 2652 2653 return "since=\(since.ISO8601Format()) " 2654 + "local=\(Self.shortAuthorID(localAuthorID)) " 2655 + "ledgerRows=\(allRows.count) newer=\(newerRows.count) " 2656 + "counted=\(counted) cells=\(changes.cells.count) " 2657 + "byAuthor=[\(byAuthor)] " 2658 + "newest=\(newest?.ISO8601Format() ?? "nil") " 2659 + Self.peerChangeEntitySampleSummary(newerRows) 2660 } 2661 2662 /// Sender-side measurements describing *why* the pause-push counts for 2663 /// `(gameID, authorID)` came out as they did. Mirrors the set the count 2664 /// path (`mergedAuthorCells`) iterates, then breaks it down against the 2665 /// current grid: how many merged positions fall inside the bounds and on 2666 /// playable squares, the coordinate range, the contributing device count, 2667 /// and the edit window. Diagnostic-only; never affects badge or body. The 2668 /// time-of-day and per-recipient fields are filled by the caller — only 2669 /// the store-derived measurements are populated here. 2670 func movesDiagnostics(for gameID: UUID, by authorID: String) -> PushPayload.Diagnostics? { 2671 let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2672 gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 2673 gReq.fetchLimit = 1 2674 guard let game = try? context.fetch(gReq).first else { return nil } 2675 2676 let mReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 2677 mReq.predicate = NSPredicate( 2678 format: "game.id == %@ AND authorID == %@", 2679 gameID as CVarArg, 2680 authorID 2681 ) 2682 let entities = (try? context.fetch(mReq)) ?? [] 2683 let values: [MovesValue] = entities.compactMap { Self.movesValue(from: $0) } 2684 let merged = GridStateMerger.mergeWithProvenance(values) 2685 2686 let width = Int(game.gridWidth) 2687 let height = Int(game.gridHeight) 2688 let puzzle = game.puzzleSource 2689 .flatMap { try? XD.parse($0) } 2690 .map(Puzzle.init(xd:)) 2691 2692 var inBounds = 0 2693 var playable = 0 2694 var minRow = Int.max, maxRow = Int.min, minCol = Int.max, maxCol = Int.min 2695 var earliest: Date? 2696 var latest: Date? 2697 for (position, provenance) in merged { 2698 minRow = min(minRow, position.row); maxRow = max(maxRow, position.row) 2699 minCol = min(minCol, position.col); maxCol = max(maxCol, position.col) 2700 let updatedAt = provenance.cell.updatedAt 2701 if earliest == nil || updatedAt < earliest! { earliest = updatedAt } 2702 if latest == nil || updatedAt > latest! { latest = updatedAt } 2703 let within = position.row >= 0 && position.row < height 2704 && position.col >= 0 && position.col < width 2705 guard within else { continue } 2706 inBounds += 1 2707 if let puzzle, !puzzle.cells[position.row][position.col].isBlock { 2708 playable += 1 2709 } 2710 } 2711 let deviceCount = Set(entities.compactMap { $0.deviceID }).count 2712 2713 return PushPayload.Diagnostics( 2714 gridWidth: width, 2715 gridHeight: height, 2716 parserVersion: Int(game.puzzleParserVersion), 2717 mergedCells: merged.count, 2718 inBounds: inBounds, 2719 playable: playable, 2720 minRow: merged.isEmpty ? nil : minRow, 2721 maxRow: merged.isEmpty ? nil : maxRow, 2722 minCol: merged.isEmpty ? nil : minCol, 2723 maxCol: merged.isEmpty ? nil : maxCol, 2724 deviceCount: deviceCount, 2725 earliestEdit: earliest, 2726 latestEdit: latest 2727 ) 2728 } 2729 2730 /// Every `(author, device)` that has written a `MovesEntity` for `gameID` — 2731 /// i.e. every device whose grid letters are present locally. Peers' and this 2732 /// account's *other* devices' Moves sync in as their own per-device rows, so 2733 /// this is the authoritative local answer to "did anyone else contribute" 2734 /// (the roster can't tell, being keyed by author alone). Replay needs a 2735 /// journal from each; when the set is just this device the local journal 2736 /// already explains the whole grid and no CloudKit fetch is needed. 2737 func contributingDevices(for gameID: UUID) -> Set<JournalDeviceKey> { 2738 let request = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 2739 request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) 2740 let entities = (try? context.fetch(request)) ?? [] 2741 var devices: Set<JournalDeviceKey> = [] 2742 for entity in entities { 2743 guard let authorID = entity.authorID, !authorID.isEmpty, 2744 let deviceID = entity.deviceID, !deviceID.isEmpty else { continue } 2745 devices.insert(JournalDeviceKey(authorID: authorID, deviceID: deviceID)) 2746 } 2747 return devices 2748 } 2749 2750 /// Distinct authorIDs that have written a `MovesEntity` for `gameID`, 2751 /// with `excluding` filtered out. The session-summary banner uses this 2752 /// to enumerate peers whose activity it should diff. 2753 func peerAuthorIDs(for gameID: UUID, excluding localAuthorID: String?) -> [String] { 2754 let request = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 2755 request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) 2756 let entities = (try? context.fetch(request)) ?? [] 2757 var unique: Set<String> = [] 2758 for entity in entities { 2759 guard let authorID = entity.authorID, !authorID.isEmpty else { continue } 2760 if let localAuthorID, authorID == localAuthorID { continue } 2761 unique.insert(authorID) 2762 } 2763 return Array(unique) 2764 } 2765 2766 /// Formatted title used by notifications and the in-app session banner 2767 /// for `gameID`. Returns an empty string when the game can't be found. 2768 func puzzleTitleForNotification(for gameID: UUID) -> String { 2769 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2770 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 2771 request.fetchLimit = 1 2772 let entity = try? context.fetch(request).first 2773 return PuzzleNotificationText.title(for: entity) 2774 } 2775 2776 /// Display name persisted on the PlayerEntity for `(gameID, authorID)`, 2777 /// or an empty string when no row exists. The session banner falls back 2778 /// to "A player" via `SessionMonitor.bodyText` when empty. 2779 func playerName(for gameID: UUID, by authorID: String) -> String { 2780 return fetchPlayerEntity(gameID: gameID, authorID: authorID)?.name ?? "" 2781 } 2782 2783 /// The user's private nickname for `authorID` (`FriendEntity.nickname`), 2784 /// or `nil` when none is set. The same override `resolvedDisplayName` 2785 /// applies, exposed here for surfaces hydrated through `GameStore` 2786 /// rather than from a `FriendEntity` row — currently the catch-up 2787 /// banner's `SessionMonitor.summaries`. 2788 func friendNickname(for authorID: String) -> String? { 2789 guard !authorID.isEmpty else { return nil } 2790 let request = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 2791 request.predicate = NSPredicate(format: "authorID == %@", authorID) 2792 request.fetchLimit = 1 2793 guard let nickname = (try? context.fetch(request).first)?.nickname? 2794 .trimmingCharacters(in: .whitespacesAndNewlines), 2795 !nickname.isEmpty 2796 else { return nil } 2797 return nickname 2798 } 2799 2800 /// The read cursor persisted for `(gameID, authorID)`, or `nil` when no row 2801 /// exists or none has been stamped. Used by the local pause-diagnostics 2802 /// mirror to compare this device's actual cursor against the value a peer's 2803 /// pushed diagnostics claim it saw. 2804 func presenceUntil(for gameID: UUID, by authorID: String) -> Date? { 2805 return fetchPlayerEntity(gameID: gameID, authorID: authorID)?.presenceUntil 2806 } 2807 2808 /// The local author's own derived push address for `gameID`, read off the 2809 /// local Player row, or nil if one hasn't been stamped yet. Used to keep a 2810 /// room broadcast from notifying the sender's own other devices. 2811 func localPushAddress(gameID: UUID, authorID: String) -> String? { 2812 fetchPlayerEntity(gameID: gameID, authorID: authorID)?.pushAddress 2813 } 2814 2815 /// The authorIDs of every participant in `gameID` — the game's known 2816 /// roster, reused from the same summary the library renders. Used to 2817 /// address per-recipient authentication tags on outbound live engagement 2818 /// frames. 2819 func participantAuthorIDs(gameID: UUID) -> [String] { 2820 guard let entity = fetchGameEntity(id: gameID), 2821 let summary = GameSummary(entity: entity, localAuthorID: authorIDProvider()) 2822 else { return [] } 2823 return summary.allParticipants.map(\.authorID).filter { !$0.isEmpty } 2824 } 2825 2826 private func fetchPlayerEntity(gameID: UUID, authorID: String) -> PlayerEntity? { 2827 let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 2828 request.predicate = NSPredicate( 2829 format: "game.id == %@ AND authorID == %@", 2830 gameID as CVarArg, 2831 authorID 2832 ) 2833 request.fetchLimit = 1 2834 return try? context.fetch(request).first 2835 } 2836 2837 /// Replaces a game's persisted XD source with a re-converted equivalent, 2838 /// stamps the current CmVer, raises `hasPushPending` so the next outbound 2839 /// Game record re-includes the `puzzleSource` asset, and enqueues the push 2840 /// via `onGameUpdated`. Callers (currently the NYT upgrade flow) are 2841 /// responsible for verifying that `newSource` is structurally compatible 2842 /// with the player's in-progress moves before invoking this. 2843 func replacePuzzleSource(id: UUID, with newSource: String) { 2844 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2845 request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 2846 request.fetchLimit = 1 2847 guard let entity = try? context.fetch(request).first, 2848 let parsed = try? XD.parse(newSource) else { return } 2849 let puzzle = Puzzle(xd: parsed) 2850 entity.puzzleSource = newSource 2851 entity.title = puzzle.title 2852 entity.puzzleParserVersion = Int64(XD.currentParserVersion) 2853 entity.hasPushPending = true 2854 entity.hasPendingSave = true 2855 entity.populateCachedSummaryFields(from: puzzle) 2856 saveContext("upgradePuzzleSource") 2857 if let ckName = entity.ckRecordName { 2858 onGameUpdated(ckName) 2859 } 2860 } 2861 2862 /// Records that a game's source has been evaluated at the current converter 2863 /// version by rewriting its `ConVer:` header in place, leaving the grid (and 2864 /// the player's moves) untouched. Used when an attempted NYT upgrade found a 2865 /// structural divergence and kept the old source: advancing the header stops 2866 /// `NYTPuzzleUpgrader.plan` from re-fetching the game on every open. The 2867 /// parser version is deliberately left alone so `preparePuzzleForLoad` still 2868 /// refreshes the cache if the parser has advanced. Local-only — only the 2869 /// owning device acts on the converter version, so there is nothing to push. 2870 func stampConverterVersion(for id: UUID) { 2871 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2872 request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 2873 request.fetchLimit = 1 2874 guard let entity = try? context.fetch(request).first, 2875 let source = entity.puzzleSource else { return } 2876 entity.puzzleSource = XD.settingConverterVersionHeader( 2877 in: source, to: XD.currentConverterVersion 2878 ) 2879 saveContext("stampConverterVersion") 2880 } 2881 2882 private func restore(game: Game, from entity: GameEntity, updateCache: Bool = true) { 2883 let movesRequest = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 2884 movesRequest.predicate = NSPredicate(format: "game == %@", entity) 2885 let movesEntities = (try? context.fetch(movesRequest)) ?? [] 2886 let values: [MovesValue] = movesEntities.compactMap { Self.movesValue(from: $0) } 2887 // A materialised Chronicle deliberately has no live Moves rows. Its 2888 // final cells are the authoritative frozen grid, including the author 2889 // attribution needed for participant colours. 2890 let archiveGrid = materializedArchiveGrid(entity) 2891 let grid = archiveGrid ?? GridStateMerger.merge(values) 2892 2893 // A completed game (won or resigned) is terminal; its grid is, by 2894 // definition, the solution. The merge is watermarked at `completedAt`: 2895 // a collaborator's letter typed just before the win still merges in 2896 // when it reaches us afterward (carrying its author), but anything 2897 // stamped after the latch is ignored — nothing re-opens or rewrites a 2898 // finished puzzle. Whatever's still empty at the cutoff is sealed to 2899 // the solution so a completed game always renders solved, independent 2900 // of merge drift (a late clear, an edit that reached a peer over 2901 // engagement but never synced — see the realtime/durable decoupling). 2902 // Input is separately locked (`GameMutator.isCompleted`); the 2903 // CellEntity cache still mirrors the raw (un-watermarked) merge. 2904 if let completedAt = entity.completedAt { 2905 let sealedGrid = archiveGrid 2906 ?? GridStateMerger.merge(values, notAfter: completedAt) 2907 sealToSolution(game: game, mergedGrid: sealedGrid) 2908 if updateCache { 2909 updateCellCache(for: entity, from: grid) 2910 } 2911 return 2912 } 2913 2914 // The local device's own row. Used to decide whether a buffered edit 2915 // (flagged by `Square.enqueuedAt`) has landed durably yet: once the 2916 // flush writes it, this row carries the cell with `updatedAt` equal 2917 // to the flag's timestamp (`MovesUpdater` persists `enqueuedAt` as the 2918 // cell's `updatedAt`). 2919 let localDeviceID = RecordSerializer.localDeviceID 2920 let localAuthorID = authorIDProvider() 2921 let localCells: [GridPosition: TimestampedCell] = values.first { 2922 $0.deviceID == localDeviceID 2923 && (localAuthorID == nil || $0.authorID == localAuthorID) 2924 }?.cells ?? [:] 2925 2926 // Apply the merge as a diff: an inbound catch-up usually carries one 2927 // peer keystroke, yet the merged grid spans the whole board. Writing 2928 // every square unconditionally fires the `@Observable squares` 2929 // hundreds of times per catch-up — invalidating the grid view and 2930 // re-running the completion scan — even when the merged result is 2931 // identical to what's already on screen. Touch only the cells whose 2932 // value actually changed, and rebuild the completion cache only if at 2933 // least one did, so a redundant catch-up becomes a true no-op on the 2934 // main actor (the co-solve hot path). 2935 var changed = false 2936 for (position, cell) in grid { 2937 let r = position.row 2938 let c = position.col 2939 guard r >= 0, r < game.puzzle.height, c >= 0, c < game.puzzle.width else { continue } 2940 let current = game.squares[r][c] 2941 // A non-nil `enqueuedAt` means the user typed here and the edit 2942 // may still be buffered in `MovesUpdater`. Retire the flag only 2943 // once the local row shows this cell at a timestamp >= the flag 2944 // (the edit, or a newer one, has landed); until then leave the 2945 // value fields alone so the just-typed letter stays on screen. 2946 var clearEnqueued = false 2947 if let stamp = current.enqueuedAt { 2948 guard let landed = localCells[position], 2949 landed.updatedAt >= stamp 2950 else { continue } 2951 clearEnqueued = true 2952 } 2953 guard clearEnqueued 2954 || current.entry != cell.letter 2955 || current.mark != cell.mark 2956 || current.letterAuthorID != cell.authorID 2957 else { continue } 2958 // Build the new square locally and assign once, so the cell fires 2959 // a single `squares` mutation rather than one per field. 2960 var updated = current 2961 updated.enqueuedAt = clearEnqueued ? nil : current.enqueuedAt 2962 updated.entry = cell.letter 2963 updated.mark = cell.mark 2964 updated.letterAuthorID = cell.authorID 2965 game.squares[r][c] = updated 2966 changed = true 2967 } 2968 if changed { 2969 game.recomputeCompletionCache() 2970 } 2971 2972 if updateCache { 2973 updateCellCache(for: entity, from: grid) 2974 } 2975 } 2976 2977 /// Populates `game.squares` from the puzzle solution for a completed 2978 /// (terminal) game. A cell the merge already resolved to an accepted answer 2979 /// keeps that entry, its author, and its mark; a hole or a stray 2980 /// post-completion letter is overwritten with the canonical solution and a 2981 /// clean mark. Cells with no known solution fall back to the merged value. 2982 /// The result is always `.solved`, so the finish presentation is shown. 2983 private func sealToSolution(game: Game, mergedGrid: GridState) { 2984 for r in 0..<game.puzzle.height { 2985 for c in 0..<game.puzzle.width { 2986 let cell = game.puzzle.cells[r][c] 2987 guard !cell.isBlock else { continue } 2988 game.squares[r][c].enqueuedAt = nil 2989 let merged = mergedGrid[GridPosition(row: r, col: c)] 2990 if let merged, cell.accepts(merged.letter) { 2991 // Correctly filled — preserve who filled it and its mark 2992 // (a stale wrong-mark on a correct letter is contradictory, 2993 // so force it off). 2994 game.squares[r][c].entry = merged.letter 2995 game.squares[r][c].mark = merged.mark.withoutWrongCheck 2996 game.squares[r][c].letterAuthorID = merged.authorID 2997 } else if let solution = cell.solution { 2998 // Hole or stray post-completion letter — seal to solution. 2999 game.squares[r][c].entry = solution 3000 game.squares[r][c].mark = .none 3001 game.squares[r][c].letterAuthorID = merged?.authorID 3002 } else if let merged { 3003 // No known solution — show whatever the merge resolved. 3004 game.squares[r][c].entry = merged.letter 3005 game.squares[r][c].mark = merged.mark 3006 game.squares[r][c].letterAuthorID = merged.authorID 3007 } 3008 } 3009 } 3010 game.recomputeCompletionCache() 3011 } 3012 3013 private func updateCellCache(for gameEntity: GameEntity, from grid: GridState) { 3014 Self.applyCellCache(to: gameEntity, from: grid, in: context) 3015 saveContext("updateCellCache") 3016 } 3017 3018 /// Hydrates a `MovesValue` from a `MovesEntity`. Returns `nil` if the row 3019 /// is missing required fields. 3020 fileprivate nonisolated static func movesValue(from entity: MovesEntity) -> MovesValue? { 3021 guard let gameID = entity.game?.id, 3022 let authorID = entity.authorID, 3023 let deviceID = entity.deviceID, 3024 let updatedAt = entity.updatedAt 3025 else { return nil } 3026 let cells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:] 3027 return MovesValue( 3028 gameID: gameID, 3029 authorID: authorID, 3030 deviceID: deviceID, 3031 cells: cells, 3032 updatedAt: updatedAt 3033 ) 3034 } 3035 3036 fileprivate nonisolated static func peerChange(from entity: PeerChangeEntity) -> PeerChange { 3037 PeerChange( 3038 position: GridPosition(row: Int(entity.row), col: Int(entity.col)), 3039 letter: entity.letter ?? "", 3040 authorID: entity.authorID, 3041 changedAt: entity.changedAt ?? .distantPast 3042 ) 3043 } 3044 3045 private nonisolated static func peerChangeSampleSummary(_ changes: [PeerChange]) -> String { 3046 guard !changes.isEmpty else { return "sample=[]" } 3047 let sample = changes 3048 .sorted { lhs, rhs in 3049 if lhs.changedAt != rhs.changedAt { return lhs.changedAt < rhs.changedAt } 3050 if lhs.position.row != rhs.position.row { return lhs.position.row < rhs.position.row } 3051 return lhs.position.col < rhs.position.col 3052 } 3053 .prefix(5) 3054 .map { 3055 "r\($0.position.row)c\($0.position.col):" 3056 + "\($0.letter.isEmpty ? "-" : $0.letter)" 3057 + "@\($0.changedAt.ISO8601Format())" 3058 + "#\(Self.shortAuthorID($0.authorID))" 3059 } 3060 .joined(separator: ",") 3061 return "sample=[\(sample)]" 3062 } 3063 3064 private nonisolated static func peerChangeEntitySampleSummary(_ rows: [PeerChangeEntity]) -> String { 3065 peerChangeSampleSummary(rows.map { peerChange(from: $0) }) 3066 } 3067 3068 private nonisolated static func shortAuthorID(_ authorID: String?) -> String { 3069 guard let authorID else { return "nil" } 3070 return String(authorID.prefix(8)) 3071 } 3072 3073 private func inferredObservedCompletionAuthorID(for id: UUID) -> String? { 3074 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 3075 request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 3076 request.fetchLimit = 1 3077 guard let entity = try? context.fetch(request).first, 3078 let source = entity.puzzleSource, 3079 let xd = try? XD.parse(source) 3080 else { return nil } 3081 3082 let puzzle = Puzzle(xd: xd) 3083 let movesRequest = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 3084 movesRequest.predicate = NSPredicate(format: "game == %@", entity) 3085 let movesEntities = (try? context.fetch(movesRequest)) ?? [] 3086 let values: [MovesValue] = movesEntities.compactMap { Self.movesValue(from: $0) } 3087 let provenance = GridStateMerger.mergeWithProvenance(values) 3088 3089 var latest: (date: Date, authorID: String)? 3090 for row in puzzle.cells { 3091 for cell in row { 3092 guard !cell.isBlock, cell.solution != nil else { continue } 3093 let position = GridPosition(row: cell.row, col: cell.col) 3094 guard let winner = provenance[position], 3095 !winner.cell.letter.isEmpty, 3096 cell.accepts(winner.cell.letter) 3097 else { return nil } 3098 3099 if latest.map({ winner.cell.updatedAt > $0.date }) ?? true { 3100 latest = (winner.cell.updatedAt, winner.writerAuthorID) 3101 } 3102 } 3103 } 3104 return latest?.authorID 3105 } 3106 3107 /// Reconciles a `GameEntity`'s `CellEntity` cache against `grid` inside 3108 /// `ctx`. Caller is responsible for saving `ctx`. Used from both the 3109 /// main-context `updateCellCache` and the background-context 3110 /// `replayCellCaches`. 3111 fileprivate nonisolated static func applyCellCache( 3112 to gameEntity: GameEntity, 3113 from grid: GridState, 3114 in ctx: NSManagedObjectContext 3115 ) { 3116 let cellEntities = (gameEntity.cells as? Set<CellEntity>) ?? [] 3117 var existing: [GridPosition: CellEntity] = [:] 3118 for ce in cellEntities { 3119 existing[GridPosition(row: Int(ce.row), col: Int(ce.col))] = ce 3120 } 3121 3122 for (position, cell) in grid { 3123 // The merged grid can include peer-controlled positions; the codec 3124 // guarantees Int16 representability, and this keeps out-of-grid 3125 // leftovers from materializing as CellEntity rows. 3126 guard position.isPersistable( 3127 gridWidth: gameEntity.gridWidth, 3128 gridHeight: gameEntity.gridHeight 3129 ) else { continue } 3130 let ce: CellEntity 3131 if let found = existing[position] { 3132 ce = found 3133 } else { 3134 ce = CellEntity(context: ctx) 3135 ce.row = Int16(position.row) 3136 ce.col = Int16(position.col) 3137 ce.game = gameEntity 3138 } 3139 ce.letter = cell.letter 3140 ce.markCode = cell.mark.code 3141 ce.letterAuthorID = cell.authorID 3142 } 3143 3144 for (position, ce) in existing where grid[position] == nil { 3145 ce.letter = "" 3146 ce.markCode = 0 3147 ce.letterAuthorID = nil 3148 } 3149 } 3150 3151 /// Marks the active game read-only when the sync engine sees its shared 3152 /// zone disappear from the shared database (owner revoked access). 3153 func markAccessRevoked(gameID: UUID) { 3154 guard currentEntity?.id == gameID else { return } 3155 currentMutator?.isAccessRevoked = true 3156 } 3157 3158 /// Called after the sync engine deletes a `GameEntity` in response to a 3159 /// remote private-DB zone deletion (the user removed this game on another 3160 /// device). The deletion itself has already been merged into the view 3161 /// context; this method's job is to drop the active references if the 3162 /// open puzzle is the one that just disappeared, so the UI doesn't 3163 /// dereference a deleted managed object. Returns whether the removed game 3164 /// was the one currently open, so the caller can surface an in-puzzle 3165 /// notice only when there is a puzzle on screen to host it. 3166 @discardableResult 3167 func handleRemoteRemoval(gameID: UUID) -> Bool { 3168 let wasOpen = currentEntity?.id == gameID 3169 if wasOpen { 3170 currentGame = nil 3171 currentMutator = nil 3172 currentEntity = nil 3173 } 3174 onUnreadOtherMovesChanged?() 3175 return wasOpen 3176 } 3177 3178 /// Flips the active game's mutator to shared after `ShareController` 3179 /// saves a `CKShare`, so an open `PuzzleView` reacts (builds the roster, 3180 /// starts publishing the local selection) without requiring the user to re-open. 3181 func markShared(gameID: UUID) { 3182 guard currentEntity?.id == gameID else { return } 3183 currentMutator?.isShared = true 3184 } 3185 3186 private func makeMutator(game: Game, entity: GameEntity) -> GameMutator { 3187 guard let gameID = entity.id else { 3188 fatalError("GameEntity missing id — data model invariant violated") 3189 } 3190 return GameMutator( 3191 game: game, 3192 gameID: gameID, 3193 movesUpdater: movesUpdater, 3194 movesJournal: movesJournal, 3195 authorIDProvider: authorIDProvider, 3196 onLocalCellEdit: { [weak self] edit in 3197 self?.onLocalCellEdit?(edit) 3198 }, 3199 onLocalCellEditBatch: { [weak self] edits in 3200 self?.onLocalCellEditBatch?(edits) 3201 }, 3202 isOwned: entity.databaseScope == 0, 3203 isShared: entity.ckShareRecordName != nil || entity.databaseScope == 1, 3204 showsPlayerAttribution: entity.ckShareRecordName != nil 3205 || entity.databaseScope == 1 3206 || isArchivedSharedGame(entity), 3207 isArchived: isMaterializedArchive(entity), 3208 isAccessRevoked: entity.isAccessRevoked, 3209 isSyncSupported: GameSyncVersion.supports(entity.syncVersion), 3210 syncVersion: entity.syncVersion, 3211 initialLogicalTick: maximumLogicalTick(for: entity), 3212 isCompleted: entity.completedAt != nil 3213 ) 3214 } 3215 3216 /// Advances an owned legacy game when its owner opens it. Participants 3217 /// adopt the owner's Game record and never select a protocol themselves. 3218 /// Marking the row pending protects the chosen version from a stale fetch 3219 /// until SyncEngine confirms the owner-authoritative save. 3220 private func upgradeOwnedGameSyncVersionIfNeeded(_ entity: GameEntity) throws { 3221 let version = GameSyncVersion.normalized(entity.syncVersion) 3222 guard entity.databaseScope == DatabaseScope.private.rawValue, 3223 version < GameSyncVersion.current, 3224 GameSyncVersion.supports(version) 3225 else { return } 3226 3227 entity.syncVersion = GameSyncVersion.current 3228 entity.hasPendingSave = true 3229 try context.save() 3230 if let recordName = entity.ckRecordName { 3231 onGameUpdated(recordName) 3232 } 3233 } 3234 3235 /// Highest modern tick or timestamp-derived legacy value currently known 3236 /// for the game. This is the Lamport floor used by the next local edit. 3237 private func maximumLogicalTick(for entity: GameEntity) -> Int64 { 3238 let moves = (entity.moves as? Set<MovesEntity>) ?? [] 3239 var maximum: Int64 = 0 3240 for row in moves { 3241 guard let data = row.cells, 3242 let cells = try? MovesCodec.decode(data) 3243 else { continue } 3244 for cell in cells.values { 3245 maximum = max(maximum, cell.logicalValue) 3246 } 3247 } 3248 return maximum 3249 } 3250 3251 private func fetchGameEntity(id gameID: UUID) -> GameEntity? { 3252 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 3253 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 3254 req.fetchLimit = 1 3255 return try? context.fetch(req).first 3256 } 3257 3258 private func ensureMovesEntity( 3259 recordName: String, 3260 game: GameEntity, 3261 authorID: String, 3262 deviceID: String 3263 ) -> MovesEntity { 3264 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 3265 req.predicate = NSPredicate(format: "ckRecordName == %@", recordName) 3266 req.fetchLimit = 1 3267 if let existing = try? context.fetch(req).first { 3268 return existing 3269 } 3270 3271 let entity = MovesEntity(context: context) 3272 entity.game = game 3273 entity.ckRecordName = recordName 3274 entity.authorID = authorID 3275 entity.deviceID = deviceID 3276 entity.cells = Data() 3277 entity.updatedAt = Date() 3278 return entity 3279 } 3280 3281 }