ReplayControls.swift (9042B)
1 import Foundation 2 import Observation 3 4 /// Step intervals for the finish-banner replay autoplay, one per speed level. 5 enum ReplayTuning { 6 /// Step interval (ms) per speed level, fastest last. 7 static let speedMs = [350, 160, 70] 8 9 /// Step interval in milliseconds for `speed` (`1...speedMs.count`). 10 static func stepMilliseconds(forSpeed speed: Int) -> Int { 11 let index = speed - 1 12 guard speedMs.indices.contains(index) else { return speedMs.last ?? 0 } 13 return speedMs[index] 14 } 15 } 16 17 /// An immutable snapshot of the scrubber at one position — exactly what 18 /// `GridView` needs to render a rewound frame, decoupled from the controls' 19 /// mutable scrub/load state. A `nil` frame means "render the live grid". 20 struct ReplayFrame: Equatable { 21 /// Each touched cell's after-state at this position; cells absent here 22 /// render blank. 23 let cells: [GridPosition: JournalCellState] 24 /// The cell the most recent step changed — the playhead — or `nil` for a 25 /// batched gesture, which has no single focus to highlight. 26 let cursor: GridPosition? 27 /// Who made that move, so the playhead can take their colour. 28 let cursorAuthorID: String? 29 /// The direction the acting player had when the step was recorded. Older 30 /// decoded replay rows may not carry this, so clue display falls back to 31 /// puzzle geometry only when the cell is unambiguous. 32 let cursorDirection: Puzzle.Direction? 33 } 34 35 /// View-model for the finish-banner replay scrubber. Loads a finished game's 36 /// merged journal (Phase 2b) once the banner appears, then exposes a scrub 37 /// position and the per-cell grid override that `GridView` renders while the 38 /// user drags back through history. 39 /// 40 /// Held as `@State` by `PuzzleView` so the position survives re-renders; it is 41 /// `.idle` (no override, live grid) until a solved game's banner triggers 42 /// `load`. 43 @MainActor 44 @Observable 45 final class ReplayControls { 46 enum Status: Equatable { 47 case idle 48 case loading 49 case ready(ReplayTimeline) 50 case waiting(missing: Int) 51 case unavailable 52 } 53 54 private(set) var status: Status = .idle 55 56 /// Bumped by `retry()` so a `.task(id:)` re-fires `load` — the only way to 57 /// re-check after a `.waiting` result without polling. 58 private(set) var reloadToken = 0 59 60 /// Scrub position in `0...timeline.count`. Resting at `count` shows the 61 /// finished grid (override is `nil`, so the live `Game` renders); dragging 62 /// left rewinds. Set to `count` when a timeline loads so the head starts 63 /// hard-right at the end of the game. 64 var position: Int = 0 65 66 /// The number of replay speed steps offered by the speed control. 67 static let maxPlaybackSpeed = 3 68 69 /// The speed to use when playback starts or resumes. 70 var selectedPlaybackSpeed: Int = 1 71 72 /// Whether the replay is actively advancing. 73 var isPlaybackActive = false 74 75 /// Seconds between autoplay steps at the current speed, or `nil` when 76 /// stopped. Faster speeds step sooner; the per-speed intervals come from 77 /// `ReplayTuning`. 78 var playbackStepInterval: Duration? { 79 guard isPlaybackActive else { return nil } 80 return .milliseconds(ReplayTuning.stepMilliseconds(forSpeed: selectedPlaybackSpeed)) 81 } 82 83 /// Advances the playback speed one notch, wrapping back to the slowest 84 /// speed after the fastest. 85 func cycleSelectedPlaybackSpeed() { 86 selectedPlaybackSpeed = selectedPlaybackSpeed >= Self.maxPlaybackSpeed ? 1 : selectedPlaybackSpeed + 1 87 } 88 89 /// Toggles autoplay without losing the selected speed, so pause/resume 90 /// preserves the user's pace. Starting with the head already at the end 91 /// rewinds to the beginning first: playback runs through once and stops 92 /// there, so pressing play at rest replays the game instead of doing 93 /// nothing. 94 func togglePlayback() { 95 if !isPlaybackActive, let timeline, position >= timeline.count { 96 position = 0 97 } 98 isPlaybackActive.toggle() 99 } 100 101 /// Pauses autoplay — called when the user grabs the scrubber, so a manual 102 /// scrub always wins without changing the selected speed. 103 func pausePlayback() { 104 isPlaybackActive = false 105 } 106 107 /// Advances one autoplay step, stopping once the head reaches the end 108 /// rather than looping — the run ends on the finished grid and stays there. 109 /// A no-op when stopped or before a timeline loads. 110 func advancePlayback() { 111 guard let timeline, isPlaybackActive else { return } 112 position = min(position + 1, timeline.count) 113 if position >= timeline.count { 114 isPlaybackActive = false 115 } 116 } 117 118 var timeline: ReplayTimeline? { 119 if case .ready(let timeline) = status { return timeline } 120 return nil 121 } 122 123 /// A scrubber is offered only for a ready, non-empty timeline. 124 var isScrubbable: Bool { 125 (timeline?.count ?? 0) > 0 126 } 127 128 /// The grid to render at the current position, or `nil` to show the live 129 /// finished grid (head at the far right, or nothing loaded). Recomputed per 130 /// scrub tick — `state(through:)` is O(position), trivial for one game. 131 var gridOverride: [GridPosition: JournalCellState]? { 132 guard let timeline, position < timeline.count else { return nil } 133 return timeline.state(through: position) 134 } 135 136 /// The cell changed by the most recently applied step — the replay 137 /// playhead, highlighted so the eye can follow the rewind. Tracks 138 /// `gridOverride`: `nil` at rest (head at the far right, live grid shown), 139 /// non-nil only while actively scrubbed back. 140 var cursor: GridPosition? { 141 guard let timeline, position > 0, position < timeline.count else { return nil } 142 return timeline.focus(ofStep: position - 1) 143 } 144 145 /// The author whose move the playhead highlights, used to tint it in that 146 /// author's colour so the rewind reads as each player's moves in turn. 147 /// Tracks `cursor`: `nil` whenever there is no playhead. 148 var cursorAuthorID: String? { 149 guard let timeline, position > 0, position < timeline.count else { return nil } 150 return timeline.actingAuthor(ofStep: position - 1) 151 } 152 153 /// The direction of the most recently applied single-cell step, used by 154 /// the clue bar during replay. `nil` for batched gestures, older decoded 155 /// rows, or entries that did not record a cursor direction. 156 var cursorDirection: Puzzle.Direction? { 157 guard let timeline, position > 0, position < timeline.count else { return nil } 158 return timeline.direction(ofStep: position - 1) 159 } 160 161 /// The frame `GridView` should render, bundling the grid override with the 162 /// playhead and its author. `nil` exactly when `gridOverride` is — i.e. at 163 /// rest (head hard-right), where the live finished grid shows instead. 164 var frame: ReplayFrame? { 165 guard let cells = gridOverride else { return nil } 166 return ReplayFrame( 167 cells: cells, 168 cursor: cursor, 169 cursorAuthorID: cursorAuthorID, 170 cursorDirection: cursorDirection 171 ) 172 } 173 174 /// Loads the replay via the caller's loader. Idempotent for an in-flight or 175 /// ready load; a `.waiting` / `.unavailable` result stays retryable, so 176 /// `retry()` (driven by the inbound-journal sync signal, or the manual 177 /// button) can re-check when a contributor's journal finally syncs. 178 func load(_ loader: () async -> JournalReplayResult) async { 179 switch status { 180 case .loading, .ready: 181 return 182 case .idle, .waiting, .unavailable: 183 break 184 } 185 status = .loading 186 let result = await loader() 187 switch result { 188 case .ready(let timeline): 189 status = .ready(timeline) 190 #if DEBUG 191 position = Self.marketingInitialPosition(in: timeline) ?? timeline.count 192 #else 193 position = timeline.count 194 #endif 195 case .waiting(let missing): 196 status = .waiting(missing: missing) 197 case .unavailable: 198 status = .unavailable 199 } 200 } 201 202 #if DEBUG 203 private static func marketingInitialPosition(in timeline: ReplayTimeline) -> Int? { 204 let arguments = ProcessInfo.processInfo.arguments 205 guard let index = arguments.firstIndex(of: "--crossmate-marketing-replay-position"), 206 arguments.indices.contains(index + 1), 207 let value = Int(arguments[index + 1]) 208 else { return nil } 209 return min(max(value, 0), timeline.count) 210 } 211 #endif 212 213 /// Drops back to `.idle` so the next `load` re-checks. Triggered by the 214 /// inbound-journal sync signal (a contributor's journal arrived) and by the 215 /// manual "Check Again" affordance. 216 func retry() { 217 status = .idle 218 isPlaybackActive = false 219 selectedPlaybackSpeed = 1 220 reloadToken += 1 221 } 222 }