GameStore.swift (29392B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import Observation 5 6 /// Per-cell state for rendering a thumbnail. Plain value type so 7 /// SwiftUI can diff it cheaply. 8 enum GameThumbnailCell: Equatable { 9 case block 10 case empty 11 case filled 12 } 13 14 /// Value type backing a library row. Built from a `GameEntity` so that 15 /// SwiftUI's `@FetchRequest` can drive the list and still render through 16 /// an immutable, diff-friendly model. 17 struct GameSummary: Identifiable, Equatable { 18 let id: UUID 19 let title: String 20 let publisher: String? 21 let puzzleDate: Date? 22 let updatedAt: Date? 23 let completedAt: Date? 24 let gridWidth: Int 25 let gridHeight: Int 26 let thumbnailCells: [GameThumbnailCell] 27 /// `true` when the current user owns this game (`databaseScope == 0`). 28 let isOwned: Bool 29 /// `true` when this game has an active share (owner) or is joined via 30 /// a share (participant, `databaseScope == 1`). 31 let isShared: Bool 32 let isAccessRevoked: Bool 33 let hasUnseenOtherMoves: Bool 34 35 init?(entity: GameEntity) { 36 guard let id = entity.id else { return nil } 37 38 let width: Int 39 let height: Int 40 let publisher: String? 41 let puzzleDate: Date? 42 let blocks: [Bool] 43 44 if entity.gridWidth > 0, 45 entity.gridHeight > 0, 46 let mask = entity.blockMask, 47 mask.count == Int(entity.gridWidth) * Int(entity.gridHeight) { 48 // Fast path: derived data is cached on the entity, so the list 49 // can render without parsing XD on every keystroke-driven save. 50 width = Int(entity.gridWidth) 51 height = Int(entity.gridHeight) 52 publisher = entity.cachedPublisher 53 puzzleDate = entity.cachedPuzzleDate 54 blocks = mask.map { $0 != 0 } 55 } else { 56 // Fallback for legacy rows that haven't been backfilled yet, or 57 // test fixtures that bypass the creation helpers. The 58 // PersistenceController backfill should make this rare. 59 guard let source = entity.puzzleSource, 60 let xd = try? XD.parse(source) else { 61 return nil 62 } 63 let puzzle = Puzzle(xd: xd) 64 width = puzzle.width 65 height = puzzle.height 66 publisher = puzzle.publisher 67 puzzleDate = puzzle.date 68 var bs: [Bool] = [] 69 bs.reserveCapacity(puzzle.width * puzzle.height) 70 for r in 0..<puzzle.height { 71 for c in 0..<puzzle.width { 72 bs.append(puzzle.cells[r][c].isBlock) 73 } 74 } 75 blocks = bs 76 } 77 78 let cellEntities = (entity.cells as? Set<CellEntity>) ?? [] 79 var filledSet: Set<Int> = [] 80 for ce in cellEntities where !(ce.letter ?? "").isEmpty { 81 filledSet.insert(Int(ce.row) * width + Int(ce.col)) 82 } 83 84 var thumbCells: [GameThumbnailCell] = [] 85 thumbCells.reserveCapacity(width * height) 86 for r in 0..<height { 87 for c in 0..<width { 88 let idx = r * width + c 89 if blocks[idx] { 90 thumbCells.append(.block) 91 } else if filledSet.contains(idx) { 92 thumbCells.append(.filled) 93 } else { 94 thumbCells.append(.empty) 95 } 96 } 97 } 98 99 self.id = id 100 self.title = entity.title ?? "Untitled" 101 self.publisher = publisher 102 self.puzzleDate = puzzleDate 103 self.updatedAt = entity.updatedAt 104 self.completedAt = entity.completedAt 105 self.gridWidth = width 106 self.gridHeight = height 107 self.thumbnailCells = thumbCells 108 self.isOwned = entity.databaseScope == 0 109 self.isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 110 self.isAccessRevoked = entity.isAccessRevoked 111 self.hasUnseenOtherMoves = Self.computeHasUnseen( 112 isShared: self.isShared, 113 latest: entity.latestOtherMoveAt, 114 lastSeen: entity.lastSeenOtherMoveAt 115 ) 116 } 117 118 fileprivate static func computeHasUnseen( 119 isShared: Bool, 120 latest: Date?, 121 lastSeen: Date? 122 ) -> Bool { 123 guard isShared, let latest else { return false } 124 guard let lastSeen else { return true } 125 return latest > lastSeen 126 } 127 } 128 129 /// CloudKit routing metadata captured before a game row is deleted locally. 130 /// The sync layer cannot look this up after the Core Data cascade completes. 131 struct GameCloudDeletion: Sendable, Equatable { 132 let gameID: UUID 133 let databaseScope: Int16 134 let ckZoneName: String 135 let ckZoneOwnerName: String 136 } 137 138 /// Per-entity memoisation of `GameSummary`. The library list re-runs on 139 /// every Core Data save (i.e., every keystroke), but only the active 140 /// entity's fields actually change. The cache key intentionally uses fast 141 /// scalar/string fields so a hit never has to fault the `cells` 142 /// relationship; `MovesUpdater` bumps `updatedAt` atomically with cell 143 /// writes, so it acts as a faithful proxy for "thumbnail might have 144 /// changed". 145 @MainActor 146 final class GameSummaryCache { 147 private struct Key: Equatable { 148 let updatedAt: Date? 149 let completedAt: Date? 150 let latestOther: Date? 151 let lastSeenOther: Date? 152 let scope: Int16 153 let shareName: String? 154 let revoked: Bool 155 } 156 private var entries: [NSManagedObjectID: (key: Key, summary: GameSummary)] = [:] 157 158 func summary(for entity: GameEntity) -> GameSummary? { 159 let key = Key( 160 updatedAt: entity.updatedAt, 161 completedAt: entity.completedAt, 162 latestOther: entity.latestOtherMoveAt, 163 lastSeenOther: entity.lastSeenOtherMoveAt, 164 scope: entity.databaseScope, 165 shareName: entity.ckShareRecordName, 166 revoked: entity.isAccessRevoked 167 ) 168 if let hit = entries[entity.objectID], hit.key == key { 169 return hit.summary 170 } 171 guard let fresh = GameSummary(entity: entity) else { return nil } 172 entries[entity.objectID] = (key, fresh) 173 return fresh 174 } 175 } 176 177 extension GameEntity { 178 /// Writes the derived puzzle data that `GameSummary` (and the library 179 /// list) needs into the entity, so the list path never has to call 180 /// `XD.parse` on every Core Data save. Block layout is encoded as one 181 /// byte per cell in row-major order. 182 func populateCachedSummaryFields(from puzzle: Puzzle) { 183 cachedPublisher = puzzle.publisher 184 cachedPuzzleDate = puzzle.date 185 gridWidth = Int16(puzzle.width) 186 gridHeight = Int16(puzzle.height) 187 188 var bytes = [UInt8]() 189 bytes.reserveCapacity(puzzle.width * puzzle.height) 190 for r in 0..<puzzle.height { 191 for c in 0..<puzzle.width { 192 bytes.append(puzzle.cells[r][c].isBlock ? 1 : 0) 193 } 194 } 195 blockMask = Data(bytes) 196 } 197 } 198 199 /// Repository over the local Core Data store. Manages the lifecycle of 200 /// games — loading a specific one, creating new ones from bundled puzzles, 201 /// and deleting them. The library list itself is driven by `@FetchRequest` 202 /// in `GameListView`, not this type. Persistence of individual cell 203 /// mutations is handled by `GameMutator`. 204 @MainActor 205 @Observable 206 final class GameStore { 207 let persistence: PersistenceController 208 private var context: NSManagedObjectContext { persistence.viewContext } 209 210 private(set) var currentGame: Game? 211 private(set) var currentMutator: GameMutator? 212 private(set) var currentEntity: GameEntity? 213 214 private let movesUpdater: MovesUpdater 215 216 /// Returns the current iCloud author ID, or nil while the first 217 /// `userRecordID()` lookup is still pending. The inner Optional reflects 218 /// genuine "don't know yet" state on first install. 219 private let authorIDProvider: @MainActor () -> String? 220 221 /// Called when a new game's `ckRecordName` is ready to push. 222 private let onGameCreated: (String) -> Void 223 224 /// Called with CloudKit zone metadata after a game is removed locally. 225 private let onGameDeleted: (GameCloudDeletion) -> Void 226 227 /// Called when a mutable field on the `Game` record (e.g. `completedAt`) 228 /// changes and needs to be re-pushed. 229 private let onGameUpdated: (String) -> Void 230 231 init( 232 persistence: PersistenceController, 233 movesUpdater: MovesUpdater, 234 authorIDProvider: @escaping @MainActor () -> String?, 235 onGameCreated: @escaping (String) -> Void, 236 onGameUpdated: @escaping (String) -> Void, 237 onGameDeleted: @escaping (GameCloudDeletion) -> Void 238 ) { 239 self.persistence = persistence 240 self.movesUpdater = movesUpdater 241 self.authorIDProvider = authorIDProvider 242 self.onGameCreated = onGameCreated 243 self.onGameUpdated = onGameUpdated 244 self.onGameDeleted = onGameDeleted 245 } 246 247 enum LoadError: Error { 248 case sampleResourceMissing 249 case persistedSourceMissing 250 case gameNotFound 251 } 252 253 // MARK: - Remote update 254 255 /// Re-replays the current game from its move log after remote moves have 256 /// been written into Core Data by the sync engine. 257 func refreshCurrentGame() { 258 guard let game = currentGame, let entity = currentEntity else { return } 259 // On this path the SyncEngine's inbound fetch has already replayed 260 // the CellEntity cache atomically with the inbound MovesEntity 261 // (see SyncEngine.replayCellCache); rewriting it here would do the 262 // same work against the main context, so we skip it to keep the 263 // main thread free during co-solve bursts. 264 restore(game: game, from: entity, updateCache: false) 265 } 266 267 /// Merges every device's `MovesEntity` rows for each game ID and updates 268 /// the `CellEntity` cache so that list thumbnails reflect local edits 269 /// immediately after a `MovesUpdater` flush, without waiting for the next 270 /// sync cycle. Runs on a background context to keep the main actor free. 271 func replayCellCaches(for gameIDs: Set<UUID>) async { 272 let bgCtx = persistence.container.newBackgroundContext() 273 bgCtx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 274 await bgCtx.perform { 275 for gameID in gameIDs { 276 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 277 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 278 req.fetchLimit = 1 279 guard let entity = try? bgCtx.fetch(req).first else { continue } 280 281 let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 282 movesReq.predicate = NSPredicate(format: "game == %@", entity) 283 let values: [MovesValue] = ((try? bgCtx.fetch(movesReq)) ?? []) 284 .compactMap { Self.movesValue(from: $0) } 285 let grid = GridStateMerger.merge(values) 286 Self.applyCellCache(to: entity, from: grid, in: bgCtx) 287 } 288 if bgCtx.hasChanges { 289 try? bgCtx.save() 290 } 291 } 292 } 293 294 /// Updates `latestOtherMoveAt` for each game whose Moves record was just 295 /// updated by another iCloud user, driving the unread-badge heuristic. 296 /// `gameIDs` are the games that received an inbound `Moves` record in the 297 /// most recent sync batch; for each, we scan the now-persisted 298 /// `MovesEntity` rows and pick the latest `updatedAt` whose row is owned 299 /// by a different `authorID` than the local user. If the game is currently 300 /// open, `lastSeenOtherMoveAt` is advanced in lockstep so the badge 301 /// doesn't appear for activity the user is already watching. 302 func noteIncomingMovesUpdate(gameIDs: Set<UUID>, currentAuthorID: String?) { 303 guard let currentAuthorID, !gameIDs.isEmpty else { return } 304 305 for gameID in gameIDs { 306 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 307 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 308 gameReq.fetchLimit = 1 309 guard let entity = try? context.fetch(gameReq).first else { continue } 310 311 let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 312 movesReq.predicate = NSPredicate( 313 format: "game == %@ AND authorID != %@", 314 entity, 315 currentAuthorID 316 ) 317 let rows = (try? context.fetch(movesReq)) ?? [] 318 guard let latest = rows.compactMap(\.updatedAt).max() else { continue } 319 320 if (entity.latestOtherMoveAt ?? .distantPast) < latest { 321 entity.latestOtherMoveAt = latest 322 } 323 if currentEntity?.id == gameID { 324 entity.lastSeenOtherMoveAt = entity.latestOtherMoveAt 325 } 326 } 327 328 if context.hasChanges { 329 try? context.save() 330 } 331 } 332 333 // MARK: - Load a specific game 334 335 /// Loads a game by its entity ID. Sets it as the current game. 336 func loadGame(id: UUID) throws -> (Game, GameMutator) { 337 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 338 request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 339 request.fetchLimit = 1 340 341 guard let entity = try context.fetch(request).first else { 342 throw LoadError.gameNotFound 343 } 344 let puzzle = try preparePuzzleForLoad(from: entity) 345 let game = Game(puzzle: puzzle) 346 restore(game: game, from: entity) 347 348 let mutator = makeMutator(game: game, entity: entity) 349 350 currentGame = game 351 currentMutator = mutator 352 currentEntity = entity 353 markOtherMovesSeen(for: entity) 354 355 return (game, mutator) 356 } 357 358 // MARK: - Duplicate detection 359 360 /// Returns the ID of an existing game for the same source. Exact source 361 /// matches win, then catalog resource ID/title matches catch older stored 362 /// copies of a packaged puzzle. 363 func findGameID(matching source: String) -> UUID? { 364 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 365 request.predicate = NSPredicate(format: "puzzleSource == %@", source) 366 request.fetchLimit = 1 367 if let exact = try? context.fetch(request).first?.id { 368 return exact 369 } 370 371 guard let xd = try? XD.parse(source) else { return nil } 372 let resourceID = PuzzleCatalog.resourceID(matching: source) 373 let fallback = NSFetchRequest<GameEntity>(entityName: "GameEntity") 374 if let resourceID { 375 fallback.predicate = NSPredicate(format: "puzzleResourceID == %@", resourceID) 376 fallback.fetchLimit = 1 377 if let match = try? context.fetch(fallback).first?.id { 378 return match 379 } 380 } 381 382 guard resourceID != nil, let title = xd.title else { return nil } 383 fallback.predicate = NSPredicate(format: "title == %@", title) 384 fallback.fetchLimit = 1 385 return (try? context.fetch(fallback).first?.id) 386 } 387 388 /// Returns joined CloudKit-share games that have a usable puzzle payload. 389 /// Placeholders created from shared-zone discovery are intentionally 390 /// excluded until the root Game record has arrived. 391 func joinedSharedGameIDs() -> Set<UUID> { 392 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 393 request.predicate = NSPredicate( 394 format: "databaseScope == 1 AND puzzleSource != nil AND puzzleSource != %@", 395 "" 396 ) 397 return Set(((try? context.fetch(request)) ?? []).compactMap(\.id)) 398 } 399 400 // MARK: - Create a new game 401 402 /// Creates a new game from XD source text. Returns the new game's UUID. 403 func createGame(from source: String) throws -> UUID { 404 let xd = try XD.parse(source) 405 let puzzle = Puzzle(xd: xd) 406 407 let now = Date() 408 let gameID = UUID() 409 let entity = GameEntity(context: context) 410 entity.id = gameID 411 entity.title = puzzle.title 412 entity.puzzleSource = source 413 entity.puzzleCmVersion = Int64(XD.currentCmVersion) 414 entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: source) 415 entity.createdAt = now 416 entity.updatedAt = now 417 entity.ckRecordName = "game-\(gameID.uuidString)" 418 entity.ckZoneName = "game-\(gameID.uuidString)" 419 entity.databaseScope = 0 420 entity.populateCachedSummaryFields(from: puzzle) 421 422 try context.save() 423 onGameCreated("game-\(gameID.uuidString)") 424 return gameID 425 } 426 427 // MARK: - Delete a game 428 429 func deleteGame(id: UUID) throws { 430 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 431 request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 432 request.fetchLimit = 1 433 434 guard let entity = try context.fetch(request).first else { return } 435 436 let deletion = GameCloudDeletion( 437 gameID: id, 438 databaseScope: entity.databaseScope, 439 ckZoneName: entity.ckZoneName ?? "game-\(id.uuidString)", 440 ckZoneOwnerName: entity.ckZoneOwnerName ?? CKCurrentUserDefaultName 441 ) 442 443 // Clear current references if this is the active game 444 if currentEntity?.id == id { 445 currentGame = nil 446 currentMutator = nil 447 currentEntity = nil 448 } 449 450 context.delete(entity) 451 try context.save() 452 onGameDeleted(deletion) 453 } 454 455 // MARK: - Resign a game 456 457 /// Reveals all cells and marks the game as completed (resigned). 458 func resignGame(id: UUID) throws { 459 let (game, mutator) = try loadGame(id: id) 460 let allCells = game.puzzle.cells.flatMap { $0 } 461 mutator.revealCells(allCells) 462 463 guard let entity = currentEntity else { return } 464 entity.completedAt = Date() 465 try context.save() 466 if let ckName = entity.ckRecordName { 467 onGameUpdated(ckName) 468 } 469 Task { await movesUpdater.flush() } 470 471 // Clean up current references 472 currentGame = nil 473 currentMutator = nil 474 currentEntity = nil 475 } 476 477 /// Marks a game as completed after a normal win. No-ops if already marked. 478 /// Triggers a buffer flush so the completion snapshot is created promptly 479 /// rather than waiting for the next keystroke or app-background event. 480 func markCompleted(id: UUID) { 481 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 482 request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 483 request.fetchLimit = 1 484 guard let entity = try? context.fetch(request).first, 485 entity.completedAt == nil else { return } 486 entity.completedAt = Date() 487 try? context.save() 488 if let ckName = entity.ckRecordName { 489 onGameUpdated(ckName) 490 } 491 Task { await movesUpdater.flush() } 492 } 493 494 // MARK: - Reset 495 496 /// Deletes every game (and its cascaded moves, snapshots, and cells) plus 497 /// the sync-state row. Used by the diagnostics reset button. 498 func resetAllData() { 499 for entity in (try? context.fetch(NSFetchRequest<GameEntity>(entityName: "GameEntity"))) ?? [] { 500 context.delete(entity) 501 } 502 for entity in (try? context.fetch(NSFetchRequest<SyncStateEntity>(entityName: "SyncStateEntity"))) ?? [] { 503 context.delete(entity) 504 } 505 try? context.save() 506 currentGame = nil 507 currentMutator = nil 508 currentEntity = nil 509 } 510 511 // MARK: - Legacy convenience 512 513 /// Returns the single current game and its mutator, creating from 514 /// `sample.xd` on first launch. Subsequent launches rehydrate the 515 /// in-memory `Game` from the stored `CellEntity` rows so any prior 516 /// progress is restored. 517 func loadOrCreateCurrentGame() throws -> (Game, GameMutator) { 518 let entity: GameEntity 519 let puzzle: Puzzle 520 521 if let existing = try fetchCurrentEntity() { 522 entity = existing 523 puzzle = try preparePuzzleForLoad(from: existing) 524 } else { 525 (entity, puzzle) = try seedFromSample() 526 } 527 528 let game = Game(puzzle: puzzle) 529 restore(game: game, from: entity) 530 531 let mutator = makeMutator(game: game, entity: entity) 532 533 currentGame = game 534 currentMutator = mutator 535 currentEntity = entity 536 markOtherMovesSeen(for: entity) 537 538 return (game, mutator) 539 } 540 541 // MARK: - Loading 542 543 private func fetchCurrentEntity() throws -> GameEntity? { 544 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 545 request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)] 546 request.fetchLimit = 1 547 return try context.fetch(request).first 548 } 549 550 private func markOtherMovesSeen(for entity: GameEntity) { 551 let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 552 guard isShared, let latest = entity.latestOtherMoveAt else { return } 553 if (entity.lastSeenOtherMoveAt ?? .distantPast) < latest { 554 entity.lastSeenOtherMoveAt = latest 555 try? context.save() 556 } 557 } 558 559 private func seedFromSample() throws -> (GameEntity, Puzzle) { 560 guard let url = Bundle.main.resourceURL? 561 .appendingPathComponent("Puzzles/Debug/sample.xd") else { 562 throw LoadError.sampleResourceMissing 563 } 564 let source = try String(contentsOf: url, encoding: .utf8) 565 let xd = try XD.parse(source) 566 let puzzle = Puzzle(xd: xd) 567 568 let now = Date() 569 let gameID = UUID() 570 let entity = GameEntity(context: context) 571 entity.id = gameID 572 entity.title = puzzle.title 573 entity.puzzleSource = source 574 entity.puzzleCmVersion = Int64(XD.currentCmVersion) 575 entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: source) 576 entity.createdAt = now 577 entity.updatedAt = now 578 entity.ckRecordName = "game-\(gameID.uuidString)" 579 entity.ckZoneName = "game-\(gameID.uuidString)" 580 entity.databaseScope = 0 581 entity.populateCachedSummaryFields(from: puzzle) 582 583 try context.save() 584 onGameCreated("game-\(gameID.uuidString)") 585 return (entity, puzzle) 586 } 587 588 private func preparePuzzleForLoad(from entity: GameEntity) throws -> Puzzle { 589 guard let source = entity.puzzleSource else { 590 throw LoadError.persistedSourceMissing 591 } 592 593 let currentVersion = Int64(XD.currentCmVersion) 594 if entity.puzzleCmVersion != currentVersion { 595 let catalogSource = PuzzleCatalog.source( 596 matchingResourceID: entity.puzzleResourceID, 597 title: try? XD.parse(source).title 598 ) 599 let nextSource = catalogSource?.source ?? source 600 let nextXD = try XD.parse(nextSource) 601 let puzzle = Puzzle(xd: nextXD) 602 entity.title = puzzle.title 603 entity.puzzleSource = nextSource 604 entity.puzzleCmVersion = currentVersion 605 if let catalogSource { 606 entity.puzzleResourceID = catalogSource.id 607 } else if entity.puzzleResourceID == nil { 608 entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: nextSource) 609 } 610 entity.populateCachedSummaryFields(from: puzzle) 611 try context.save() 612 return puzzle 613 } 614 615 if entity.puzzleResourceID == nil { 616 entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: source) 617 if context.hasChanges { try context.save() } 618 } 619 620 return Puzzle(xd: try XD.parse(source)) 621 } 622 623 private func restore(game: Game, from entity: GameEntity, updateCache: Bool = true) { 624 let movesRequest = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 625 movesRequest.predicate = NSPredicate(format: "game == %@", entity) 626 let movesEntities = (try? context.fetch(movesRequest)) ?? [] 627 let values: [MovesValue] = movesEntities.compactMap { Self.movesValue(from: $0) } 628 let grid = GridStateMerger.merge(values) 629 630 for (position, cell) in grid { 631 let r = position.row 632 let c = position.col 633 guard r >= 0, r < game.puzzle.height, c >= 0, c < game.puzzle.width else { continue } 634 game.squares[r][c].entry = cell.letter 635 game.squares[r][c].mark = decodeMark(kind: cell.markKind, checkedWrong: cell.checkedWrong) 636 game.squares[r][c].letterAuthorID = cell.authorID 637 } 638 game.recomputeCompletionCache() 639 640 if updateCache { 641 updateCellCache(for: entity, from: grid) 642 } 643 } 644 645 private func updateCellCache(for gameEntity: GameEntity, from grid: GridState) { 646 Self.applyCellCache(to: gameEntity, from: grid, in: context) 647 try? context.save() 648 } 649 650 /// Hydrates a `MovesValue` from a `MovesEntity`. Returns `nil` if the row 651 /// is missing required fields. 652 fileprivate nonisolated static func movesValue(from entity: MovesEntity) -> MovesValue? { 653 guard let gameID = entity.game?.id, 654 let authorID = entity.authorID, 655 let deviceID = entity.deviceID, 656 let updatedAt = entity.updatedAt 657 else { return nil } 658 let cells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:] 659 return MovesValue( 660 gameID: gameID, 661 authorID: authorID, 662 deviceID: deviceID, 663 cells: cells, 664 updatedAt: updatedAt 665 ) 666 } 667 668 /// Reconciles a `GameEntity`'s `CellEntity` cache against `grid` inside 669 /// `ctx`. Caller is responsible for saving `ctx`. Used from both the 670 /// main-context `updateCellCache` and the background-context 671 /// `replayCellCaches`. 672 fileprivate nonisolated static func applyCellCache( 673 to gameEntity: GameEntity, 674 from grid: GridState, 675 in ctx: NSManagedObjectContext 676 ) { 677 let cellEntities = (gameEntity.cells as? Set<CellEntity>) ?? [] 678 var existing: [GridPosition: CellEntity] = [:] 679 for ce in cellEntities { 680 existing[GridPosition(row: Int(ce.row), col: Int(ce.col))] = ce 681 } 682 683 for (position, cell) in grid { 684 let ce: CellEntity 685 if let found = existing[position] { 686 ce = found 687 } else { 688 ce = CellEntity(context: ctx) 689 ce.row = Int16(position.row) 690 ce.col = Int16(position.col) 691 ce.game = gameEntity 692 } 693 ce.letter = cell.letter 694 ce.markKind = cell.markKind 695 ce.checkedWrong = cell.checkedWrong 696 ce.letterAuthorID = cell.authorID 697 } 698 699 for (position, ce) in existing where grid[position] == nil { 700 ce.letter = "" 701 ce.markKind = 0 702 ce.checkedWrong = false 703 ce.letterAuthorID = nil 704 } 705 } 706 707 /// Marks the active game read-only when the sync engine sees its shared 708 /// zone disappear from the shared database (owner revoked access). 709 func markAccessRevoked(gameID: UUID) { 710 guard currentEntity?.id == gameID else { return } 711 currentMutator?.isAccessRevoked = true 712 } 713 714 /// Called after the sync engine deletes a `GameEntity` in response to a 715 /// remote private-DB zone deletion (the user removed this game on another 716 /// device). The deletion itself has already been merged into the view 717 /// context; this method's only job is to drop the active references if 718 /// the open puzzle is the one that just disappeared, so the UI doesn't 719 /// dereference a deleted managed object. 720 func handleRemoteRemoval(gameID: UUID) { 721 guard currentEntity?.id == gameID else { return } 722 currentGame = nil 723 currentMutator = nil 724 currentEntity = nil 725 } 726 727 /// Flips the active game's mutator to shared after `ShareController` 728 /// saves a `CKShare`, so an open `PuzzleView` reacts (builds the roster, 729 /// starts publishing the local selection) without requiring the user to re-open. 730 func markShared(gameID: UUID) { 731 guard currentEntity?.id == gameID else { return } 732 currentMutator?.isShared = true 733 } 734 735 private func makeMutator(game: Game, entity: GameEntity) -> GameMutator { 736 guard let gameID = entity.id else { 737 fatalError("GameEntity missing id — data model invariant violated") 738 } 739 return GameMutator( 740 game: game, 741 gameID: gameID, 742 movesUpdater: movesUpdater, 743 authorIDProvider: authorIDProvider, 744 isOwned: entity.databaseScope == 0, 745 isShared: entity.ckShareRecordName != nil || entity.databaseScope == 1, 746 isAccessRevoked: entity.isAccessRevoked 747 ) 748 } 749 750 751 // MARK: - CellMark coding 752 753 private func decodeMark(kind: Int16, checkedWrong: Bool) -> CellMark { 754 switch kind { 755 case 1: return .pen(checkedWrong: checkedWrong) 756 case 2: return .pencil(checkedWrong: checkedWrong) 757 case 3: return .revealed 758 default: return .none 759 } 760 } 761 }