crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }