crossmate

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

ReplayController.swift (8066B)


      1 import Foundation
      2 import Observation
      3 
      4 /// Tunable knobs for the finish-banner replay autoplay, persisted in
      5 /// `UserDefaults` and editable from the Debugging menu. Reads fall back to the
      6 /// shipping defaults whenever a key is unset, so an empty store behaves exactly
      7 /// as it did before these became adjustable.
      8 enum ReplayTuning {
      9     enum Key {
     10         static let speedMs = ["replay.speed1Ms", "replay.speed2Ms", "replay.speed3Ms"]
     11         static let offOpacity = "replay.offOpacity"
     12     }
     13 
     14     /// Default step interval (ms) per speed level, fastest last.
     15     static let defaultSpeedMs = [350, 160, 70]
     16     /// Default contrast of an unlit ("off") fast-forward arrow.
     17     static let defaultOffOpacity = 0.3
     18 
     19     /// Step interval in milliseconds for `speed` (`1...defaultSpeedMs.count`),
     20     /// from the store or the matching default when unset (stored `0`).
     21     static func stepMilliseconds(forSpeed speed: Int) -> Int {
     22         let index = speed - 1
     23         guard defaultSpeedMs.indices.contains(index) else { return defaultSpeedMs.last ?? 0 }
     24         let stored = UserDefaults.standard.integer(forKey: Key.speedMs[index])
     25         return stored > 0 ? stored : defaultSpeedMs[index]
     26     }
     27 
     28     /// Contrast of an unlit arrow, from the store or the default when unset.
     29     static var offOpacity: Double {
     30         UserDefaults.standard.object(forKey: Key.offOpacity) as? Double ?? defaultOffOpacity
     31     }
     32 }
     33 
     34 /// An immutable snapshot of the scrubber at one position — exactly what
     35 /// `GridView` needs to render a rewound frame, decoupled from the controller's
     36 /// mutable scrub/load state. A `nil` frame means "render the live grid".
     37 struct ReplayFrame: Equatable {
     38     /// Each touched cell's after-state at this position; cells absent here
     39     /// render blank.
     40     let cells: [GridPosition: JournalCellState]
     41     /// The cell the most recent step changed — the playhead — or `nil` for a
     42     /// batched gesture, which has no single focus to highlight.
     43     let cursor: GridPosition?
     44     /// Who made that move, so the playhead can take their colour.
     45     let cursorAuthorID: String?
     46 }
     47 
     48 /// View-model for the finish-banner replay scrubber. Loads a finished game's
     49 /// merged journal (Phase 2b) once the banner appears, then exposes a scrub
     50 /// position and the per-cell grid override that `GridView` renders while the
     51 /// user drags back through history.
     52 ///
     53 /// Held as `@State` by `PuzzleView` so the position survives re-renders; it is
     54 /// `.idle` (no override, live grid) until a solved game's banner triggers
     55 /// `load`.
     56 @MainActor
     57 @Observable
     58 final class ReplayController {
     59     enum Status: Equatable {
     60         case idle
     61         case loading
     62         case ready(ReplayTimeline)
     63         case waiting(missing: Int)
     64         case unavailable
     65     }
     66 
     67     private(set) var status: Status = .idle
     68 
     69     /// Bumped by `retry()` so a `.task(id:)` re-fires `load` — the only way to
     70     /// re-check after a `.waiting` result without polling.
     71     private(set) var reloadToken = 0
     72 
     73     /// Scrub position in `0...timeline.count`. Resting at `count` shows the
     74     /// finished grid (override is `nil`, so the live `Game` renders); dragging
     75     /// left rewinds. Set to `count` when a timeline loads so the head starts
     76     /// hard-right at the end of the game.
     77     var position: Int = 0
     78 
     79     /// The number of speed steps the fast-forward control cycles through, and
     80     /// the number of arrows it draws.
     81     static let maxPlaybackSpeed = 3
     82 
     83     /// Autoplay speed: `0` is stopped; `1...maxPlaybackSpeed` advance the
     84     /// position automatically, faster at each step. The fast-forward control
     85     /// cycles `0 → 1 → … → max → 0`, filling one more arrow per step, and the
     86     /// user grabbing the scrubber resets it to `0`.
     87     var playbackSpeed: Int = 0
     88 
     89     /// Seconds between autoplay steps at the current speed, or `nil` when
     90     /// stopped. Faster speeds step sooner; the per-speed intervals come from
     91     /// `ReplayTuning` so they can be tuned live from the Debugging menu.
     92     var playbackStepInterval: Duration? {
     93         guard playbackSpeed > 0 else { return nil }
     94         return .milliseconds(ReplayTuning.stepMilliseconds(forSpeed: playbackSpeed))
     95     }
     96 
     97     /// Advances the fast-forward control one notch, wrapping past the top back
     98     /// to stopped.
     99     func cyclePlaybackSpeed() {
    100         playbackSpeed = (playbackSpeed + 1) % (Self.maxPlaybackSpeed + 1)
    101     }
    102 
    103     /// Stops autoplay — called when the user grabs the scrubber, so a manual
    104     /// scrub always wins and the arrows fall back to off.
    105     func stopPlayback() {
    106         playbackSpeed = 0
    107     }
    108 
    109     /// Advances one autoplay step, looping back to the start once the head
    110     /// reaches the end. A no-op when stopped or before a timeline loads.
    111     func advancePlayback() {
    112         guard let timeline, playbackSpeed > 0 else { return }
    113         position = position >= timeline.count ? 0 : position + 1
    114     }
    115 
    116     var timeline: ReplayTimeline? {
    117         if case .ready(let timeline) = status { return timeline }
    118         return nil
    119     }
    120 
    121     /// A scrubber is offered only for a ready, non-empty timeline.
    122     var isScrubbable: Bool {
    123         (timeline?.count ?? 0) > 0
    124     }
    125 
    126     /// The grid to render at the current position, or `nil` to show the live
    127     /// finished grid (head at the far right, or nothing loaded). Recomputed per
    128     /// scrub tick — `state(through:)` is O(position), trivial for one game.
    129     var gridOverride: [GridPosition: JournalCellState]? {
    130         guard let timeline, position < timeline.count else { return nil }
    131         return timeline.state(through: position)
    132     }
    133 
    134     /// The cell changed by the most recently applied step — the replay
    135     /// playhead, highlighted so the eye can follow the rewind. Tracks
    136     /// `gridOverride`: `nil` at rest (head at the far right, live grid shown),
    137     /// non-nil only while actively scrubbed back.
    138     var cursor: GridPosition? {
    139         guard let timeline, position > 0, position < timeline.count else { return nil }
    140         return timeline.focus(ofStep: position - 1)
    141     }
    142 
    143     /// The author whose move the playhead highlights, used to tint it in that
    144     /// author's colour so the rewind reads as each player's moves in turn.
    145     /// Tracks `cursor`: `nil` whenever there is no playhead.
    146     var cursorAuthorID: String? {
    147         guard let timeline, position > 0, position < timeline.count else { return nil }
    148         return timeline.actingAuthor(ofStep: position - 1)
    149     }
    150 
    151     /// The frame `GridView` should render, bundling the grid override with the
    152     /// playhead and its author. `nil` exactly when `gridOverride` is — i.e. at
    153     /// rest (head hard-right), where the live finished grid shows instead.
    154     var frame: ReplayFrame? {
    155         guard let cells = gridOverride else { return nil }
    156         return ReplayFrame(cells: cells, cursor: cursor, cursorAuthorID: cursorAuthorID)
    157     }
    158 
    159     /// Loads the replay via the caller's loader. Idempotent for an in-flight or
    160     /// ready load; a `.waiting` / `.unavailable` result stays retryable, so
    161     /// `retry()` (driven by the inbound-journal sync signal, or the manual
    162     /// button) can re-check when a contributor's journal finally syncs.
    163     func load(_ loader: () async -> JournalReplayResult) async {
    164         switch status {
    165         case .loading, .ready:
    166             return
    167         case .idle, .waiting, .unavailable:
    168             break
    169         }
    170         status = .loading
    171         let result = await loader()
    172         switch result {
    173         case .ready(let timeline):
    174             status = .ready(timeline)
    175             position = timeline.count
    176         case .waiting(let missing):
    177             status = .waiting(missing: missing)
    178         case .unavailable:
    179             status = .unavailable
    180         }
    181     }
    182 
    183     /// Drops back to `.idle` so the next `load` re-checks. Triggered by the
    184     /// inbound-journal sync signal (a contributor's journal arrived) and by the
    185     /// manual "Check again" affordance.
    186     func retry() {
    187         status = .idle
    188         playbackSpeed = 0
    189         reloadToken += 1
    190     }
    191 }