JournalReplay.swift (8233B)
1 import Foundation 2 3 /// Identifies one device's journal within a game: the `(authorID, deviceID)` 4 /// pair that names a `Journal` (and `Moves`) record. Faithful replay needs one 5 /// journal per device that ever wrote grid state. 6 struct JournalDeviceKey: Hashable, Sendable { 7 let authorID: String 8 let deviceID: String 9 } 10 11 /// One device's decoded move log, tagged with the device it came from. 12 struct DeviceJournal: Sendable { 13 let key: JournalDeviceKey 14 let entries: [JournalValue] 15 } 16 17 /// What `SyncEngine.fetchReplay` pulls from a finished game's zone: every 18 /// device's uploaded journal, plus the set of devices that wrote grid state 19 /// (derived from the `Moves` record names). Replay is only faithful once a 20 /// journal is present for every expected device — see `ReplayAssembler`. 21 struct JournalReplayFetch: Sendable { 22 let journals: [DeviceJournal] 23 let expectedDevices: Set<JournalDeviceKey> 24 } 25 26 /// The outcome of assembling a replay. `.waiting` means at least one 27 /// contributing device hasn't uploaded its journal yet (the scrubber stays 28 /// disabled until it does); `.unavailable` means the game's zone can't be 29 /// reached (unknown locally, or access revoked). 30 enum JournalReplayResult: Sendable, Equatable { 31 case ready(ReplayTimeline) 32 case waiting(missing: Int) 33 case unavailable 34 } 35 36 /// A merged, chronological replay of a finished game, reconstructed from every 37 /// device's journal. Forward replay needs only each entry's *after-state*, so 38 /// the device-local `seq` / `prevSeqAtCell` links (which exist for undo 39 /// derivation) are ignored here — undo, check, and reveal rows replay 40 /// naturally because each already carries the cell state it produced and the 41 /// wall-clock instant it happened. Pure and `Sendable`: no CloudKit, no Core 42 /// Data, so it can be unit-tested directly. 43 struct ReplayTimeline: Sendable, Equatable { 44 /// Replay steps in order. Each step is one scrub increment: either a single 45 /// cell touch, or every cell of one batched gesture grouped by `batchID` — 46 /// a bulk reveal/check/clear, or an undo/redo op — so e.g. "reveal puzzle" 47 /// rewinds as a single event rather than cell by cell. 48 let steps: [[JournalValue]] 49 50 var count: Int { steps.count } 51 52 /// Every entry in replay order, flattened across steps — for inspection. 53 var entries: [JournalValue] { steps.flatMap { $0 } } 54 55 /// Merges each device's log into one timeline ordered by `timestamp`, then 56 /// coalesces runs of same-`batchID` entries into one step. Ties break 57 /// deterministically on `(actingAuthorID, seq)` so two devices that touch 58 /// cells at the same instant always replay in the same order, regardless of 59 /// the order the journals were fetched in. 60 init(merging logs: [[JournalValue]]) { 61 let sorted = logs.flatMap { $0 }.sorted(by: ReplayTimeline.precedes) 62 var grouped: [[JournalValue]] = [] 63 for entry in sorted { 64 if let batchID = entry.batchID, grouped.last?.first?.batchID == batchID { 65 grouped[grouped.count - 1].append(entry) 66 } else { 67 grouped.append([entry]) 68 } 69 } 70 steps = grouped 71 } 72 73 private static func precedes(_ a: JournalValue, _ b: JournalValue) -> Bool { 74 if a.timestamp != b.timestamp { return a.timestamp < b.timestamp } 75 let lhs = a.actingAuthorID ?? "" 76 let rhs = b.actingAuthorID ?? "" 77 if lhs != rhs { return lhs < rhs } 78 return a.seq < b.seq 79 } 80 81 /// The grid after applying the first `count` steps: each touched position 82 /// mapped to the after-state of its most recent touch. Positions never 83 /// touched are absent (render as blank). `count` is clamped to 84 /// `0...self.count`, so `state(through: 0)` is the empty starting grid and 85 /// `state(through: count)` is the finished puzzle. 86 func state(through count: Int) -> [GridPosition: JournalCellState] { 87 let upper = min(max(count, 0), steps.count) 88 var grid: [GridPosition: JournalCellState] = [:] 89 for index in 0..<upper { 90 for entry in steps[index] { 91 grid[entry.position] = entry.state 92 } 93 } 94 return grid 95 } 96 97 /// The single cell a step changed — the replay playhead. `nil` for a 98 /// batched gesture, which has no single focus to highlight. 99 func focus(ofStep index: Int) -> GridPosition? { 100 guard steps.indices.contains(index) else { return nil } 101 let step = steps[index] 102 return step.count == 1 ? step.first?.position : nil 103 } 104 105 /// Who performed a single-cell step — pairs with `focus(ofStep:)` so the 106 /// playhead can be tinted in the acting author's colour. `nil` for a 107 /// batched gesture (no single playhead) or an entry that carries no author. 108 func actingAuthor(ofStep index: Int) -> String? { 109 guard steps.indices.contains(index) else { return nil } 110 let step = steps[index] 111 return step.count == 1 ? step.first?.actingAuthorID : nil 112 } 113 114 /// The cursor direction recorded with a single-cell step. Older decoded 115 /// replay rows and non-input rows may not carry one. 116 func direction(ofStep index: Int) -> Puzzle.Direction? { 117 guard steps.indices.contains(index) else { return nil } 118 let step = steps[index] 119 return step.count == 1 ? step.first?.direction : nil 120 } 121 } 122 123 /// Composes a `JournalReplayFetch` with this device's *live* journal into a 124 /// gated result. Pure, so the completeness rule is unit-testable without 125 /// CloudKit. 126 /// 127 /// Two reasons the live local log is overlaid rather than trusting the fetched 128 /// copy of ourselves: the completion upload may not have round-tripped yet, and 129 /// even once it has, the in-memory log is the session's authoritative copy. 130 /// 131 /// Strict completeness: a timeline is produced only when a journal is present 132 /// for every expected device; otherwise the caller is told how many are still 133 /// missing so the UI can wait. 134 enum ReplayAssembler { 135 static func assemble( 136 fetch: JournalReplayFetch, 137 localKey: JournalDeviceKey, 138 localEntries: [JournalValue] 139 ) -> JournalReplayResult { 140 // Index fetched journals by device, then overlay this device's live 141 // log (fresher than any uploaded copy of itself). 142 var byDevice: [JournalDeviceKey: [JournalValue]] = [:] 143 for journal in fetch.journals { 144 byDevice[journal.key] = journal.entries 145 } 146 if !localEntries.isEmpty { 147 byDevice[localKey] = localEntries 148 } 149 150 // Expected = every device that wrote grid state. The local device is 151 // implicitly expected when it has a live log, in case its own Moves 152 // record query raced the fetch. 153 var expected = fetch.expectedDevices 154 if !localEntries.isEmpty { 155 expected.insert(localKey) 156 } 157 158 let present = Set(byDevice.keys) 159 let missing = expected.subtracting(present) 160 guard missing.isEmpty else { 161 return .waiting(missing: missing.count) 162 } 163 return .ready(ReplayTimeline(merging: Array(byDevice.values))) 164 } 165 166 /// Gates assembly on a per-game memo so re-entry doesn't re-run the merge. 167 /// A `cached` timeline short-circuits to `.ready` (a finished game's 168 /// journals are frozen, so it can never go stale); otherwise `assemble` 169 /// runs and *only* a `.ready` result is handed to `store` to cache — 170 /// `.waiting`/`.unavailable` stay uncached so they remain retryable. Pure 171 /// orchestration with its IO injected, so it's testable without 172 /// `AppServices`. `onHit` is a logging-only side hook for the cache-hit path. 173 @MainActor 174 static func memoised( 175 cached: ReplayTimeline?, 176 onHit: (ReplayTimeline) -> Void = { _ in }, 177 store: (ReplayTimeline) -> Void, 178 assemble: () async -> JournalReplayResult 179 ) async -> JournalReplayResult { 180 if let cached { 181 onHit(cached) 182 return .ready(cached) 183 } 184 let result = await assemble() 185 if case .ready(let timeline) = result { 186 store(timeline) 187 } 188 return result 189 } 190 }