crossmate

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

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 }