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 }