crossmate

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

GameStore.swift (152487B)


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