crossmate

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

ReplayLoader.swift (6204B)


      1 import Foundation
      2 
      3 /// Loads finished-game replays and caches their assembled timelines for the
      4 /// session, extracted from `AppServices`. A finished game's journals are
      5 /// frozen, so a fully-merged timeline never changes once built.
      6 @MainActor
      7 final class ReplayLoader {
      8     private let store: GameStore
      9     private let syncEngine: SyncEngine
     10     private let syncMonitor: SyncMonitor
     11 
     12     init(store: GameStore, syncEngine: SyncEngine, syncMonitor: SyncMonitor) {
     13         self.store = store
     14         self.syncEngine = syncEngine
     15         self.syncMonitor = syncMonitor
     16     }
     17 
     18     /// Assembled replay timelines, keyed by game. A finished game's journals are
     19     /// frozen (edit-lockout), so its timeline never changes once built — caching
     20     /// it here lets a `ReplayControls` instance recreated by rapid
     21     /// finish-banner nav re-entry skip re-running `ReplayAssembler.assemble`.
     22     /// Only `.ready` results land here; `.waiting`/`.unavailable` stay retryable.
     23     private var replayTimelineCache: [UUID: ReplayTimeline] = [:]
     24 
     25     /// A previously assembled timeline for `gameID`, if one was cached this
     26     /// session.
     27     func cachedReplayTimeline(gameID: UUID) -> ReplayTimeline? {
     28         replayTimelineCache[gameID]
     29     }
     30 
     31     /// Caches a fully assembled timeline so re-entry skips the re-merge. Safe
     32     /// because the caller only ever passes a finished game's `.ready` result,
     33     /// whose journals are frozen.
     34     func cacheReplayTimeline(_ timeline: ReplayTimeline, gameID: UUID) {
     35         replayTimelineCache[gameID] = timeline
     36     }
     37 
     38     /// Loads a finished game's replay: fetches every device's journal from
     39     /// CloudKit, overlays this device's live log, and gates on strict
     40     /// completeness. `.ready` carries a merged timeline; `.waiting(missing:)`
     41     /// means some contributing device hasn't uploaded its journal yet (the
     42     /// scrubber stays disabled until it does); `.unavailable` means the game's
     43     /// zone can't be reached. The fetch is a plain CKQuery, so it's safe to call
     44     /// from the UI when the finish banner appears.
     45     func loadReplay(gameID: UUID) async -> JournalReplayResult {
     46         let short = gameID.uuidString.prefix(8)
     47         func describe(_ result: JournalReplayResult) -> String {
     48             switch result {
     49             case .ready(let timeline): return "ready(steps=\(timeline.count))"
     50             case .waiting(let missing): return "waiting(missing=\(missing))"
     51             case .unavailable: return "unavailable"
     52             }
     53         }
     54         if let blocker = await store.archivedReplayBlocker(forGameID: gameID) {
     55             switch blocker {
     56             case .waiting(let missing):
     57                 syncMonitor.note(
     58                     "replay[\(short)]: Chronicle waiting for \(missing) device journal(s)"
     59                 )
     60             case .unavailable:
     61                 syncMonitor.note("replay[\(short)]: unavailable by archive retention policy")
     62             case .ready:
     63                 assertionFailure("A ready replay is not a blocker")
     64             }
     65             return blocker
     66         }
     67         // This device's live journal is always overlaid (fresher than any
     68         // uploaded copy of itself), whether the contributors' journals come
     69         // from the local cache or a fresh CloudKit fetch.
     70         let local = store.localReplaySource(gameID: gameID)
     71         let localKey = local?.key ?? JournalDeviceKey(authorID: "", deviceID: "")
     72         let localEntries = local?.entries ?? []
     73 
     74         // Completed-game journals are frozen (edit-lockout), so once every
     75         // contributor's journal has been fetched in full we cache the remote
     76         // ones locally and re-merge from Core Data — no CloudKit round-trip on
     77         // re-entry. The cache is `nil` until that first complete fetch lands.
     78         if let cachedRemotes = await store.cachedRemoteJournals(forGameID: gameID) {
     79             let result = ReplayAssembler.assemble(
     80                 fetch: JournalReplayFetch(
     81                     journals: cachedRemotes,
     82                     expectedDevices: Set(cachedRemotes.map(\.key))
     83                 ),
     84                 localKey: localKey,
     85                 localEntries: localEntries
     86             )
     87             syncMonitor.note(
     88                 "replay[\(short)]: served from cache — remoteDevices=\(cachedRemotes.count), " +
     89                 "localEntries=\(localEntries.count) → \(describe(result))"
     90             )
     91             return result
     92         }
     93 
     94         // A nil return means zone unknown / access revoked; a throw means the
     95         // on-demand CKQuery itself failed. Both flatten to `.unavailable`, but
     96         // log which one (and the error) so the diagnostics stream can tell them
     97         // apart — the replay fetch is otherwise invisible to the event log.
     98         let fetch: JournalReplayFetch?
     99         do {
    100             fetch = try await syncEngine.fetchReplay(forGameID: gameID)
    101         } catch {
    102             let ns = error as NSError
    103             syncMonitor.note(
    104                 "replay[\(short)]: fetch threw — domain=\(ns.domain) " +
    105                 "code=\(ns.code) \(ns.localizedDescription)"
    106             )
    107             return .unavailable
    108         }
    109         guard let fetch else {
    110             syncMonitor.note("replay[\(short)]: fetch unavailable (zone unknown / access revoked)")
    111             return .unavailable
    112         }
    113         let result = ReplayAssembler.assemble(
    114             fetch: fetch,
    115             localKey: localKey,
    116             localEntries: localEntries
    117         )
    118         // A complete merge will never change (the game is finished), so cache
    119         // the *remote* journals for offline re-entry. Our own copy is excluded:
    120         // the live local journal is overlaid fresh on every load.
    121         if case .ready = result {
    122             await store.cacheRemoteJournals(
    123                 fetch.journals.filter { $0.key != localKey },
    124                 forGameID: gameID
    125             )
    126         }
    127         syncMonitor.note(
    128             "replay[\(short)]: merged — expected=\(fetch.expectedDevices.count), " +
    129             "journals=\(fetch.journals.count), localEntries=\(localEntries.count) " +
    130             "→ \(describe(result))"
    131         )
    132         return result
    133     }
    134 }