crossmate

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

ReplayLoader.swift (5643B)


      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         // This device's live journal is always overlaid (fresher than any
     55         // uploaded copy of itself), whether the contributors' journals come
     56         // from the local cache or a fresh CloudKit fetch.
     57         let local = store.localReplaySource(gameID: gameID)
     58         let localKey = local?.key ?? JournalDeviceKey(authorID: "", deviceID: "")
     59         let localEntries = local?.entries ?? []
     60 
     61         // Completed-game journals are frozen (edit-lockout), so once every
     62         // contributor's journal has been fetched in full we cache the remote
     63         // ones locally and re-merge from Core Data — no CloudKit round-trip on
     64         // re-entry. The cache is `nil` until that first complete fetch lands.
     65         if let cachedRemotes = await store.cachedRemoteJournals(forGameID: gameID) {
     66             let result = ReplayAssembler.assemble(
     67                 fetch: JournalReplayFetch(
     68                     journals: cachedRemotes,
     69                     expectedDevices: Set(cachedRemotes.map(\.key))
     70                 ),
     71                 localKey: localKey,
     72                 localEntries: localEntries
     73             )
     74             syncMonitor.note(
     75                 "replay[\(short)]: served from cache — remoteDevices=\(cachedRemotes.count), " +
     76                 "localEntries=\(localEntries.count) → \(describe(result))"
     77             )
     78             return result
     79         }
     80 
     81         // A nil return means zone unknown / access revoked; a throw means the
     82         // on-demand CKQuery itself failed. Both flatten to `.unavailable`, but
     83         // log which one (and the error) so the diagnostics stream can tell them
     84         // apart — the replay fetch is otherwise invisible to the event log.
     85         let fetch: JournalReplayFetch?
     86         do {
     87             fetch = try await syncEngine.fetchReplay(forGameID: gameID)
     88         } catch {
     89             let ns = error as NSError
     90             syncMonitor.note(
     91                 "replay[\(short)]: fetch threw — domain=\(ns.domain) " +
     92                 "code=\(ns.code) \(ns.localizedDescription)"
     93             )
     94             return .unavailable
     95         }
     96         guard let fetch else {
     97             syncMonitor.note("replay[\(short)]: fetch unavailable (zone unknown / access revoked)")
     98             return .unavailable
     99         }
    100         let result = ReplayAssembler.assemble(
    101             fetch: fetch,
    102             localKey: localKey,
    103             localEntries: localEntries
    104         )
    105         // A complete merge will never change (the game is finished), so cache
    106         // the *remote* journals for offline re-entry. Our own copy is excluded:
    107         // the live local journal is overlaid fresh on every load.
    108         if case .ready = result {
    109             await store.cacheRemoteJournals(
    110                 fetch.journals.filter { $0.key != localKey },
    111                 forGameID: gameID
    112             )
    113         }
    114         syncMonitor.note(
    115             "replay[\(short)]: merged — expected=\(fetch.expectedDevices.count), " +
    116             "journals=\(fetch.journals.count), localEntries=\(localEntries.count) " +
    117             "→ \(describe(result))"
    118         )
    119         return result
    120     }
    121 }