feat(engine): in-engine replay playback core

Promotes the replay feature from disk-only to a real in-engine
playback path. A new ReplayPlaybackState resource models a three-
state machine (Inactive / Playing / Completed); start_replay_playback
resets the live game to the recorded deal via
GameState::new_with_mode(seed, draw_mode, mode) and a tick system
fires the canonical MoveRequestEvent / DrawRequestEvent for each
recorded move at REPLAY_MOVE_INTERVAL_SECS (0.45s).

The reset path bypasses NewGameRequestEvent because the existing
event always sources draw_mode from Settings — a Draw-1 replay
would silently coerce to Draw-3 (or vice versa) on a player whose
preference doesn't match the recording. Inserting GameStateResource
directly applies the recording's exact draw_mode and sidesteps the
abandon-current-game confirmation modal that would otherwise block
playback.

Recording suppression during playback is non-invasive: a sibling
system snapshots RecordingReplay's length on entry to playback and
truncates the buffer back to that mark every frame while is_playing
or is_completed. game_plugin's recording append paths are
untouched.

Completion lingers for REPLAY_COMPLETION_LINGER_SECS (5s) so the
overlay can show "Replay complete" before the auto-clear flips
state to Inactive.

Six new tests cover the state transitions, tick cadence, canonical
event firing, completion, stop-clears-state, and the
recording-suppression contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-05 20:34:16 +00:00
parent 95fcdad5d2
commit 8e90574437
2 changed files with 692 additions and 0 deletions
+10
View File
@@ -24,6 +24,8 @@ pub mod onboarding_plugin;
pub mod pause_plugin; pub mod pause_plugin;
pub mod profile_plugin; pub mod profile_plugin;
pub mod radial_menu; pub mod radial_menu;
pub mod replay_overlay;
pub mod replay_playback;
pub mod settings_plugin; pub mod settings_plugin;
pub mod progress_plugin; pub mod progress_plugin;
pub mod resources; pub mod resources;
@@ -112,6 +114,14 @@ pub use radial_menu::{
legal_destinations_for_card, radial_anchor_for_index, radial_hovered_index, RadialIcon, legal_destinations_for_card, radial_anchor_for_index, radial_hovered_index, RadialIcon,
RadialMenuPlugin, RightClickRadialState, Z_RADIAL_MENU, RadialMenuPlugin, RightClickRadialState, Z_RADIAL_MENU,
}; };
pub use replay_overlay::{
ReplayOverlayBannerText, ReplayOverlayPlugin, ReplayOverlayProgressText, ReplayOverlayRoot,
ReplayStopButton, Z_REPLAY_OVERLAY,
};
pub use replay_playback::{
start_replay_playback, stop_replay_playback, ReplayPlaybackPlugin, ReplayPlaybackState,
REPLAY_COMPLETION_LINGER_SECS, REPLAY_MOVE_INTERVAL_SECS,
};
pub use settings_plugin::{ pub use settings_plugin::{
PendingWindowGeometry, SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen, PendingWindowGeometry, SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen,
SFX_STEP, WINDOW_GEOMETRY_DEBOUNCE_SECS, SFX_STEP, WINDOW_GEOMETRY_DEBOUNCE_SECS,
+682
View File
@@ -0,0 +1,682 @@
//! 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;
/// Per-move duration during playback. Tunable in Settings later;
/// hardcoded for v1.
pub const REPLAY_MOVE_INTERVAL_SECS: f32 = 0.45;
/// 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<ReplayPlaybackState>,
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));
**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<ReplayPlaybackState>,
) {
**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<Time>,
mut state: ResMut<ReplayPlaybackState>,
mut moves_writer: MessageWriter<MoveRequestEvent>,
mut draws_writer: MessageWriter<DrawRequestEvent>,
) {
let dt = time.delta_secs();
let mut transition_to_completed = false;
if let ReplayPlaybackState::Playing {
replay,
cursor,
secs_to_next,
} = state.as_mut()
{
*secs_to_next -= dt;
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
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;
*secs_to_next += REPLAY_MOVE_INTERVAL_SECS;
}
if *cursor >= replay.moves.len() {
transition_to_completed = true;
}
}
if transition_to_completed {
*state = ReplayPlaybackState::Completed;
}
}
/// Local timer for the [`ReplayPlaybackState::Completed`] linger.
/// Resets to zero whenever the state transitions out of
/// [`ReplayPlaybackState::Completed`].
#[derive(Default)]
struct CompletionLinger(f32);
/// Auto-clear system. While [`ReplayPlaybackState::Completed`],
/// accumulates time and transitions back to
/// [`ReplayPlaybackState::Inactive`] once
/// [`REPLAY_COMPLETION_LINGER_SECS`] has elapsed.
fn auto_clear_completed_replay(
time: Res<Time>,
mut state: ResMut<ReplayPlaybackState>,
mut linger: Local<CompletionLinger>,
) {
if state.is_completed() {
linger.0 += time.delta_secs();
if linger.0 >= REPLAY_COMPLETION_LINGER_SECS {
*state = ReplayPlaybackState::Inactive;
linger.0 = 0.0;
}
} else {
// Reset whenever we're not in Completed so the next completion
// measures from zero rather than accumulating across cycles.
linger.0 = 0.0;
}
}
/// Local cache of the recording buffer's length at the start of
/// playback. Lets us roll back any growth during playback without
/// touching `game_plugin`'s recording call sites.
#[derive(Default)]
struct RecordingSnapshot {
/// `Some(len)` while playback is active. The recording is
/// truncated back to this length every frame so playback-driven
/// events leak no entries into the recorded move list. `None`
/// when not playing — recording behaves normally.
snapshot_len: Option<usize>,
}
/// Recording-pause system. While [`ReplayPlaybackState::is_playing`],
/// snapshots the recording's length on entry and truncates the
/// recording back to that length every frame. This keeps the live
/// [`RecordingReplay`] opaque to `game_plugin`'s `handle_move` /
/// `handle_draw` — those still push unconditionally; we just wipe the
/// playback-driven entries before any other system can read them.
///
/// Implemented this way because [`RecordingReplay`] is mutated inside
/// the [`GameMutation`] system set (the schedule set that owns
/// `handle_move` / `handle_draw`). We schedule this system
/// `.after(GameMutation)` so the truncation runs each frame *after*
/// the unconditional push, removing the same entry the playback tick
/// caused.
fn record_replay_skip_during_playback(
state: Res<ReplayPlaybackState>,
mut recording: ResMut<RecordingReplay>,
mut snap: Local<RecordingSnapshot>,
) {
// Treat `Playing` and `Completed` identically for the purpose of
// recording suppression. The tick system's final advance fires
// its event in the same frame it transitions to `Completed`; the
// event is then consumed by `handle_move` / `handle_draw` either
// this frame (race-dependent on system order) or the next. By
// suppressing recording growth across both states, we close that
// window cleanly: the snapshot survives until the resource is
// back to `Inactive` (auto-cleared after
// `REPLAY_COMPLETION_LINGER_SECS`).
if state.is_playing() || state.is_completed() {
let baseline = match snap.snapshot_len {
Some(n) => n,
None => {
let n = recording.moves.len();
snap.snapshot_len = Some(n);
n
}
};
if recording.moves.len() > baseline {
recording.moves.truncate(baseline);
}
} else {
// Drop the snapshot when neither playing nor completed so
// the next playback cycle re-anchors to whatever the
// recording is at that point.
snap.snapshot_len = None;
}
}
/// On-completion side effect: fire a single [`StateChangedEvent`] when
/// playback transitions from `Playing` to `Completed` so any UI that
/// listens for state mutations refreshes one final time. Cheap and
/// idempotent — `StateChangedEvent` is a one-shot signal.
fn fire_state_changed_on_completion(
state: Res<ReplayPlaybackState>,
mut last_was_completed: Local<bool>,
mut writer: MessageWriter<StateChangedEvent>,
) {
let now_completed = state.is_completed();
if now_completed && !*last_was_completed {
writer.write(StateChangedEvent);
}
*last_was_completed = now_completed;
}
/// Bevy plugin that initialises [`ReplayPlaybackState`] and drives
/// playback ticks, completion linger, and the recording-pause guard.
///
/// Register this in the main app alongside [`crate::game_plugin::GamePlugin`].
/// Tests can install it under [`MinimalPlugins`] to exercise the public
/// API without spinning up the full client.
pub struct ReplayPlaybackPlugin;
impl Plugin for ReplayPlaybackPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ReplayPlaybackState>()
.add_systems(
Update,
(
tick_replay_playback,
auto_clear_completed_replay,
fire_state_changed_on_completion,
)
.chain(),
)
.add_systems(
Update,
record_replay_skip_during_playback.after(GameMutation),
);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use bevy::time::TimeUpdateStrategy;
use chrono::NaiveDate;
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::pile::PileType;
use std::time::Duration;
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
/// `ReplayPlaybackPlugin`. `GamePlugin` brings the canonical
/// `MoveRequestEvent` / `DrawRequestEvent` registrations along with
/// `RecordingReplay` so the recording-pause test can read it.
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin::headless())
.add_plugins(ReplayPlaybackPlugin);
// Disable game-state persistence so tests don't touch the
// real ~/.local/share/solitaire_quest/game_state.json.
app.insert_resource(crate::game_plugin::GameStatePath(None));
app.insert_resource(crate::game_plugin::ReplayPath(None));
// Tick once so any startup systems flush before the first
// assertion.
app.update();
app
}
/// `Time<Virtual>` clamps each tick to `max_delta` (default 250 ms),
/// so we drive 200 ms steps and call `update` enough times to pass
/// the requested duration.
fn advance_by(app: &mut App, total_secs: f32) {
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(0.2),
));
let ticks = (total_secs / 0.2).ceil() as usize + 1;
for _ in 0..ticks {
app.update();
}
}
/// A 3-move replay covering both `Move` and `StockClick` variants.
/// Seed 12345 is arbitrary — the test asserts on event counts and
/// move shapes, not on board positions.
fn sample_replay_three_moves() -> Replay {
Replay::new(
12345,
DrawMode::DrawOne,
GameMode::Classic,
60,
500,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![
ReplayMove::StockClick,
ReplayMove::Move {
from: PileType::Waste,
to: PileType::Tableau(3),
count: 1,
},
ReplayMove::StockClick,
],
)
}
/// Scoped helper to invoke `start_replay_playback` from within the
/// app's `World` (the public API takes `Commands`, which only
/// exists inside systems). We use a one-shot system to obtain the
/// `Commands`.
fn start_playback(app: &mut App, replay: Replay) {
#[derive(Resource)]
struct ReplayInbox(Option<Replay>);
app.insert_resource(ReplayInbox(Some(replay)));
fn run(
mut commands: Commands,
mut state: ResMut<ReplayPlaybackState>,
mut inbox: ResMut<ReplayInbox>,
) {
if let Some(replay) = inbox.0.take() {
start_replay_playback(&mut commands, &mut state, replay);
}
}
let id = app.world_mut().register_system(run);
app.world_mut()
.run_system(id)
.expect("one-shot start_playback");
}
fn stop_playback(app: &mut App) {
fn run(mut commands: Commands, mut state: ResMut<ReplayPlaybackState>) {
stop_replay_playback(&mut commands, &mut state);
}
let id = app.world_mut().register_system(run);
app.world_mut()
.run_system(id)
.expect("one-shot stop_playback");
}
/// Fresh state must be `Inactive`. After `start_replay_playback`
/// the state must be `Playing { cursor: 0, .. }` carrying the
/// supplied replay.
#[test]
fn start_replay_playback_transitions_inactive_to_playing() {
let mut app = headless_app();
assert!(matches!(
*app.world().resource::<ReplayPlaybackState>(),
ReplayPlaybackState::Inactive
));
let replay = sample_replay_three_moves();
start_playback(&mut app, replay.clone());
// Apply the deferred Commands flush.
app.update();
let state = app.world().resource::<ReplayPlaybackState>();
match state {
ReplayPlaybackState::Playing {
cursor,
replay: r,
..
} => {
assert_eq!(*cursor, 0);
assert_eq!(r.seed, replay.seed);
assert_eq!(r.moves.len(), 3);
}
other => panic!("expected Playing, got {other:?}"),
}
assert_eq!(state.progress(), Some((0, 3)));
}
/// One full interval (plus a small margin to clear the boundary)
/// must advance the cursor by at least one.
#[test]
fn tick_advances_cursor_after_interval() {
let mut app = headless_app();
start_playback(&mut app, sample_replay_three_moves());
app.update();
// Drive virtual time forward by one interval.
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS + 0.05);
let state = app.world().resource::<ReplayPlaybackState>();
match state {
ReplayPlaybackState::Playing { cursor, .. } => {
assert!(
*cursor >= 1,
"expected cursor advanced past one move, got {cursor}",
);
}
other => panic!("expected Playing, got {other:?}"),
}
}
/// Driving past `n * REPLAY_MOVE_INTERVAL_SECS` must produce
/// `n` events that match the recorded move kinds. We register a
/// pair of accumulator systems that drain `MoveRequestEvent` /
/// `DrawRequestEvent` into resources every frame — using a
/// detached cursor across many `app.update()` calls is unreliable
/// because Bevy's `Messages` double-buffer drops events older
/// than two frames.
#[test]
fn tick_fires_canonical_event_for_each_move() {
#[derive(Resource, Default)]
struct CapturedMoves(Vec<MoveRequestEvent>);
#[derive(Resource, Default)]
struct CapturedDraws(usize);
fn collect_moves(
mut events: MessageReader<MoveRequestEvent>,
mut sink: ResMut<CapturedMoves>,
) {
for ev in events.read() {
sink.0.push(ev.clone());
}
}
fn collect_draws(
mut events: MessageReader<DrawRequestEvent>,
mut sink: ResMut<CapturedDraws>,
) {
for _ in events.read() {
sink.0 += 1;
}
}
let mut app = headless_app();
app.init_resource::<CapturedMoves>()
.init_resource::<CapturedDraws>()
.add_systems(Update, (collect_moves, collect_draws));
start_playback(&mut app, sample_replay_three_moves());
app.update();
// Drive through 3 intervals. Add a small margin to ensure the
// last firing isn't sitting exactly on the boundary.
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS * 3.0 + 0.1);
let captured_moves = app.world().resource::<CapturedMoves>();
let captured_draws = app.world().resource::<CapturedDraws>();
// Sample replay: StockClick, Move { Waste -> Tableau(3), 1 }, StockClick.
assert_eq!(
captured_draws.0, 2,
"expected 2 DrawRequestEvent (two StockClicks)",
);
assert_eq!(
captured_moves.0.len(),
1,
"expected 1 MoveRequestEvent (the single Move variant)",
);
let m = &captured_moves.0[0];
assert!(matches!(m.from, PileType::Waste));
assert!(matches!(m.to, PileType::Tableau(3)));
assert_eq!(m.count, 1);
}
/// Driving past one interval on a single-move replay must
/// transition to `Completed`.
#[test]
fn playback_completes_when_cursor_reaches_end() {
let mut app = headless_app();
let one_move = Replay::new(
42,
DrawMode::DrawOne,
GameMode::Classic,
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick],
);
start_playback(&mut app, one_move);
app.update();
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS + 0.1);
let state = app.world().resource::<ReplayPlaybackState>();
assert!(
state.is_completed(),
"expected Completed after consuming the only move, got {state:?}",
);
}
/// `stop_replay_playback` must force the state back to `Inactive`
/// even mid-playback.
#[test]
fn stop_replay_playback_returns_to_inactive() {
let mut app = headless_app();
start_playback(&mut app, sample_replay_three_moves());
app.update();
// Tick once so the state is well and truly `Playing`.
advance_by(&mut app, 0.1);
assert!(app.world().resource::<ReplayPlaybackState>().is_playing());
stop_playback(&mut app);
app.update();
assert!(matches!(
*app.world().resource::<ReplayPlaybackState>(),
ReplayPlaybackState::Inactive
));
}
/// Recording must remain frozen during playback. Pre-populate the
/// recording with one entry, start playback, and assert the
/// recording's move list is unchanged after several ticks.
#[test]
fn recording_paused_during_playback() {
let mut app = headless_app();
// Pre-populate the recording with one entry that should
// survive playback unchanged. Mirrors the situation where the
// player partway through a game opens stats and clicks Watch
// Replay — their in-flight recording must not get clobbered.
{
let mut rec = app.world_mut().resource_mut::<RecordingReplay>();
rec.moves.push(ReplayMove::StockClick);
}
start_playback(&mut app, sample_replay_three_moves());
app.update();
let baseline_len = app.world().resource::<RecordingReplay>().moves.len();
assert_eq!(
baseline_len, 1,
"preconditions: recording starts with one entry",
);
// Drive playback through every move in the replay. Each move
// would normally append to `RecordingReplay`; the pause
// system must clamp the recording back to `baseline_len` on
// every frame.
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS * 4.0 + 0.1);
let after_len = app.world().resource::<RecordingReplay>().moves.len();
assert_eq!(
after_len, baseline_len,
"recording must not grow while playback is active",
);
}
}