crossmate

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

GameStore.swift (148344B)


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