//! In-engine replay playback core. //! //! When the player clicks "Watch replay" on the Stats overlay, the live //! game state is reset to the deal seeded from the replay's `seed` / //! `mode` / `draw_mode`, and the engine ticks through `replay.moves` 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_data::{Replay, ReplayMove}; 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 [`ReplayMove`]. /// 4. When `cursor == replay.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 /// `replay.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. replay: Replay, /// Index of the next move to apply, in `[0, replay.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 { /// 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 { replay, cursor, .. } => Some((*cursor, replay.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; let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode, 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, 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, moves_writer: &mut MessageWriter, draws_writer: &mut MessageWriter, ) -> bool { let ReplayPlaybackState::Playing { replay, cursor, paused: true, .. } = state.as_mut() else { return false; }; if *cursor >= replay.moves.len() { return false; } match &replay.moves[*cursor] { ReplayMove::Move { from, to, count } => { moves_writer.write(MoveRequestEvent { from: from.clone(), to: to.clone(), count: *count, }); } ReplayMove::StockClick => { draws_writer.write(DrawRequestEvent); } } *cursor += 1; true } /// 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 `replay.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