crossmate

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

GameStore.swift (118760B)


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