//! 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}; 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, }, /// 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.clone(), 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, }; } /// 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; } /// 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