//! In-engine replay playback core. //! //! When the player clicks "Watch replay" on the Stats overlay, the live //! game state is reset to the recorded deal — rebuilt directly from //! `replay.recording` via `GameState::from_recording`, never by //! re-dealing the seed — and the engine ticks through the recording's //! instruction list at a steady cadence, firing the canonical //! [`MoveRequestEvent`] / [`DrawRequestEvent`] for each one. The //! existing animation pipeline plays back identically to a live game. //! //! ## Public surface //! //! - [`ReplayPlaybackState`] — single source of truth for whether //! playback is live, how far through the move list we've ticked, and //! how long until the next advance. //! - [`start_replay_playback`] — public entry point; the Stats //! "Watch replay" button calls this. Resets the game to the recorded //! deal and transitions the state machine to //! [`ReplayPlaybackState::Playing`]. //! - [`stop_replay_playback`] — interrupts playback at any time. Safe to //! call when [`ReplayPlaybackState::Inactive`]. //! - [`ReplayPlaybackPlugin`] — registers the resource and the tick / //! linger systems. //! //! ## Coordination note //! //! This module is built in parallel with the Stats-side overlay. The //! resource shape, helper signatures, and plugin marker match the //! contract the overlay agent reads against — see also the docs on the //! enum variants. //! //! ## Recording is paused during playback //! //! Playback fires the same [`MoveRequestEvent`] / [`DrawRequestEvent`] //! the live engine handles. Without intervention, [`RecordingReplay`] //! would re-record those events and a replay would re-record itself //! indefinitely. To prevent that, [`record_replay_skip_during_playback`] //! snapshots the recording's length at the start of playback and //! truncates the buffer back to that length every frame. This keeps //! the recording contract opaque to `game_plugin` — no event-source //! flag is threaded through, no every-callsite gate is added. use bevy::prelude::*; use solitaire_core::KlondikeInstruction; use solitaire_data::Replay; use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent}; use crate::game_plugin::{GameMutation, RecordingReplay}; use crate::resources::GameStateResource; use crate::settings_plugin::SettingsResource; /// Default per-move duration during playback, in seconds. Acts as the /// fallback when `SettingsResource` is absent — i.e. in headless test /// fixtures that don't install [`crate::settings_plugin::SettingsPlugin`]. /// In production the live value is read from /// [`solitaire_data::Settings::replay_move_interval_secs`] every frame /// so Settings adjustments take effect on the next playback tick. /// /// Kept in sync with `solitaire_data::settings::default_replay_move_interval_secs` /// (the data crate cannot depend on this engine crate, so the constant /// is duplicated). The /// `settings_replay_move_interval_default_matches_engine_constant` /// test in `solitaire_engine::settings_plugin` enforces equality. pub const REPLAY_MOVE_INTERVAL_SECS: f32 = 0.45; /// Helper: returns the live per-move replay interval. Reads /// [`SettingsResource::replay_move_interval_secs`] when the resource is /// installed, falling back to [`REPLAY_MOVE_INTERVAL_SECS`] otherwise. /// Also clamps below by `f32::EPSILON` so a hand-edited 0.0 cannot /// busy-loop the playback tick. fn current_move_interval_secs(settings: Option<&SettingsResource>) -> f32 { let raw = settings .map(|s| s.0.replay_move_interval_secs) .unwrap_or(REPLAY_MOVE_INTERVAL_SECS); raw.max(f32::EPSILON) } /// How long the [`ReplayPlaybackState::Completed`] state lingers before /// the auto-clear system transitions it back to /// [`ReplayPlaybackState::Inactive`]. Gives the overlay UI time to /// display "Replay complete" before dismissing. pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0; /// Lifecycle state of an in-flight replay playback. /// /// The default state is [`Inactive`](Self::Inactive) — no replay is /// running. The overlay (and any other consumer) reads this resource to /// decide whether the "Replay" banner should be visible and what /// progress to display. /// /// Lifecycle: /// 1. Default state is [`Inactive`](Self::Inactive). /// 2. [`start_replay_playback`] transitions to /// [`Playing`](Self::Playing) and resets the live `GameState` to the /// replay's recorded deal. /// 3. The tick system [`tick_replay_playback`] advances `cursor` once /// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event /// for each [`KlondikeInstruction`]. /// 4. When `cursor == moves.len()`, the state transitions to /// [`Completed`](Self::Completed). It lingers for /// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by /// [`auto_clear_completed_replay`]) before returning to /// [`Inactive`](Self::Inactive). /// 5. [`stop_replay_playback`] interrupts at any time and forces the /// state back to [`Inactive`](Self::Inactive). #[derive(Resource, Debug, Default)] pub enum ReplayPlaybackState { /// No replay is being played back. The overlay despawns itself when /// the resource transitions back to this variant. #[default] Inactive, /// A replay is currently being played back. The overlay reads /// `moves.len()` for the denominator of the progress /// indicator and `cursor` for the numerator. Playing { /// The replay being played back. Owned so the state is the /// only place playback metadata lives — no separate resource /// needed. Boxed: the v4 recording embeds the dealt board and /// would otherwise dwarf the other variants /// (`clippy::large_enum_variant`). replay: Box, /// Instruction list extracted once from `replay.recording` at /// [`start_replay_playback`] time, so the per-frame tick indexes /// a plain slice instead of re-walking the recording. moves: Vec, /// Index of the next move to apply, in `[0, moves.len()]`. cursor: usize, /// Seconds remaining until the next move is dispatched. secs_to_next: f32, /// `true` while playback is paused — `tick_replay_playback` /// skips the `secs_to_next` decrement entirely while this is /// set, so the cursor and the timer freeze together. The /// overlay stays mounted (`is_playing()` still returns /// `true`) so the player can see the paused state and the /// Resume / Step controls. Stepping while paused fires the /// next move directly via [`step_replay_playback`] and /// leaves the paused flag untouched. paused: bool, }, /// The replay finished playing back. The overlay swaps the banner /// label to "Replay complete" until [`auto_clear_completed_replay`] /// transitions back to [`Inactive`](Self::Inactive) a few seconds /// later. Completed, } impl ReplayPlaybackState { /// Construct a [`Self::Playing`] state, extracting the instruction /// list from `replay.recording` once up front. pub fn playing(replay: Replay, cursor: usize, secs_to_next: f32, paused: bool) -> Self { let moves = replay.recording.instructions(); Self::Playing { replay: Box::new(replay), moves, cursor, secs_to_next, paused, } } /// Returns `true` when a replay is currently being played back. pub fn is_playing(&self) -> bool { matches!(self, Self::Playing { .. }) } /// Returns `true` when the replay has finished but the resource has /// not yet been auto-cleared back to [`Self::Inactive`]. pub fn is_completed(&self) -> bool { matches!(self, Self::Completed) } /// Returns `(cursor, total)` when a replay is in progress so the /// overlay can render `"Move N of M"`. Returns `None` while /// [`Inactive`](Self::Inactive) or [`Completed`](Self::Completed) — /// the replay is consumed when transitioning out of `Playing`, so /// the total is no longer available in `Completed`. pub fn progress(&self) -> Option<(usize, usize)> { match self { Self::Playing { moves, cursor, .. } => Some((*cursor, moves.len())), Self::Inactive | Self::Completed => None, } } } /// Public entry point — call from the Stats "Watch replay" button /// handler. /// /// Resets the live [`GameStateResource`] to a fresh deal seeded from /// `replay.seed` / `replay.draw_mode` / `replay.mode` (via /// [`Commands::insert_resource`]), then transitions the state machine /// to [`ReplayPlaybackState::Playing`] with `cursor: 0` and /// `secs_to_next: REPLAY_MOVE_INTERVAL_SECS`. /// /// `commands` is used to overwrite [`GameStateResource`] in a deferred /// flush — equivalent to what `handle_new_game` does, minus the /// [`crate::events::NewGameRequestEvent`] round-trip and the /// abandon-current-game confirmation modal (which would block playback /// indefinitely). Using `Commands` rather than [`crate::events::NewGameRequestEvent`] /// also sidesteps the fact that `NewGameRequestEvent` has no /// `draw_mode_override` field — `handle_new_game` always reads /// `draw_mode` from `Settings`, which would silently coerce a Draw-1 /// replay into a Draw-3 game (or vice versa) when the player's /// settings disagree with the recording. /// /// Safe to call from any state — if a replay is already playing it is /// dropped and the new one starts immediately. pub fn start_replay_playback( commands: &mut Commands, state: &mut ResMut, replay: Replay, ) { use solitaire_core::game_state::GameState; // The recording carries the dealt board and the session config // (including draw mode), so the fresh game is bit-identical to the // recorded deal regardless of how the current build maps seeds to // deals — `replay.seed` is presentation metadata only. let (fresh, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode); commands.insert_resource(GameStateResource(fresh)); // Initial `secs_to_next` uses the constant rather than reading // `SettingsResource` because this entry point takes `Commands` / // `ResMut` only. The first-tick latency may // therefore lag the configured interval by up to ~0.45 s on an // unusually short setting; subsequent ticks read the live setting // every frame via [`tick_replay_playback`]. **state = ReplayPlaybackState::Playing { replay: Box::new(replay), moves, cursor: 0, secs_to_next: REPLAY_MOVE_INTERVAL_SECS, paused: false, }; } /// Aborts an in-flight replay playback and resets /// [`ReplayPlaybackState`] back to [`ReplayPlaybackState::Inactive`]. /// /// Safe to call from any state — when already /// [`ReplayPlaybackState::Inactive`] it simply re-asserts inactivity. /// /// The current [`GameStateResource`] is left as-is: the player sees the /// replay's most-recently-applied state until they start a fresh game /// manually. This avoids forcing an extra deal animation in their face /// the moment they cancel. /// /// `commands` is currently unused but accepted to match the /// [`start_replay_playback`] signature — leaves room to hook in /// cleanup (e.g. despawning playback-only overlays) without a future /// API break. pub fn stop_replay_playback(_commands: &mut Commands, state: &mut ResMut) { **state = ReplayPlaybackState::Inactive; } /// Toggle the `paused` flag on the active playback. No-op when not /// `Playing` (i.e. `Inactive` or `Completed`) — pause has no meaning /// in those states. Returns the new paused value, or `None` if the /// state wasn't `Playing`. pub fn toggle_pause_replay_playback(state: &mut ResMut) -> Option { if let ReplayPlaybackState::Playing { paused, .. } = state.as_mut() { *paused = !*paused; Some(*paused) } else { None } } /// Advance playback by exactly one move. Only meaningful while paused /// — when called on an unpaused playback it would race the /// `tick_replay_playback` loop. Returns `true` when a move was fired, /// `false` when no-op (state isn't `Playing { paused: true }` or the /// cursor is already at the end of the move list). /// /// Stepping the last move transitions the state to `Completed` on /// the next `tick_replay_playback` frame — same end-of-list path the /// normal advance loop takes. pub fn step_replay_playback( state: &mut ResMut, game: Option<&GameStateResource>, moves_writer: &mut MessageWriter, draws_writer: &mut MessageWriter, ) -> bool { let ReplayPlaybackState::Playing { moves, cursor, paused: true, .. } = state.as_mut() else { return false; }; if *cursor >= moves.len() { return false; } let instruction = moves[*cursor]; dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer); *cursor += 1; true } /// Translates one recorded [`KlondikeInstruction`] into the canonical /// engine event that drives the live animation pipeline. /// /// `RotateStock` fires a [`DrawRequestEvent`]; a `Dst*` instruction is /// decoded back to its runtime `(from, to, count)` pile coordinates via /// [`GameState::instruction_to_piles`] against the *current* live state /// (decoded before the event mutates it, so the source pile's face-up /// run length is the one in effect when the move applies) and fires a /// [`MoveRequestEvent`]. A decode that returns `None` (e.g. a malformed /// instruction loaded from disk) is skipped with a warning rather than /// panicking — the cursor still advances so playback never stalls. /// /// `game` is `None` only in headless fixtures that install no /// [`GameStateResource`]; in that case only `RotateStock` (which needs /// no live state) is dispatched and `Dst*` instructions are skipped. fn dispatch_instruction( instruction: KlondikeInstruction, cursor: usize, game: Option<&GameStateResource>, moves_writer: &mut MessageWriter, draws_writer: &mut MessageWriter, ) { match instruction { KlondikeInstruction::RotateStock => { draws_writer.write(DrawRequestEvent); } _ => match game.and_then(|g| g.0.instruction_to_piles(instruction)) { Some((from, to, count)) => { moves_writer.write(MoveRequestEvent { from, to, count }); } None => { warn!("skipping replay move that did not decode to piles at cursor {cursor}"); } }, } } /// Steps the replay **backwards** by exactly one move while paused. /// /// Strategy: the live game's undo system is the source of truth for /// reversing moves. Every move the replay forward-stepped (via /// [`step_replay_playback`] or the auto-advance loop in /// [`tick_replay_playback`]) was dispatched as a canonical /// [`MoveRequestEvent`] / [`DrawRequestEvent`], which the game /// applied and pushed onto its undo stack. So a backwards step here /// is simply: decrement the cursor (so the about-to-apply move /// re-points at the one we're rewinding past) and fire an /// [`UndoRequestEvent`] so the game reverses its most-recent move /// next frame. /// /// Hard-gated to the paused state via destructure pattern — /// matches the existing [`step_replay_playback`] gate so the /// player can only scrub one direction at a time and the tick /// loop never races a manual rewind. /// /// Returns `false` and is a no-op in three cases: /// - State isn't `Playing` (no replay attached). /// - State is `Playing` but not paused (the tick loop owns the cursor). /// - Cursor is already at 0 (nothing to rewind past). /// /// Returns `true` on a successful step; the actual game-state /// reversal happens next frame when `handle_undo` reads the /// `UndoRequestEvent`. pub fn step_backwards_replay_playback( state: &mut ResMut, undo_writer: &mut MessageWriter, ) -> bool { let ReplayPlaybackState::Playing { cursor, paused: true, .. } = state.as_mut() else { return false; }; if *cursor == 0 { return false; } *cursor -= 1; undo_writer.write(UndoRequestEvent); true } /// Tick system. Runs every frame; only does work when /// [`ReplayPlaybackState::is_playing`]. /// /// Drains `secs_to_next` by `time.delta_secs()`. When the countdown /// expires, fires the canonical event for the move at `cursor`, /// increments `cursor`, and resets `secs_to_next`. When `cursor` /// reaches `moves.len()`, transitions to /// [`ReplayPlaybackState::Completed`]. /// /// The advance loop is a `while`, not an `if`, so coarse time steps /// (e.g. test-driven 200 ms ticks against a 450 ms interval) still /// fire the right number of events — accumulated debt is paid off /// across as many advances as needed in the same frame. In normal /// gameplay frame deltas are well below `REPLAY_MOVE_INTERVAL_SECS`, /// so the loop runs at most once per frame. fn tick_replay_playback( time: Res