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 }