Compare commits

..

8 Commits

Author SHA1 Message Date
funman300 70165da103 docs: CHANGELOG + SESSION_HANDOFF refresh for v0.15.0
CHANGELOG gains a [0.15.0] section covering 7 commits since
v0.14.0: Bevy default-features trim (51 transitive crates dropped),
in-engine replay playback core + overlay banner + Stats button
wiring, rolling replay history (last 8 wins) with selector UI,
"Cinephile" achievement (#19), and the Klondike solver + "Winnable
deals only" toggle.

The bottom-of-file compare links thread the new tag into the chain.
Test count updated to 1178.

SESSION_HANDOFF rewritten for the post-v0.15.0 state. Open punch
list collapsed to two release-prep items (smoke-test, desktop
packaging) and six fresh next-round candidates: solver-driven
hints (now unblocked), playback-rate slider, solver progress
overlay, solver-on-async-compute, per-deal "won previously"
indicator, replay sharing. Resume prompt asks A–E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:07:15 +00:00
funman300 8a5fa8751c feat(core,engine): Klondike solver and "Winnable deals only" toggle
Closes Quat investigation #1. Today some Klondike deals are
unwinnable from the start and the player has no signal that the
deal they were given is solvable. A new Settings → Gameplay toggle
"Winnable deals only" (default off) makes the engine retry seeds
at deal-time until the solver returns Winnable, up to a cap.

Solver

solitaire_core::solver is a hand-rolled iterative-DFS solver with
memoisation on a 64-bit canonical state hash. Move enumeration is
priority-ordered: foundation moves first (zero choice when an Ace
or rank-up exists), inter-tableau moves second, waste-to-tableau
third, stock-draw last. The draw is skipped when the cycle counter
shows we've recirculated the entire stock without progress —
Klondike's deterministic stock cycle means further draws can't
unlock anything new.

Two budget knobs (move_budget = 100k, state_budget = 200k by
default) cap pathological cases at Inconclusive; the caller treats
Inconclusive as "winnable" so the player isn't penalised for the
solver giving up. Median solve time is 2 ms; pathological
inconclusives top out near 120 ms.

Switched from recursive to iterative DFS after a real-deal solve
overflowed Rust's default 8 MB thread stack. Behaviour identical;
the change is invisible to callers.

Pure logic — solitaire_core has no Bevy or I/O. Same input always
yields the same SolverResult.

Settings

Settings.winnable_deals_only is a #[serde(default)] bool; legacy
files load to false. SOLVER_DEAL_RETRY_CAP = 50 caps the retry
loop. The Settings → Gameplay toggle reads as "Winnable deals only"
with a "(may take a moment when on)" caption.

Engine integration

handle_new_game's seed-selection path now branches on the toggle.
When on AND mode is Classic AND no specific seed was requested
(daily challenges, replays, and explicit-seed requests bypass the
solver), choose_winnable_seed walks seed N, N+1, N+2, … calling
try_solve until it finds Winnable or Inconclusive. If the cap is
hit without a verdict, the latest tried seed is used so the player
always gets a deal rather than spinning forever.

19 new tests (11 solver, 3 settings, 5 engine including the
choose_winnable_seed unit). Two ignored bench/scan helpers
(solver_bench, find_unwinnable) for ad-hoc profiling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:02:22 +00:00
funman300 bf660df971 feat(core,engine): "Cinephile" achievement for completing a replay
Adds a 19th achievement: "Cinephile — Watch a saved replay all the
way through." Unlocks the first time ReplayPlaybackState transitions
Playing → Completed (i.e. the move list runs out without the player
pressing Stop). Discoverability nudge for the replay feature itself.

The achievement uses the existing event-driven unlock pattern
(condition closure returns false; an unlock system fires
AchievementUnlockedEvent on the right state transition) rather than
the standard condition-evaluation path, mirroring how other
non-stat-driven achievements work.

The unlock system distinguishes natural completion from Stop-button
abort by watching for the specific Playing → Completed transition;
Stop transitions Playing → Inactive directly without going through
Completed, so it doesn't fire the achievement. Already-unlocked
state is checked via AchievementsResource so the achievement can't
double-fire on subsequent replays.

README's "18 Achievements" → "19 Achievements". ARCHITECTURE.md §11
gains a Cinephile entry alongside the existing 18.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:32:56 +00:00
funman300 13a8a012ee feat(data,engine): rolling replay history (last 8 wins)
Promotes replay storage from a single overwriting slot at
latest_replay.json to a rolling list of the most recent 8 wins at
replays.json so the player can revisit a memorable game even after
winning more recently.

Storage layer

solitaire_data::replay gains ReplayHistory (schema_version=1, Vec<Replay>
capped at REPLAY_HISTORY_CAP = 8) plus save_replay_history_to,
load_replay_history_from, append_replay_to_history, and
replay_history_path. append_replay_to_history inserts at the front,
drops the oldest when the cap is hit, and persists atomically via
the existing .tmp + rename pattern. The legacy single-slot helpers
are #[deprecated] but kept for one release as a migration safety
net via the new migrate_legacy_latest_replay helper.

Engine integration

game_plugin's record_replay_on_win now appends to the history
instead of overwriting latest_replay.json. On Startup, if a legacy
latest_replay.json exists but replays.json doesn't, the migration
helper seeds the new file from the legacy entry — so the player's
last v0.14.0 replay carries forward.

Stats UI

LatestReplayResource → ReplayHistoryResource holding the full
history. New SelectedReplayIndex resource (default 0 = most
recent) drives a Prev / Next / "Replay N / M" selector at the top
of the Stats overlay. ReplayPrevButton, ReplayNextButton, and
ReplaySelectorCaption marker components let the repaint system
update the caption as the selection changes. The Watch button
launches the selected replay rather than always the most recent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:32:37 +00:00
funman300 02ababa65f feat(engine): wire Stats Watch Replay button to in-engine playback
Promotes the Stats overlay's Watch Replay button from a stub
InfoToastEvent ("playback coming in a future build") to actually
starting in-engine playback via the new
replay_playback::start_replay_playback API. Pressing the button
when a replay exists resets the game to the recorded deal and the
ReplayOverlayPlugin's banner takes over.

The handler reads ReplayPlaybackState via Option<ResMut<...>> so
headless test fixtures that don't register ReplayPlaybackPlugin
keep compiling — they fall back to a descriptive "Replay ready"
toast. The "no replay yet" branch still surfaces the existing
"win a game first" toast.

Plugin registration in solitaire_app/src/main.rs picks up
ReplayPlaybackPlugin and ReplayOverlayPlugin alongside the existing
StatsPlugin. They run in any order — the playback plugin owns the
state resource, the overlay plugin spawns/despawns based on state
changes, and Stats's button handler dispatches into the playback
plugin's API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:34:48 +00:00
funman300 9c36b49729 feat(engine): replay-playback overlay banner with Stop button
Visible UI for the in-engine replay playback that just landed: a
thin top banner anchored to the window edge while
ReplayPlaybackState is Playing or Completed, surfacing the player's
current position in the move list and a way to abort.

Layout: full-width banner ~48 px tall with three children — a
"Replay" label in ACCENT_PRIMARY left-aligned, "Move N of M"
progress text centred, and a Tertiary Stop button right-aligned via
the existing spawn_modal_button helper so it gets focus rings and
hover/press states for free.

Z_REPLAY_OVERLAY = Z_DROP_OVERLAY + 5 (= 55) sits above HUD but
well below modal scrim (≥200), so Settings, Pause, and Help still
render on top of the overlay during a replay — the player can
adjust audio or pause mid-playback.

State-driven: the spawn system reacts to Changed<ReplayPlaybackState>
transitions, swapping the banner text to "Replay complete" when
state moves Playing → Completed and despawning entirely when state
returns to Inactive (either via the Stop button, completion linger
expiry, or external reset).

Five tests cover spawn-on-Playing, progress text, stop-button
clears state and despawns, despawn-on-Inactive, and Completed
banner text swap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:34:36 +00:00
funman300 8e90574437 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>
2026-05-05 20:34:16 +00:00
funman300 95fcdad5d2 chore: disable Bevy default features to drop unused audio stack
Closes Quat investigation #2. The project uses kira for audio
(cpal 0.17 + alsa 0.10), but Bevy's default feature set still pulled
bevy_audio → rodio → cpal 0.15 + alsa 0.9 + symphonia codecs — about
50 transitive crates the binary never executes.

Workspace Cargo.toml's bevy entry now declares default-features =
false plus an explicit allow-list of the features actually used
(default_app subset + default_platform desktop subset + common_api +
2D + UI rendering). The list is derived analytically from the leaves
of Bevy 0.18's 2d and ui meta-features; built cleanly on the first
try with no missing-symbol errors.

Features intentionally omitted vs Bevy default:
- bevy_audio (kira handles audio directly)
- bevy_animation (custom CardAnimation, not Bevy's)
- bevy_gilrs, bevy_gizmos, bevy_picking variants, bevy_post_process,
  scene, hdr, sysinfo_plugin (none used)
- webgl2, web, android-* (desktop-only; solitaire_wasm is Bevy-free
  and uses wasm-bindgen + solitaire_core directly)
- wayland (X11 chosen; Wayland can be added later if requested)

Dependency-tree size for solitaire_app drops from 628 unique crates
to 577 (-51). Verified gone: bevy_audio, rodio, cpal 0.15. The
remaining cpal 0.17 and symphonia 0.5 are pulled by kira, not Bevy.

solitaire_wasm needed no changes — it doesn't depend on bevy.

All 1134 tests pass; clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:07:30 +00:00
19 changed files with 3637 additions and 1169 deletions
+3
View File
@@ -716,11 +716,14 @@ pub struct AchievementDef {
| `speed_and_skill` | ??? | Win < 90s without undo | Yes | Card back #4 | | `speed_and_skill` | ??? | Win < 90s without undo | Yes | Card back #4 |
| `comeback` | ??? | Win after 3+ stock recycles | Yes | Background #4 | | `comeback` | ??? | Win after 3+ stock recycles | Yes | Background #4 |
| `zen_winner` | ??? | Win in Zen Mode | Yes | Badge | | `zen_winner` | ??? | Win in Zen Mode | Yes | Badge |
| `cinephile` | Cinephile | Watch a saved replay all the way through | No | — |
### Evaluation Timing ### Evaluation Timing
Achievement conditions are evaluated by `AchievementPlugin` on every `GameWonEvent` and `StateChangedEvent`. The plugin calls `solitaire_core::check_achievements()` which returns a `Vec<AchievementDef>` of newly unlocked achievements. The plugin then fires `AchievementUnlockedEvent` for each, which the toast and persistence systems handle independently. Achievement conditions are evaluated by `AchievementPlugin` on every `GameWonEvent` and `StateChangedEvent`. The plugin calls `solitaire_core::check_achievements()` which returns a `Vec<AchievementDef>` of newly unlocked achievements. The plugin then fires `AchievementUnlockedEvent` for each, which the toast and persistence systems handle independently.
A small number of achievements are *event-driven* rather than condition-driven: their `AchievementDef::condition` always returns `false` and their unlock is written from a dedicated observer system instead. `cinephile` is the canonical example — it unlocks when `ReplayPlaybackState` transitions from `Playing` to `Completed` (a saved replay watched to its natural end). The Stop button transitions `Playing → Inactive` directly without entering `Completed`, so manual aborts do not unlock the achievement.
--- ---
## 12. Progression System ## 12. Progression System
+62 -1
View File
@@ -8,6 +8,66 @@ project follows [Semantic Versioning](https://semver.org/).
_Nothing yet._ _Nothing yet._
## [0.15.0] — 2026-05-02
In-engine replay playback, the Klondike solver + "Winnable deals
only" toggle, a 19th achievement, rolling replay history, and a
significant build-time / binary-size win from disabling Bevy's
default audio stack.
### Added
- **In-engine replay playback** for the Stats overlay's Watch Replay
button. New `ReplayPlaybackPlugin` runs a state machine
(Inactive / Playing / Completed) that resets the live game to the
recorded deal and ticks through `replay.moves` at
`REPLAY_MOVE_INTERVAL_SECS` (0.45 s) firing the canonical
`MoveRequestEvent` / `DrawRequestEvent` per recorded move.
Recording is suppressed during playback so replays don't re-record
themselves.
- **Replay overlay banner** (`ReplayOverlayPlugin`) anchored to the
top of the window during playback. Shows "Replay" label, "Move N
of M" progress, and a Stop button. Z-order leaves modals
(Settings, Pause, Help) free to render on top so the player can
adjust audio mid-replay.
- **Rolling replay history** at `<data_dir>/replays.json` capped at
8 entries. Replaces the single-slot `latest_replay.json` (legacy
file is migrated forward on first launch via
`migrate_legacy_latest_replay`). Stats overlay gains a Prev / Next
selector and a "Replay N / M" caption so the player can revisit
older wins.
- **"Cinephile" achievement** (#19). Unlocks the first time
`ReplayPlaybackState` transitions Playing → Completed (i.e. the
replay played out to its end without the player pressing Stop).
Stop transitions Playing → Inactive directly so it doesn't count.
- **Klondike solver** in `solitaire_core::solver`. Iterative-DFS
with memoisation on a 64-bit canonical state hash, two budget
knobs (move_budget + state_budget) for pathological cases, and a
three-state `SolverResult` (Winnable / Unwinnable / Inconclusive).
Median solve time 2 ms; pathological inconclusives cap near
120 ms. Pure logic — `solitaire_core` keeps no Bevy or I/O.
- **"Winnable deals only" toggle** in Settings → Gameplay (default
off). When on, `handle_new_game` walks seed N, N+1, N+2, …
through `try_solve` until it finds Winnable or Inconclusive,
capped at `SOLVER_DEAL_RETRY_CAP` (50) attempts. Daily
challenges, replays, and explicit-seed requests bypass the
solver — only random Classic deals are gated.
### Changed
- **Bevy default-feature trim** (`bevy = { default-features = false,
features = [...] }` in workspace Cargo.toml) drops 51 transitive
crates including the `bevy_audio` → rodio → cpal 0.15 + symphonia
chain that the project doesn't use (kira handles audio directly).
The retained feature list is curated to exactly what the engine
uses; `solitaire_wasm` is unaffected because it doesn't depend on
bevy.
### Stats
- 1178 passing tests (was 1134 at v0.14.0 close).
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
## [0.14.0] — 2026-05-02 ## [0.14.0] — 2026-05-02
Two threads land in v0.14.0: the second half of the post-v0.12.0 UX Two threads land in v0.14.0: the second half of the post-v0.12.0 UX
@@ -405,7 +465,8 @@ with no PNG artwork yet.
CREDITS.md, persistent window geometry, mode-launcher Home repurpose, CREDITS.md, persistent window geometry, mode-launcher Home repurpose,
client-side sync round-trip integration tests. client-side sync round-trip integration tests.
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.14.0...HEAD [Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.15.0...HEAD
[0.15.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.14.0...v0.15.0
[0.14.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.13.0...v0.14.0 [0.14.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.13.0...v0.14.0
[0.13.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.12.0...v0.13.0 [0.13.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.12.0...v0.13.0
[0.12.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.11.0...v0.12.0 [0.12.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.11.0...v0.12.0
Generated
+23 -1090
View File
File diff suppressed because it is too large Load Diff
+40 -1
View File
@@ -36,7 +36,46 @@ solitaire_sync = { path = "solitaire_sync" }
solitaire_data = { path = "solitaire_data" } solitaire_data = { path = "solitaire_data" }
solitaire_engine = { path = "solitaire_engine" } solitaire_engine = { path = "solitaire_engine" }
bevy = "0.18" # Bevy with `default-features = false` to avoid the unused
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
# Audio is handled directly by `kira` in `audio_plugin.rs`, so the
# `bevy_audio` feature is intentionally omitted. The features below
# enumerate every leaf of the standard `2d` + `ui` meta-features that
# we actually use; new features should only be added with a
# corresponding use site.
bevy = { version = "0.18", default-features = false, features = [
# default_app
"async_executor",
"bevy_asset",
"bevy_input_focus",
"bevy_log",
"bevy_state",
"bevy_window",
"custom_cursor",
"reflect_auto_register",
# default_platform (desktop subset; no android/wayland/webgl/gilrs/sysinfo)
"std",
"bevy_winit",
"default_font",
"multi_threaded",
"x11",
# common_api
"bevy_color",
"bevy_image",
"bevy_mesh",
"bevy_shader",
"bevy_text",
"png",
# 2d rendering
"bevy_camera",
"bevy_render",
"bevy_core_pipeline",
"bevy_sprite",
"bevy_sprite_render",
# UI rendering
"bevy_ui",
"bevy_ui_render",
] }
kira = "0.12" kira = "0.12"
# SVG rasterisation pipeline for the runtime card-theme system. # SVG rasterisation pipeline for the runtime card-theme system.
+1 -1
View File
@@ -22,7 +22,7 @@ optional self-hosted sync so your stats follow you across machines.
move within picker rows, Enter activates; works across every modal and move within picker rows, Enter activates; works across every modal and
the HUD action bar the HUD action bar
- **Progression** — XP, levels, unlockable card backs and backgrounds - **Progression** — XP, levels, unlockable card backs and backgrounds
- **18 Achievements** — including secret ones - **19 Achievements** — including secret ones
- **Daily Challenge** — server-seeded so every player worldwide gets the - **Daily Challenge** — server-seeded so every player worldwide gets the
same deal same deal
- **Leaderboard** — opt-in, powered by your own self-hosted server - **Leaderboard** — opt-in, powered by your own self-hosted server
+4 -1
View File
@@ -10,7 +10,8 @@ use solitaire_engine::{
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
SelectionPlugin, SettingsPlugin, SplashPlugin,
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
}; };
@@ -117,6 +118,8 @@ fn main() {
.add_plugins(FeedbackAnimPlugin) .add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin) .add_plugins(CardAnimationPlugin)
.add_plugins(AutoCompletePlugin) .add_plugins(AutoCompletePlugin)
.add_plugins(ReplayPlaybackPlugin)
.add_plugins(ReplayOverlayPlugin)
.add_plugins(StatsPlugin::default()) .add_plugins(StatsPlugin::default())
.add_plugins(ProgressPlugin::default()) .add_plugins(ProgressPlugin::default())
.add_plugins(AchievementPlugin::default()) .add_plugins(AchievementPlugin::default())
+47
View File
@@ -140,6 +140,16 @@ fn comeback(c: &AchievementContext) -> bool {
fn zen_winner(c: &AchievementContext) -> bool { fn zen_winner(c: &AchievementContext) -> bool {
c.last_win_is_zen c.last_win_is_zen
} }
/// Cinephile is event-driven: it unlocks when the engine observes a
/// `ReplayPlaybackState` transition from `Playing` to `Completed`, not on
/// any field of [`AchievementContext`]. The condition predicate therefore
/// always returns false so [`check_achievements`] never unlocks it from a
/// `GameWonEvent` / `StateChangedEvent` cycle — the unlock is driven by
/// `AchievementUnlockedEvent` written directly from the engine's
/// replay-playback observer.
fn cinephile_never(_c: &AchievementContext) -> bool {
false
}
/// All currently-evaluable achievements. Order is stable so persistence files /// All currently-evaluable achievements. Order is stable so persistence files
/// remain readable across versions (new achievements append). /// remain readable across versions (new achievements append).
@@ -288,6 +298,18 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
reward: Some(Reward::Badge), reward: Some(Reward::Badge),
condition: zen_winner, condition: zen_winner,
}, },
AchievementDef {
id: "cinephile",
name: "Cinephile",
description: "Watch a saved replay all the way through",
secret: false,
reward: None,
// Event-driven unlock: the engine's replay-playback observer fires
// `AchievementUnlockedEvent("cinephile")` directly on a Playing →
// Completed transition. `cinephile_never` keeps the condition path
// a no-op so a `GameWonEvent` evaluation cycle cannot unlock it.
condition: cinephile_never,
},
]; ];
/// Return every `AchievementDef` whose condition is satisfied by `ctx`. /// Return every `AchievementDef` whose condition is satisfied by `ctx`.
@@ -721,6 +743,31 @@ mod tests {
assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does"); assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does");
} }
#[test]
fn cinephile_achievement_in_canonical_list() {
let def = achievement_by_id("cinephile").expect("cinephile must be registered");
assert_eq!(def.id, "cinephile");
assert_eq!(def.name, "Cinephile");
assert!(!def.secret, "cinephile is not a secret achievement");
// Event-driven: the predicate is a sentinel that always returns
// false. `check_achievements` must never unlock cinephile from a
// GameWonEvent context, even one that satisfies every other gate.
let mut c = ctx();
c.games_won = 1;
c.win_streak_current = 999;
c.last_win_time_seconds = 1;
c.last_win_used_undo = false;
c.best_single_score = 99_999;
c.lifetime_score = u64::MAX;
c.last_win_is_zen = true;
c.last_win_recycle_count = 99;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(
!ids.contains(&"cinephile"),
"cinephile must never unlock via condition evaluation; got {ids:?}",
);
}
#[test] #[test]
fn perfectionist_score_well_above_threshold_still_passes() { fn perfectionist_score_well_above_threshold_still_passes() {
let mut c = ctx(); let mut c = ctx();
+1
View File
@@ -6,3 +6,4 @@ pub mod game_state;
pub mod pile; pub mod pile;
pub mod rules; pub mod rules;
pub mod scoring; pub mod scoring;
pub mod solver;
+893
View File
@@ -0,0 +1,893 @@
//! Klondike solvability checker.
//!
//! Used by the engine to back the **Settings → Gameplay → "Winnable
//! deals only"** toggle: when on, the engine retries fresh deal seeds
//! until [`try_solve`] returns [`SolverResult::Winnable`] (or
//! [`SolverResult::Inconclusive`], which we treat as winnable because
//! we cannot prove otherwise) up to a fixed retry cap.
//!
//! The implementation is a hand-rolled depth-first search with
//! memoisation on a deterministic canonical state hash. It uses no
//! external crates beyond what `solitaire_core` already depends on
//! (`std::collections::HashSet`, `std::hash::DefaultHasher`).
//!
//! # Algorithm
//!
//! 1. Encode the game state into a canonical `u64` hash. Tableau
//! columns are encoded top-to-bottom along with each card's face
//! state; foundations are encoded by their top card; stock and
//! waste are encoded as the concatenation of their card ids in
//! order. Two states with the same canonical hash are considered
//! equivalent for the purposes of pruning.
//!
//! 2. At each search step, enumerate the candidate moves in priority
//! order:
//! - **Foundation moves first** — moving a card to a foundation
//! pile reduces the search frontier and never traps the player.
//! Aces and twos are unconditional (the spec calls these out as
//! "no choice involved" forced plays).
//! - **Inter-tableau moves next** — moves between tableau columns
//! that *don't* immediately undo the previous move (a "self-undo"
//! filter prevents the trivial A→B then B→A cycle).
//! - **Stock/waste draw last** — drawing permutes a long sequence
//! and is the costliest move. It's also the only source of
//! branching once the tableau is locked, so we enumerate it last
//! and only when no productive move was made since the previous
//! stock cycle (we track this with a "drew without other progress"
//! counter).
//!
//! 3. After each move, recurse. If the recursion finds a win we
//! propagate `Winnable` immediately. If the visited-state set or
//! the move-budget counter is exhausted we return `Inconclusive`.
//! Otherwise we exhaust all moves and return `Unwinnable`.
//!
//! # Determinism
//!
//! The search is fully deterministic: move enumeration walks piles in
//! a fixed order and the canonical hash is built with `DefaultHasher`,
//! whose seed is fixed across program runs but documented as not
//! cryptographically stable. For the purposes of "same input → same
//! output across one program run" this is sufficient; the spec
//! explicitly calls `DefaultHasher` "fine for this".
//!
//! # Performance
//!
//! On real fresh deals the solver completes in tens of milliseconds
//! (median ~30 ms on the synthetic deals used by the tests below).
//! Pathological deals are bounded by [`SolverConfig::move_budget`] and
//! [`SolverConfig::state_budget`] — when either trips we return
//! [`SolverResult::Inconclusive`]. The retry loop in the engine treats
//! Inconclusive as winnable so a player who turns the toggle on never
//! sees a hung "searching..." state.
use std::collections::HashSet;
use std::hash::{Hash, Hasher};
use crate::card::{Card, Suit};
use crate::deck::{deal_klondike, Deck};
use crate::game_state::DrawMode;
use crate::pile::{Pile, PileType};
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
/// Verdict returned by [`try_solve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SolverResult {
/// The solver found a sequence of moves that wins the deal.
Winnable,
/// The solver exhaustively searched and confirmed no win exists.
Unwinnable,
/// The time / move budget was exceeded before a verdict could be
/// reached. Callers should treat this as winnable since we cannot
/// prove otherwise — Klondike has many deals where the search tree
/// is theoretically tractable but practically too wide for a
/// bounded DFS.
Inconclusive,
}
/// Tunable budgets controlling how long [`try_solve`] is willing to
/// search before bailing out with [`SolverResult::Inconclusive`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SolverConfig {
/// Maximum total moves to consider across the entire search tree.
/// Default: `100_000`. A realistic Klondike solve fits in
/// ~10k30k moves for solvable deals; the cap lets us bail out of
/// pathological states.
pub move_budget: u64,
/// Maximum unique states to visit. Memoisation prevents revisiting,
/// but the visited set grows unbounded without a cap. Default:
/// `200_000`.
pub state_budget: usize,
}
impl Default for SolverConfig {
fn default() -> Self {
Self {
move_budget: 100_000,
state_budget: 200_000,
}
}
}
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`.
///
/// This is a pure function — same input always yields the same
/// [`SolverResult`] within one program run.
///
/// The solver only explores *Classic* Klondike rules: there's no
/// undo, no Zen-mode score suppression, and no Challenge-mode undo
/// ban (irrelevant since the solver never undoes). The same engine
/// rules ([`can_place_on_foundation`], [`can_place_on_tableau`],
/// [`is_valid_tableau_sequence`]) drive move enumeration so the
/// solver's notion of "legal" exactly matches the live game.
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
let state = SolverState::initial(seed, draw_mode);
let mut visited: HashSet<u64> = HashSet::new();
let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false;
let won = state.search(config, &mut visited, &mut moves_consumed, &mut budget_exceeded);
if won {
SolverResult::Winnable
} else if budget_exceeded {
SolverResult::Inconclusive
} else {
SolverResult::Unwinnable
}
}
// ---------------------------------------------------------------------------
// Internal solver state
// ---------------------------------------------------------------------------
/// The candidate moves the solver enumerates at each step. Distinct
/// from `MoveRequestEvent` (engine-level) and `move_cards` (game-level)
/// because the solver also needs to model the stock-draw + recycle as a
/// first-class move.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SolverMove {
/// Move `count` cards from a tableau column to another tableau column.
TableauToTableau { from: usize, to: usize, count: usize },
/// Move the top of a tableau column to a foundation slot.
TableauToFoundation { from: usize, slot: u8 },
/// Move the top of the waste pile to a tableau column.
WasteToTableau { to: usize },
/// Move the top of the waste pile to a foundation slot.
WasteToFoundation { slot: u8 },
/// Draw from stock to waste (or recycle waste → stock if stock is empty).
Draw,
}
/// Compact replica of `GameState` tailored for the solver. Strips
/// undo / score / move-count tracking and replaces the `HashMap` of
/// piles with fixed arrays so the canonical hash is cheap to compute.
#[derive(Clone)]
struct SolverState {
tableau: [Vec<Card>; 7],
foundation: [Vec<Card>; 4],
stock: Vec<Card>,
waste: Vec<Card>,
draw_mode: DrawMode,
/// True when we just drew (or recycled) and have not yet made a
/// productive non-draw move. While set, further consecutive draws
/// without intervening progress are skipped — see the algorithm
/// note above.
just_drew: bool,
/// Number of draws performed since the last non-draw move. Used
/// to detect "we've cycled the entire stock without finding any
/// playable card", which guarantees no further benefit from
/// drawing.
consecutive_draws: u32,
}
impl SolverState {
fn initial(seed: u64, draw_mode: DrawMode) -> Self {
let mut deck = Deck::new();
deck.shuffle(seed);
let (tableau_piles, stock_pile) = deal_klondike(deck);
let tableau: [Vec<Card>; 7] = tableau_piles.map(|p| p.cards);
let foundation: [Vec<Card>; 4] = core::array::from_fn(|_| Vec::new());
Self {
tableau,
foundation,
stock: stock_pile.cards,
waste: Vec::new(),
draw_mode,
just_drew: false,
consecutive_draws: 0,
}
}
/// True when every foundation slot has 13 cards.
fn is_won(&self) -> bool {
self.foundation.iter().all(|f| f.len() == 13)
}
/// Returns the foundation slot that already claims `suit`, or the
/// first empty slot if no slot claims it. Used so foundation moves
/// always target a single deterministic slot per (card, board) pair.
fn target_foundation_slot(&self, suit: Suit) -> Option<u8> {
let mut empty: Option<u8> = None;
for (idx, pile) in self.foundation.iter().enumerate() {
match pile.first() {
Some(bottom) if bottom.suit == suit => return Some(idx as u8),
None if empty.is_none() => empty = Some(idx as u8),
_ => {}
}
}
empty
}
/// Build a temporary `Pile` view for use with the rule helpers.
/// Cheap clone — the helpers only inspect the top card, so we
/// pass a thin wrapper. (The compiler reuses the inner Vec by
/// value because we drop it immediately.)
fn pile_view(pile_type: PileType, cards: &[Card]) -> Pile {
Pile {
pile_type,
cards: cards.to_vec(),
}
}
/// Enumerate every legal candidate move in priority order:
/// foundation > inter-tableau > waste-to-tableau > stock-draw.
/// The order matters — foundation moves shrink the search frontier
/// fastest, and stock-draws are the costliest. See the top-of-file
/// algorithm note.
fn enumerate_moves(&self) -> Vec<SolverMove> {
let mut moves: Vec<SolverMove> = Vec::new();
// 1) Foundation moves from tableau tops.
for (i, col) in self.tableau.iter().enumerate() {
if let Some(top) = col.last()
&& top.face_up
&& let Some(slot) = self.target_foundation_slot(top.suit)
{
let foundation_pile = Self::pile_view(
PileType::Foundation(slot),
&self.foundation[slot as usize],
);
if can_place_on_foundation(top, &foundation_pile) {
moves.push(SolverMove::TableauToFoundation { from: i, slot });
}
}
}
// 2) Foundation move from the waste top.
if let Some(top) = self.waste.last()
&& let Some(slot) = self.target_foundation_slot(top.suit)
{
let foundation_pile = Self::pile_view(
PileType::Foundation(slot),
&self.foundation[slot as usize],
);
if can_place_on_foundation(top, &foundation_pile) {
moves.push(SolverMove::WasteToFoundation { slot });
}
}
// 3) Inter-tableau moves. For each source column, find the
// longest face-up valid run, then enumerate every prefix
// length that lands legally on every other column. Skip
// moves that just relocate a King onto an empty column when
// the source column would also be left empty (a no-op).
for src in 0..7usize {
let col = &self.tableau[src];
if col.is_empty() {
continue;
}
// Find the largest k such that col[col.len()-k..] is all
// face-up and a valid descending alternating run.
let max_run = longest_face_up_run(col);
for count in 1..=max_run {
let start = col.len() - count;
let bottom = &col[start];
for dst in 0..7usize {
if dst == src {
continue;
}
let dst_pile = Self::pile_view(PileType::Tableau(dst), &self.tableau[dst]);
if !can_place_on_tableau(bottom, &dst_pile) {
continue;
}
// Prune the no-op "drag a King from an empty-after-move
// column onto another empty column".
let leaves_source_empty = start == 0;
let dest_empty = self.tableau[dst].is_empty();
if leaves_source_empty
&& dest_empty
&& bottom.rank == crate::card::Rank::King
{
continue;
}
moves.push(SolverMove::TableauToTableau { from: src, to: dst, count });
}
}
}
// 4) Waste → tableau.
if let Some(top) = self.waste.last() {
for dst in 0..7usize {
let dst_pile = Self::pile_view(PileType::Tableau(dst), &self.tableau[dst]);
if can_place_on_tableau(top, &dst_pile) {
moves.push(SolverMove::WasteToTableau { to: dst });
}
}
}
// 5) Draw — but only if there's something to draw or recycle.
// Skip draws when we've already cycled the full stock+waste
// once without making progress; the deterministic stock
// permutation can't produce new value at that point.
let can_draw = !self.stock.is_empty() || !self.waste.is_empty();
let stock_cycle_len = (self.stock.len() + self.waste.len()) as u32;
// `consecutive_draws > stock_cycle_len` is a conservative cap:
// a single full cycle requires at most `ceil(stock_cycle_len / draw_count)`
// draws (Draw 1 → exactly stock_cycle_len; Draw 3 → fewer), so
// anything past that without intervening progress is wasteful.
let cycled_without_progress =
self.consecutive_draws > stock_cycle_len.saturating_add(1);
if can_draw && !cycled_without_progress {
moves.push(SolverMove::Draw);
}
moves
}
/// Apply `mv` to `self`, returning the previous `consecutive_draws`
/// value so the caller can restore it on backtrack.
fn apply_move(&mut self, mv: SolverMove) -> SolverStateUndo {
let prev_just_drew = self.just_drew;
let prev_consec = self.consecutive_draws;
match mv {
SolverMove::TableauToTableau { from, to, count } => {
let start = self.tableau[from].len() - count;
let moved: Vec<Card> = self.tableau[from].split_off(start);
self.tableau[to].extend(moved);
// Flip the newly exposed source top.
if let Some(top) = self.tableau[from].last_mut()
&& !top.face_up
{
top.face_up = true;
}
self.just_drew = false;
self.consecutive_draws = 0;
}
SolverMove::TableauToFoundation { from, slot } => {
if let Some(card) = self.tableau[from].pop() {
self.foundation[slot as usize].push(card);
if let Some(top) = self.tableau[from].last_mut()
&& !top.face_up
{
top.face_up = true;
}
}
self.just_drew = false;
self.consecutive_draws = 0;
}
SolverMove::WasteToTableau { to } => {
if let Some(card) = self.waste.pop() {
self.tableau[to].push(card);
}
self.just_drew = false;
self.consecutive_draws = 0;
}
SolverMove::WasteToFoundation { slot } => {
if let Some(card) = self.waste.pop() {
self.foundation[slot as usize].push(card);
}
self.just_drew = false;
self.consecutive_draws = 0;
}
SolverMove::Draw => {
if self.stock.is_empty() {
// Recycle waste back to stock face-down, reversed.
let mut recycled: Vec<Card> = self.waste.drain(..).collect();
recycled.reverse();
for mut c in recycled {
c.face_up = false;
self.stock.push(c);
}
} else {
let draw_count = match self.draw_mode {
DrawMode::DrawOne => 1,
DrawMode::DrawThree => 3,
};
let avail = self.stock.len().min(draw_count);
let drain_start = self.stock.len() - avail;
let drawn: Vec<Card> = self.stock.drain(drain_start..).collect();
for mut c in drawn {
c.face_up = true;
self.waste.push(c);
}
}
self.just_drew = true;
self.consecutive_draws = self.consecutive_draws.saturating_add(1);
}
}
SolverStateUndo {
prev_just_drew,
prev_consec,
}
}
/// Iterative depth-first search using an explicit stack — recursion
/// blew through Rust's default 8 MB stack on long real-deal solves
/// because each frame held a `SolverState` clone. The explicit
/// stack lives on the heap and grows only with `Vec` capacity, not
/// with thread-stack pages.
///
/// Returns `true` as soon as a winning leaf is found. Sets
/// `*budget_exceeded = true` if either budget trips before a
/// verdict.
fn search(
self,
config: &SolverConfig,
visited: &mut HashSet<u64>,
moves_consumed: &mut u64,
budget_exceeded: &mut bool,
) -> bool {
// Each stack frame keeps a state plus the move iterator we
// haven't yet expanded. Popping a frame is the backtrack.
struct Frame {
state: SolverState,
pending: std::vec::IntoIter<SolverMove>,
}
// Quick exits before allocating the stack.
if self.is_won() {
return true;
}
if *moves_consumed >= config.move_budget || visited.len() >= config.state_budget {
*budget_exceeded = true;
return false;
}
let root_hash = self.canonical_hash();
if !visited.insert(root_hash) {
return false;
}
let root_moves = self.enumerate_moves();
let mut stack: Vec<Frame> = Vec::new();
stack.push(Frame {
state: self,
pending: root_moves.into_iter(),
});
while let Some(frame) = stack.last_mut() {
// Budget gates — checked before consuming the next move so
// the budget exhaustion is reflected in the verdict.
if *moves_consumed >= config.move_budget
|| visited.len() >= config.state_budget
{
*budget_exceeded = true;
return false;
}
let Some(mv) = frame.pending.next() else {
// Exhausted this frame's children — backtrack.
stack.pop();
continue;
};
*moves_consumed = moves_consumed.saturating_add(1);
let mut next = frame.state.clone();
next.apply_move(mv);
if next.is_won() {
return true;
}
let h = next.canonical_hash();
if !visited.insert(h) {
continue;
}
let next_moves = next.enumerate_moves();
stack.push(Frame {
state: next,
pending: next_moves.into_iter(),
});
}
false
}
/// Build a deterministic 64-bit hash of the visible game state.
///
/// The encoding covers every field that can affect future legal
/// moves: tableau column contents (with face_up state), foundation
/// tops (it's enough to know the top card per slot — the rest is
/// implied by the rank), stock + waste card ids in order, and the
/// draw mode. Two states that differ only in `just_drew` or
/// `consecutive_draws` hash equally — those fields are search
/// metadata, not game state.
fn canonical_hash(&self) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
// Tag the encoding with a version byte so future schema
// changes invalidate cached hashes cleanly.
0u8.hash(&mut h);
for col in &self.tableau {
(col.len() as u32).hash(&mut h);
for c in col {
c.id.hash(&mut h);
c.face_up.hash(&mut h);
}
}
for f in &self.foundation {
match f.last() {
Some(top) => {
1u8.hash(&mut h);
top.id.hash(&mut h);
}
None => {
0u8.hash(&mut h);
}
}
}
(self.stock.len() as u32).hash(&mut h);
for c in &self.stock {
c.id.hash(&mut h);
}
(self.waste.len() as u32).hash(&mut h);
for c in &self.waste {
c.id.hash(&mut h);
}
match self.draw_mode {
DrawMode::DrawOne => 1u8.hash(&mut h),
DrawMode::DrawThree => 3u8.hash(&mut h),
}
h.finish()
}
}
/// Bookkeeping captured by [`SolverState::apply_move`] so the caller
/// could in principle restore mutated state. Currently unused —
/// `search` clones before applying — but kept so a future iteration
/// can switch to in-place mutation without changing the apply path.
#[allow(dead_code)]
struct SolverStateUndo {
prev_just_drew: bool,
prev_consec: u32,
}
/// Returns the length of the longest face-up valid descending
/// alternating-colour run anchored at the top of `cards`. Returns 0
/// when the top is face-down (or the column is empty); returns 1 for
/// a single face-up card; otherwise extends as long as the
/// `is_valid_tableau_sequence` constraint holds.
fn longest_face_up_run(cards: &[Card]) -> usize {
if cards.is_empty() {
return 0;
}
let n = cards.len();
let mut k = 0usize;
while k < n {
let candidate = &cards[n - k - 1..];
if !candidate.iter().all(|c| c.face_up) {
break;
}
if !is_valid_tableau_sequence(candidate) {
break;
}
k += 1;
}
k
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::card::{Card, Rank, Suit};
/// Construct a `SolverState` from raw piles for the synthetic
/// hand-crafted test scenarios. Skips deck-shuffle and the deal
/// step so tests can describe a near-finished or pathological
/// position directly.
fn synthetic(
tableau: [Vec<Card>; 7],
foundation: [Vec<Card>; 4],
stock: Vec<Card>,
waste: Vec<Card>,
draw_mode: DrawMode,
) -> SolverState {
SolverState {
tableau,
foundation,
stock,
waste,
draw_mode,
just_drew: false,
consecutive_draws: 0,
}
}
fn empty_columns() -> [Vec<Card>; 7] {
core::array::from_fn(|_| Vec::new())
}
fn empty_foundations() -> [Vec<Card>; 4] {
core::array::from_fn(|_| Vec::new())
}
fn ace(suit: Suit, id: u32) -> Card {
Card { id, suit, rank: Rank::Ace, face_up: true }
}
fn rank_card(suit: Suit, rank: Rank, id: u32) -> Card {
Card { id, suit, rank, face_up: true }
}
fn full_run(suit: Suit, base_id: u32) -> Vec<Card> {
let ranks = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
Rank::Jack, Rank::Queen, Rank::King,
];
ranks
.iter()
.enumerate()
.map(|(i, r)| Card {
id: base_id + i as u32,
suit,
rank: *r,
face_up: true,
})
.collect()
}
#[test]
fn solver_recognises_obviously_winnable_deal() {
// Construct a position where the four foundations are already
// 12 cards each (Ace through Queen) and the four Kings sit
// exposed on individual tableau columns. The solver only has
// to play the four Kings to win.
let mut foundations: [Vec<Card>; 4] = empty_foundations();
for (slot, suit) in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]
.iter()
.enumerate()
{
let mut full = full_run(*suit, (slot as u32) * 13);
full.pop(); // remove King
foundations[slot] = full;
}
let mut tableau = empty_columns();
tableau[0] = vec![rank_card(Suit::Clubs, Rank::King, 100)];
tableau[1] = vec![rank_card(Suit::Diamonds, Rank::King, 101)];
tableau[2] = vec![rank_card(Suit::Hearts, Rank::King, 102)];
tableau[3] = vec![rank_card(Suit::Spades, Rank::King, 103)];
let state = synthetic(tableau, foundations, Vec::new(), Vec::new(), DrawMode::DrawOne);
let mut visited: HashSet<u64> = HashSet::new();
let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false;
let cfg = SolverConfig::default();
let won = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
assert!(won, "obviously-winnable position must be recognised as Winnable");
assert!(!budget_exceeded);
assert!(
moves_consumed < 1000,
"near-finished deal should solve in well under 1k moves; consumed {moves_consumed}"
);
}
#[test]
fn solver_recognises_obviously_unwinnable_deal() {
// Synthesise a state where one tableau column buries the Ace
// of Spades under the Two of Spades, both face-up, with no
// stock, no waste, no other moves available. The Two cannot
// go anywhere (nothing to land on; no foundation accepts a
// bare Two), and the Ace is buried, so the deal is dead.
let mut tableau = empty_columns();
// Column 0: bottom-to-top [A♠, 2♠]. The Ace is the bottom
// card; the Two on top of it has no valid destination.
tableau[0] = vec![
Card { id: 0, suit: Suit::Spades, rank: Rank::Ace, face_up: true },
Card { id: 1, suit: Suit::Spades, rank: Rank::Two, face_up: true },
];
// Other six columns isolated. Put a face-up King with no
// matching Queen anywhere — it cannot move because every
// other column is empty (Kings move to empty columns, but a
// King already sitting alone on a column moving to an empty
// column is a no-op, pruned by enumerate_moves).
tableau[1] = vec![rank_card(Suit::Clubs, Rank::King, 2)];
// Empty columns 2..6 — irrelevant.
let state = synthetic(
tableau,
empty_foundations(),
Vec::new(),
Vec::new(),
DrawMode::DrawOne,
);
let cfg = SolverConfig::default();
let mut visited: HashSet<u64> = HashSet::new();
let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false;
let won = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
assert!(!won, "buried Ace under same-suit Two with no recovery must not solve");
assert!(!budget_exceeded, "small synthetic state must complete within budget");
}
#[test]
fn solver_returns_inconclusive_when_budget_exceeded() {
// Tiny budgets force the search to bail before exploring
// meaningful branches on a real fresh deal.
let cfg = SolverConfig {
move_budget: 50,
state_budget: 50,
};
let result = try_solve(0, DrawMode::DrawOne, &cfg);
assert_eq!(
result,
SolverResult::Inconclusive,
"very tight budgets must surface as Inconclusive on a real deal"
);
}
#[test]
fn solver_is_deterministic() {
// Same seed + same draw mode + same config must always return
// the same verdict. We use a tight budget so the test runs
// fast even when seed N happens to be a long-search deal.
let cfg = SolverConfig {
move_budget: 5_000,
state_budget: 5_000,
};
let r1 = try_solve(7, DrawMode::DrawOne, &cfg);
let r2 = try_solve(7, DrawMode::DrawOne, &cfg);
let r3 = try_solve(7, DrawMode::DrawOne, &cfg);
assert_eq!(r1, r2, "repeat solves must yield the same result");
assert_eq!(r2, r3);
}
#[test]
fn solver_handles_draw_three_mode() {
// The solver must accept DrawMode::DrawThree and never panic.
// A tight budget keeps the test fast — we only assert that
// the call returns a verdict (any of the three variants) and
// that the verdict is reproducible.
let cfg = SolverConfig {
move_budget: 5_000,
state_budget: 5_000,
};
let r1 = try_solve(123, DrawMode::DrawThree, &cfg);
let r2 = try_solve(123, DrawMode::DrawThree, &cfg);
assert_eq!(r1, r2, "DrawThree solver must be deterministic");
}
#[test]
fn try_solve_winnable_synthetic_via_real_init_path() {
// Cross-check: try_solve with the default budget on a real
// dealt seed should never panic and should return one of the
// three verdict variants. We don't pin a specific verdict —
// that would tightly couple the test to RNG behaviour — but
// we do assert the function reaches a result.
let cfg = SolverConfig::default();
let _verdict = try_solve(42, DrawMode::DrawOne, &cfg);
// Reaching here means the function returned without panic.
}
#[test]
fn longest_face_up_run_handles_face_down_at_top() {
let cards = vec![
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: false },
];
assert_eq!(longest_face_up_run(&cards), 0);
}
#[test]
fn longest_face_up_run_extends_through_valid_run() {
let cards = vec![
// bottom: face-down filler
Card { id: 0, suit: Suit::Spades, rank: Rank::Two, face_up: false },
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true },
Card { id: 2, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
Card { id: 3, suit: Suit::Clubs, rank: Rank::Jack, face_up: true },
];
assert_eq!(longest_face_up_run(&cards), 3);
}
#[test]
fn longest_face_up_run_breaks_on_invalid_sequence() {
// K♠ Q♥ Q♣ — second pair fails the descending check, so the
// run is just the top single card (Q♣).
let cards = vec![
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true },
Card { id: 2, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
Card { id: 3, suit: Suit::Clubs, rank: Rank::Queen, face_up: true },
];
assert_eq!(longest_face_up_run(&cards), 1);
}
#[test]
fn target_foundation_slot_prefers_claimed_suit() {
let mut state = synthetic(
empty_columns(),
empty_foundations(),
Vec::new(),
Vec::new(),
DrawMode::DrawOne,
);
// Slot 0 is empty; slot 1 already holds the Ace of Hearts.
state.foundation[1].push(ace(Suit::Hearts, 0));
assert_eq!(state.target_foundation_slot(Suit::Hearts), Some(1));
}
#[test]
fn target_foundation_slot_falls_back_to_empty() {
let state = synthetic(
empty_columns(),
empty_foundations(),
Vec::new(),
Vec::new(),
DrawMode::DrawOne,
);
// No slot claims any suit; every Ace targets slot 0.
assert_eq!(state.target_foundation_slot(Suit::Spades), Some(0));
}
/// Scan a wide seed window to find one Winnable + one Unwinnable
/// seed under tight budgets. Used during development to source the
/// fixture seeds for the engine-level retry test.
/// Run with:
/// `cargo test -p solitaire_core --release -- --ignored find_unwinnable --nocapture`.
#[test]
#[ignore]
fn find_unwinnable() {
let cfg = SolverConfig::default();
let mut found = 0;
let mut counts = [0u32; 3];
for seed in 0u64..500 {
let r = try_solve(seed, DrawMode::DrawOne, &cfg);
let bucket = match r {
SolverResult::Winnable => 0,
SolverResult::Unwinnable => 1,
SolverResult::Inconclusive => 2,
};
counts[bucket] += 1;
if r == SolverResult::Unwinnable {
println!("seed {seed} -> Unwinnable");
let next = try_solve(seed.wrapping_add(1), DrawMode::DrawOne, &cfg);
println!("seed {} -> {:?}", seed.wrapping_add(1), next);
found += 1;
if found >= 5 {
break;
}
}
}
println!(
"(scan complete) Winnable={} Unwinnable={} Inconclusive={}",
counts[0], counts[1], counts[2]
);
}
/// Manual bench — run with:
/// `cargo test -p solitaire_core --release -- --ignored solver_bench --nocapture`.
/// Prints per-seed timing and the verdict distribution so a developer
/// can sanity-check the median. Not part of the regular suite because
/// (a) it's slow and (b) timing output is noise during normal runs.
#[test]
#[ignore]
fn solver_bench() {
let cfg = SolverConfig::default();
let mut samples_ms: Vec<u128> = Vec::new();
let mut counts = [0u32; 3];
for seed in 0u64..20 {
let t = std::time::Instant::now();
let r = try_solve(seed, DrawMode::DrawOne, &cfg);
let ms = t.elapsed().as_millis();
samples_ms.push(ms);
let bucket = match r {
SolverResult::Winnable => 0,
SolverResult::Unwinnable => 1,
SolverResult::Inconclusive => 2,
};
counts[bucket] += 1;
println!("seed={seed:3} {ms:>6} ms {r:?}");
}
samples_ms.sort_unstable();
let median = samples_ms[samples_ms.len() / 2];
let total: u128 = samples_ms.iter().sum();
println!(
"\nmedian: {median} ms mean: {} ms Winnable: {} Unwinnable: {} Inconclusive: {}",
total / samples_ms.len() as u128,
counts[0], counts[1], counts[2],
);
}
}
+8 -5
View File
@@ -141,9 +141,9 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
pub mod settings; pub mod settings;
pub use settings::{ pub use settings::{
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend, load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
Theme, WindowGeometry, TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN, Theme, WindowGeometry, SOLVER_DEAL_RETRY_CAP, TIME_BONUS_MULTIPLIER_MAX,
TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS,
TOOLTIP_DELAY_STEP_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
}; };
pub mod auth_tokens; pub mod auth_tokens;
@@ -155,7 +155,10 @@ pub mod sync_client;
pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient}; pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient};
pub mod replay; pub mod replay;
#[allow(deprecated)]
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
pub use replay::{ pub use replay::{
latest_replay_path, load_latest_replay_from, save_latest_replay_to, Replay, ReplayMove, append_replay_to_history, load_replay_history_from, migrate_legacy_latest_replay,
REPLAY_SCHEMA_VERSION, replay_history_path, save_replay_history_to, Replay, ReplayHistory, ReplayMove,
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
}; };
+405
View File
@@ -31,6 +31,34 @@ use solitaire_core::pile::PileType;
const APP_DIR_NAME: &str = "solitaire_quest"; const APP_DIR_NAME: &str = "solitaire_quest";
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json"; const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
/// Maximum number of recent winning replays the rolling history retains.
///
/// When [`append_replay_to_history`] pushes a fresh entry past this cap,
/// the oldest entry is dropped so the file never grows unbounded. The
/// player can revisit any of the last [`REPLAY_HISTORY_CAP`] wins from
/// the Stats overlay's replay selector — older wins age out silently.
pub const REPLAY_HISTORY_CAP: usize = 8;
/// Save-file schema version for [`ReplayHistory`]. Bump when the on-disk
/// shape of the wrapper changes incompatibly so [`load_replay_history_from`]
/// returns `None` for older files (the player simply sees an empty
/// history rather than a half-loaded broken one). Bumping
/// [`REPLAY_SCHEMA_VERSION`] independently invalidates individual
/// [`Replay`] payloads inside an otherwise-current history.
///
/// History:
/// - v1 (current): initial release of the rolling history wrapper.
pub const REPLAY_HISTORY_SCHEMA_VERSION: u32 = 1;
/// Default value for [`ReplayHistory::schema_version`] when deserialising
/// files that pre-date the field. Any value other than
/// [`REPLAY_HISTORY_SCHEMA_VERSION`] causes [`load_replay_history_from`]
/// to return `None`.
fn history_schema_v0() -> u32 {
0
}
/// Save-file schema version for [`Replay`]. Increment when the on-disk /// Save-file schema version for [`Replay`]. Increment when the on-disk
/// representation changes incompatibly so [`load_latest_replay_from`] can /// representation changes incompatibly so [`load_latest_replay_from`] can
@@ -138,17 +166,87 @@ impl Replay {
} }
} }
/// Rolling history of the player's most recent winning replays.
///
/// Stored as a single JSON file at
/// `<data_dir>/solitaire_quest/replays.json` (see
/// [`replay_history_path`]). Capped at [`REPLAY_HISTORY_CAP`] entries —
/// when [`append_replay_to_history`] pushes past the cap, the oldest
/// entry is dropped so the file never grows unbounded.
///
/// `replays[0]` is always the most recent win; the Stats overlay's
/// replay selector defaults to that entry and surfaces the older
/// entries behind a small chooser so the player can revisit a memorable
/// game even after a more recent win.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReplayHistory {
/// Schema version. See [`REPLAY_HISTORY_SCHEMA_VERSION`].
#[serde(default = "history_schema_v0")]
pub schema_version: u32,
/// Most recent first. Capped at [`REPLAY_HISTORY_CAP`] entries —
/// older entries drop off when the cap is hit.
pub replays: Vec<Replay>,
}
impl Default for ReplayHistory {
/// An empty history at the current schema version. Used by callers
/// that need a starting point before the first winning replay has
/// ever been recorded.
fn default() -> Self {
Self {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: Vec::new(),
}
}
}
impl ReplayHistory {
/// Returns the most recent replay (`replays[0]`), or `None` when the
/// history is empty. Convenience used by the Stats overlay's default
/// selector position.
pub fn most_recent(&self) -> Option<&Replay> {
self.replays.first()
}
/// Returns the number of replays currently retained.
pub fn len(&self) -> usize {
self.replays.len()
}
/// Returns `true` when no replays have been recorded yet.
pub fn is_empty(&self) -> bool {
self.replays.is_empty()
}
}
/// Returns the platform-specific path to `latest_replay.json`, or `None` /// Returns the platform-specific path to `latest_replay.json`, or `None`
/// if `dirs::data_dir()` is unavailable (e.g. minimal Linux containers). /// if `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
#[deprecated(
note = "single-slot replay storage replaced by the rolling history at \
replay_history_path(); kept for the one-shot legacy migration \
in migrate_legacy_latest_replay"
)]
pub fn latest_replay_path() -> Option<PathBuf> { pub fn latest_replay_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME)) dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
} }
/// Returns the platform-specific path to `replays.json`, the rolling
/// history file, or `None` if `dirs::data_dir()` is unavailable (e.g.
/// minimal Linux containers).
pub fn replay_history_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
}
/// Save a [`Replay`] atomically to `path` using the standard `.tmp` → /// Save a [`Replay`] atomically to `path` using the standard `.tmp` →
/// rename contract that the rest of `storage.rs` uses. /// rename contract that the rest of `storage.rs` uses.
/// ///
/// Overwrites any existing replay — only the most recent winning replay /// Overwrites any existing replay — only the most recent winning replay
/// is retained on disk. /// is retained on disk.
#[deprecated(
note = "single-slot replay storage replaced by the rolling history; \
use append_replay_to_history instead. Kept for the one-shot \
legacy migration."
)]
pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> { pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
@@ -168,6 +266,11 @@ pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
/// "No replay recorded yet" caption rather than a half-loaded broken /// "No replay recorded yet" caption rather than a half-loaded broken
/// replay. Bumping [`REPLAY_SCHEMA_VERSION`] therefore invalidates every /// replay. Bumping [`REPLAY_SCHEMA_VERSION`] therefore invalidates every
/// older save without further migration code. /// older save without further migration code.
#[deprecated(
note = "single-slot replay storage replaced by the rolling history; \
use load_replay_history_from instead. Kept for the one-shot \
legacy migration."
)]
pub fn load_latest_replay_from(path: &Path) -> Option<Replay> { pub fn load_latest_replay_from(path: &Path) -> Option<Replay> {
let data = fs::read(path).ok()?; let data = fs::read(path).ok()?;
let replay: Replay = serde_json::from_slice(&data).ok()?; let replay: Replay = serde_json::from_slice(&data).ok()?;
@@ -177,7 +280,124 @@ pub fn load_latest_replay_from(path: &Path) -> Option<Replay> {
Some(replay) Some(replay)
} }
/// Save a [`ReplayHistory`] atomically to `path` using the standard
/// `.tmp` → rename contract.
///
/// The on-disk encoding is pretty-printed JSON; the file is intended to
/// be small (≤ [`REPLAY_HISTORY_CAP`] entries, each carrying a few
/// hundred move records at most) so the readability tradeoff is fine.
pub fn save_replay_history_to(path: &Path, history: &ReplayHistory) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(history).map_err(io::Error::other)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, json.as_bytes())?;
fs::rename(&tmp, path)?;
Ok(())
}
/// Load a [`ReplayHistory`] from `path`, returning `None` when the file
/// is missing, corrupt, or carries a [`schema_version`](ReplayHistory::schema_version)
/// other than [`REPLAY_HISTORY_SCHEMA_VERSION`].
///
/// Individual [`Replay`] entries inside an otherwise-current history are
/// filtered to only those carrying [`REPLAY_SCHEMA_VERSION`] — older
/// entries are silently dropped so a future bump of the inner replay
/// schema does not corrupt the wrapper.
pub fn load_replay_history_from(path: &Path) -> Option<ReplayHistory> {
let data = fs::read(path).ok()?;
let history: ReplayHistory = serde_json::from_slice(&data).ok()?;
if history.schema_version != REPLAY_HISTORY_SCHEMA_VERSION {
return None;
}
let filtered: Vec<Replay> = history
.replays
.into_iter()
.filter(|r| r.schema_version == REPLAY_SCHEMA_VERSION)
.collect();
Some(ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: filtered,
})
}
/// Append `replay` to the front of the rolling history at `path`,
/// dropping the oldest entry once [`REPLAY_HISTORY_CAP`] is exceeded,
/// and persist the updated history atomically.
///
/// If `path` has no existing history (missing file, corrupt, or
/// schema-mismatched) a fresh [`ReplayHistory::default`] is used as the
/// starting point so the new replay is always saved. The returned
/// [`ReplayHistory`] is the exact value written to disk so callers can
/// update an in-memory mirror (e.g. the Stats overlay's
/// `ReplayHistoryResource`) without a follow-up `load`.
pub fn append_replay_to_history(
path: &Path,
replay: Replay,
) -> io::Result<ReplayHistory> {
let mut history = load_replay_history_from(path).unwrap_or_default();
// Most recent first. Reserve the front slot; pop the oldest if we
// exceed the cap so the file never grows unbounded.
history.replays.insert(0, replay);
if history.replays.len() > REPLAY_HISTORY_CAP {
history.replays.truncate(REPLAY_HISTORY_CAP);
}
save_replay_history_to(path, &history)?;
Ok(history)
}
/// One-shot migration from the legacy single-slot
/// `latest_replay.json` file to the rolling [`ReplayHistory`] stored at
/// `history_path`.
///
/// Behaviour matrix:
/// - `history_path` already exists → no-op (the rolling history wins).
/// - `history_path` is absent and `latest_path` is absent → no-op.
/// - `history_path` is absent and `latest_path` exists with a valid
/// replay → seed a fresh history with that one replay and write it.
/// - `history_path` is absent and `latest_path` exists but is corrupt /
/// schema-mismatched → write an empty history (we know the player is
/// on the new build and shouldn't keep being prompted to migrate).
///
/// The legacy `latest_replay.json` file is intentionally NOT deleted by
/// this helper — keep it for one release as a safety net so a player
/// rolling back to the previous build doesn't lose their last winning
/// replay. The deletion is planned for the release after this one.
pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
if history_path.exists() {
// Rolling history is authoritative once it exists.
return;
}
if !latest_path.exists() {
return;
}
// Use the deprecated loader directly — the migration is the one
// place we still consult the legacy file shape on purpose.
#[allow(deprecated)]
let legacy = load_latest_replay_from(latest_path);
let history = match legacy {
Some(replay) => ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: vec![replay],
},
None => ReplayHistory::default(),
};
if let Err(e) = save_replay_history_to(history_path, &history) {
// Migration failure is non-fatal: on the next launch we'll just
// try again. We log to stderr rather than panic so headless
// tests stay quiet.
eprintln!(
"replay: failed to migrate legacy latest_replay.json into rolling history: {e}",
);
}
}
#[cfg(test)] #[cfg(test)]
// The legacy single-slot tests still exercise `save_latest_replay_to` /
// `load_latest_replay_from` on purpose — they're the round-trip
// guardrails for the migration source format.
#[allow(deprecated)]
mod tests { mod tests {
use super::*; use super::*;
use std::env; use std::env;
@@ -294,4 +514,189 @@ mod tests {
assert!(load_latest_replay_from(&path).is_none()); assert!(load_latest_replay_from(&path).is_none());
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
} }
// -----------------------------------------------------------------------
// ReplayHistory — rolling list of recent wins
// -----------------------------------------------------------------------
/// Build a [`Replay`] whose `final_score` carries `id` so tests can
/// assert ordering / identity without writing a deep equality match.
fn replay_with_id(id: i32) -> Replay {
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
Replay::new(
id as u64,
DrawMode::DrawOne,
GameMode::Classic,
60,
id,
date,
vec![ReplayMove::StockClick],
)
}
/// Pushing past [`REPLAY_HISTORY_CAP`] must drop the oldest entries —
/// the on-disk file (and the in-memory mirror returned by the helper)
/// stays bounded so the user's data dir never grows unbounded.
#[test]
fn append_replay_to_history_caps_at_eight() {
let path = tmp_path("history_cap");
let _ = fs::remove_file(&path);
let mut last_returned = ReplayHistory::default();
for i in 0..10 {
last_returned = append_replay_to_history(&path, replay_with_id(i))
.expect("append must succeed");
}
assert_eq!(
last_returned.replays.len(),
REPLAY_HISTORY_CAP,
"history must be capped at REPLAY_HISTORY_CAP entries",
);
// The most recent ten pushes were ids 0..=9; ids 9, 8, ..., 2
// survive (newest first), ids 0 and 1 aged out.
let ids: Vec<i32> = last_returned.replays.iter().map(|r| r.final_score).collect();
assert_eq!(
ids,
vec![9, 8, 7, 6, 5, 4, 3, 2],
"newest entries must survive, oldest must age out",
);
// The on-disk file must agree with the returned in-memory copy.
let loaded = load_replay_history_from(&path).expect("load must succeed");
assert_eq!(loaded, last_returned, "disk must mirror returned history");
let _ = fs::remove_file(&path);
}
/// `append_replay_to_history` must place new entries at index 0 so
/// the Stats overlay's default selector (most recent) lands on the
/// just-saved replay.
#[test]
fn append_replay_inserts_at_front() {
let path = tmp_path("history_front");
let _ = fs::remove_file(&path);
append_replay_to_history(&path, replay_with_id(1)).expect("append 1");
append_replay_to_history(&path, replay_with_id(2)).expect("append 2");
let history = append_replay_to_history(&path, replay_with_id(3)).expect("append 3");
let ids: Vec<i32> = history.replays.iter().map(|r| r.final_score).collect();
assert_eq!(
ids,
vec![3, 2, 1],
"history must be reverse-chronological (newest first)",
);
let _ = fs::remove_file(&path);
}
/// On first launch with the new code, a pre-existing
/// `latest_replay.json` must seed the new rolling history so the
/// player doesn't lose their last winning replay across the upgrade.
#[test]
fn legacy_latest_replay_migrates_to_history_on_first_launch() {
let latest = tmp_path("legacy_migrate_latest");
let history = tmp_path("legacy_migrate_history");
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
// Seed the legacy file with a real replay.
let legacy_replay = sample_replay();
save_latest_replay_to(&latest, &legacy_replay).expect("seed legacy");
assert!(!history.exists(), "history file must not exist pre-migration");
migrate_legacy_latest_replay(&latest, &history);
assert!(history.exists(), "migration must create the history file");
let loaded = load_replay_history_from(&history)
.expect("post-migration history must load");
assert_eq!(loaded.replays.len(), 1, "history must hold exactly the legacy entry");
assert_eq!(loaded.replays[0], legacy_replay, "entry must equal the legacy replay");
// Legacy file is intentionally retained for one release as a
// safety net — see `migrate_legacy_latest_replay` doc comment.
assert!(latest.exists(), "legacy file must NOT be deleted by migration");
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
}
/// When the rolling history file already exists, the migration must
/// be a no-op — we never want to overwrite the player's accumulated
/// history with a stale single-slot legacy entry.
#[test]
fn migrate_is_noop_when_history_already_exists() {
let latest = tmp_path("legacy_noop_latest");
let history = tmp_path("legacy_noop_history");
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
save_latest_replay_to(&latest, &sample_replay()).expect("seed legacy");
let pre_existing = ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: vec![replay_with_id(42)],
};
save_replay_history_to(&history, &pre_existing).expect("seed history");
migrate_legacy_latest_replay(&latest, &history);
let loaded = load_replay_history_from(&history).expect("load");
assert_eq!(loaded, pre_existing, "existing history must not be overwritten");
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
}
/// A populated [`ReplayHistory`] must round-trip byte-identically
/// through `save_replay_history_to` / `load_replay_history_from`.
#[test]
fn replay_history_round_trips_through_save_and_load() {
let path = tmp_path("history_round_trip");
let _ = fs::remove_file(&path);
let history = ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: vec![replay_with_id(7), replay_with_id(3), sample_replay()],
};
save_replay_history_to(&path, &history).expect("save");
let loaded = load_replay_history_from(&path).expect("load");
assert_eq!(loaded, history, "round-trip must preserve every field");
let _ = fs::remove_file(&path);
}
/// A file written by an older history schema must be rejected so the
/// player sees a clean empty history rather than a half-loaded one.
#[test]
fn replay_history_legacy_schema_version_falls_through_to_none() {
let path = tmp_path("history_legacy_schema");
let _ = fs::remove_file(&path);
// No `schema_version` key → defaults to 0 via `history_schema_v0()`.
let v0_json = r#"{
"replays": []
}"#;
fs::write(&path, v0_json).expect("write v0 fixture");
assert!(
load_replay_history_from(&path).is_none(),
"v0 history must be rejected (schema gate)",
);
let _ = fs::remove_file(&path);
}
/// Atomic-write contract for the rolling history — `.tmp` must not be
/// left behind after `save_replay_history_to` returns.
#[test]
fn replay_history_save_is_atomic() {
let path = tmp_path("history_atomic");
let _ = fs::remove_file(&path);
save_replay_history_to(&path, &ReplayHistory::default()).expect("save");
let tmp = path.with_extension("json.tmp");
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
let _ = fs::remove_file(&path);
}
} }
+73
View File
@@ -166,6 +166,21 @@ pub struct Settings {
/// `#[serde(default = "default_time_bonus_multiplier")]`. /// `#[serde(default = "default_time_bonus_multiplier")]`.
#[serde(default = "default_time_bonus_multiplier")] #[serde(default = "default_time_bonus_multiplier")]
pub time_bonus_multiplier: f32, pub time_bonus_multiplier: f32,
/// When `true`, the engine rejects new-game deals the
/// [`solitaire_core::solver`] cannot prove winnable, retrying
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
/// giving up and using the last tried seed. Off by default —
/// the solver adds a few hundred milliseconds of latency on the
/// pathological deals that hit the budget cap, and not every
/// player wants to wait. Older `settings.json` files written
/// before this field existed deserialize cleanly to `false` via
/// `#[serde(default)]`.
///
/// Scope: only random-seed Classic-mode deals are filtered.
/// Daily challenges, replays, and explicit-seed requests skip the
/// solver retry loop — see `solitaire_engine::handle_new_game`.
#[serde(default)]
pub winnable_deals_only: bool,
} }
fn default_draw_mode() -> DrawMode { fn default_draw_mode() -> DrawMode {
@@ -223,6 +238,17 @@ fn default_time_bonus_multiplier() -> f32 {
1.0 1.0
} }
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
/// is willing to attempt before giving up and accepting the latest
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
/// every retry comes back [`SolverResult::Unwinnable`] (which would
/// be very unusual) we'd rather hand the player a possibly-unwinnable
/// deal than spin forever on the main thread.
///
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
/// the upper bound on UI freeze when the toggle is on.
pub const SOLVER_DEAL_RETRY_CAP: u32 = 50;
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -241,6 +267,7 @@ impl Default for Settings {
shown_achievement_onboarding: false, shown_achievement_onboarding: false,
tooltip_delay_secs: default_tooltip_delay(), tooltip_delay_secs: default_tooltip_delay(),
time_bonus_multiplier: default_time_bonus_multiplier(), time_bonus_multiplier: default_time_bonus_multiplier(),
winnable_deals_only: false,
} }
} }
} }
@@ -428,6 +455,7 @@ mod tests {
shown_achievement_onboarding: false, shown_achievement_onboarding: false,
tooltip_delay_secs: default_tooltip_delay(), tooltip_delay_secs: default_tooltip_delay(),
time_bonus_multiplier: default_time_bonus_multiplier(), time_bonus_multiplier: default_time_bonus_multiplier(),
winnable_deals_only: false,
}; };
save_settings_to(&path, &s).expect("save"); save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path); let loaded = load_settings_from(&path);
@@ -835,4 +863,49 @@ mod tests {
s2.time_bonus_multiplier s2.time_bonus_multiplier
); );
} }
// -----------------------------------------------------------------------
// winnable_deals_only — solver-backed deal filter toggle
// -----------------------------------------------------------------------
#[test]
fn settings_winnable_deals_only_default_is_false() {
// Off by default — the solver adds latency we shouldn't impose
// on every player without their consent.
assert!(
!Settings::default().winnable_deals_only,
"default winnable_deals_only must be false"
);
}
#[test]
fn settings_winnable_deals_only_round_trip() {
let path = tmp_path("winnable_deals_only_round_trip");
let _ = fs::remove_file(&path);
let s = Settings {
winnable_deals_only: true,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert!(
loaded.winnable_deals_only,
"winnable_deals_only must survive serde round-trip"
);
let _ = fs::remove_file(&path);
}
#[test]
fn legacy_settings_without_winnable_deals_only_deserializes_to_false() {
// A settings.json written before this field existed must
// deserialize cleanly to `false` (the default-off behaviour)
// rather than failing the whole load or surprising the player
// by switching the toggle on.
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
assert!(
!s.winnable_deals_only,
"legacy settings.json missing winnable_deals_only must deserialize to false"
);
}
} }
+276 -4
View File
@@ -25,6 +25,7 @@ use crate::events::{
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate}; use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::replay_playback::ReplayPlaybackState;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::stats_plugin::{StatsResource, StatsUpdate}; use crate::stats_plugin::{StatsResource, StatsUpdate};
@@ -116,7 +117,12 @@ impl Plugin for AchievementPlugin {
.after(StatsUpdate), .after(StatsUpdate),
) )
.add_systems(Update, toggle_achievements_screen) .add_systems(Update, toggle_achievements_screen)
.add_systems(Update, handle_achievements_close_button); .add_systems(Update, handle_achievements_close_button)
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
// `cinephile` the first time playback runs to natural completion.
// Reads the resource via `Option<Res<_>>` so headless tests that
// omit `ReplayPlaybackPlugin` still build.
.add_systems(Update, evaluate_cinephile_on_replay_completion);
} }
} }
@@ -222,6 +228,66 @@ fn evaluate_on_win(
} }
} }
/// Cinephile unlock observer.
///
/// Watches [`ReplayPlaybackState`] and unlocks the `cinephile` achievement
/// the first time the resource transitions from `Playing` to `Completed` —
/// i.e. the player watched a saved replay all the way through. The Stop
/// button transitions `Playing` → `Inactive` directly (never via
/// `Completed`), so manual aborts do not trigger the unlock.
///
/// Idempotent: once the record is unlocked, subsequent Playing → Completed
/// transitions are a no-op (no extra `AchievementUnlockedEvent`, no extra
/// disk write). The transition itself is debounced by tracking the
/// previous frame's `is_playing()` state in a `Local<bool>` — without
/// this, a freshly-spawned `Completed` state would re-fire each frame
/// during the linger window.
///
/// Reads `ReplayPlaybackState` via `Option<Res<_>>` so achievement tests
/// that omit `ReplayPlaybackPlugin` still build cleanly.
fn evaluate_cinephile_on_replay_completion(
state: Option<Res<ReplayPlaybackState>>,
// `Local` collides with `chrono::Local` imported at the top of this
// module — fully qualify so the Bevy system parameter resolves
// correctly.
mut last_was_playing: bevy::prelude::Local<bool>,
mut achievements: ResMut<AchievementsResource>,
mut unlocks: MessageWriter<AchievementUnlockedEvent>,
path: Res<AchievementsStoragePath>,
) {
let Some(state) = state else {
return;
};
// Detect the Playing → Completed transition: was playing last frame,
// is now completed. Direct Playing → Inactive (Stop button) does not
// satisfy this guard because it never enters `Completed`.
let now_playing = state.is_playing();
let now_completed = state.is_completed();
let just_completed = *last_was_playing && now_completed;
*last_was_playing = now_playing;
if !just_completed {
return;
}
let Some(record) = achievements.0.iter_mut().find(|r| r.id == "cinephile") else {
return;
};
if record.unlocked {
return;
}
record.unlock(Utc::now());
record.reward_granted = true;
unlocks.write(AchievementUnlockedEvent(record.clone()));
if let Some(target) = &path.0
&& let Err(e) = save_achievements_to(target, &achievements.0)
{
warn!("failed to save achievements after cinephile unlock: {e}");
}
}
/// Achievement-onboarding cue. /// Achievement-onboarding cue.
/// ///
/// On the player's very first win — and only their first — fires a single /// On the player's very first win — and only their first — fires a single
@@ -1149,9 +1215,215 @@ mod tests {
); );
} }
/// Without any `GameWonEvent` arriving the system must be a no-op: // -----------------------------------------------------------------------
/// no toast, no flag flip — even on update ticks where stats happen // Cinephile (event-driven via ReplayPlaybackState)
/// to read `games_won == 1`. // -----------------------------------------------------------------------
use crate::replay_playback::ReplayPlaybackState;
use solitaire_data::{Replay, ReplayMove};
use chrono::NaiveDate;
use solitaire_core::game_state::{DrawMode, GameMode};
/// Headless app variant that injects a default `ReplayPlaybackState`
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
/// by hand. The achievement plugin's cinephile observer reads it via
/// `Option<Res<_>>` so the absence of the playback plugin is safe.
fn cinephile_app() -> App {
let mut app = headless_app();
app.init_resource::<ReplayPlaybackState>();
app
}
fn dummy_replay() -> Replay {
Replay::new(
1,
DrawMode::DrawOne,
GameMode::Classic,
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick],
)
}
fn cinephile_unlocked(app: &App) -> bool {
app.world()
.resource::<AchievementsResource>()
.0
.iter()
.find(|r| r.id == "cinephile")
.map(|r| r.unlocked)
.unwrap_or(false)
}
fn cinephile_unlocks_emitted(app: &App) -> usize {
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
let mut cursor = events.get_cursor();
cursor
.read(events)
.filter(|e| e.0.id == "cinephile")
.count()
}
/// The cinephile record must be seeded on plugin init like every other
/// achievement, so the observer can find and mutate it later.
#[test]
fn cinephile_record_seeded_by_plugin() {
let app = cinephile_app();
let records = &app.world().resource::<AchievementsResource>().0;
assert!(
records.iter().any(|r| r.id == "cinephile" && !r.unlocked),
"cinephile record must be seeded as locked",
);
}
/// Drive Inactive → Playing → Completed and assert the cinephile
/// achievement unlocks and exactly one `AchievementUnlockedEvent` is
/// emitted.
#[test]
fn cinephile_unlocks_on_replay_completion() {
let mut app = cinephile_app();
// Frame 1: enter Playing. The observer's first sample sees
// `last_was_playing = false` and `now_playing = true`.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
};
app.update();
assert!(
!cinephile_unlocked(&app),
"Playing alone must not unlock cinephile",
);
// Frame 2: transition to Completed. The observer must detect
// `last_was_playing = true && now_completed = true` and unlock.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
app.update();
assert!(
cinephile_unlocked(&app),
"cinephile must unlock on Playing → Completed transition",
);
assert_eq!(
cinephile_unlocks_emitted(&app),
1,
"exactly one AchievementUnlockedEvent must fire for cinephile",
);
}
/// Stop button transitions Playing → Inactive directly (not via
/// Completed). Drive that path and assert no cinephile unlock.
#[test]
fn cinephile_does_not_unlock_on_stop_button_abort() {
let mut app = cinephile_app();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
};
app.update();
// Direct Playing → Inactive — the path the Stop button takes via
// `stop_replay_playback`. Must not unlock cinephile.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Inactive;
app.update();
assert!(
!cinephile_unlocked(&app),
"Stop button (Playing → Inactive) must not unlock cinephile",
);
assert_eq!(
cinephile_unlocks_emitted(&app),
0,
"no AchievementUnlockedEvent for cinephile on a Stop transition",
);
}
/// A second Playing → Completed cycle on an already-unlocked record
/// must be idempotent: no additional `AchievementUnlockedEvent`.
#[test]
fn cinephile_does_not_double_fire() {
let mut app = cinephile_app();
// First completion cycle to unlock.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
};
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
app.update();
assert!(cinephile_unlocked(&app), "precondition: first cycle must unlock");
// Drain the event queue so the next assertion doesn't double-count
// the legitimate first-time unlock event.
app.world_mut()
.resource_mut::<Messages<AchievementUnlockedEvent>>()
.clear();
// Second cycle: Inactive → Playing → Completed once more.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Inactive;
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
};
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
app.update();
assert_eq!(
cinephile_unlocks_emitted(&app),
0,
"cinephile must not re-fire on a second Playing → Completed cycle",
);
}
/// `Completed` lingers across multiple frames before the auto-clear
/// transitions back to `Inactive`. The observer must fire exactly
/// once during that linger window — not once per frame.
#[test]
fn cinephile_fires_once_across_completed_linger() {
let mut app = cinephile_app();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
};
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
app.update();
// Stay in Completed for a few more frames as the real auto-clear
// does. Each subsequent frame the resource is still `Completed`
// but the observer has already counted this transition.
app.update();
app.update();
app.update();
assert_eq!(
cinephile_unlocks_emitted(&app),
1,
"cinephile must fire exactly once across the Completed linger window",
);
}
#[test] #[test]
fn no_win_event_means_no_achievement_onboarding_toast() { fn no_win_event_means_no_achievement_onboarding_toast() {
let mut app = onboarding_test_app(); let mut app = onboarding_test_app();
+318 -20
View File
@@ -11,10 +11,16 @@ use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*; use bevy::prelude::*;
use chrono::Utc; use chrono::Utc;
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use solitaire_data::{delete_game_state_at, game_state_file_path, latest_replay_path, use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
load_game_state_from, save_game_state_to, save_latest_replay_to, Replay, ReplayMove}; use solitaire_data::{
append_replay_to_history, delete_game_state_at, game_state_file_path, load_game_state_from,
migrate_legacy_latest_replay, replay_history_path, save_game_state_to, Replay, ReplayMove,
SOLVER_DEAL_RETRY_CAP,
};
#[allow(deprecated)]
use solitaire_data::latest_replay_path;
use crate::events::{ use crate::events::{
CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent, CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent,
@@ -54,7 +60,15 @@ pub struct GameMutation;
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct GameStatePath(pub Option<PathBuf>); pub struct GameStatePath(pub Option<PathBuf>);
/// Persistence path for the most recent winning replay. `None` disables I/O. /// Persistence path for the rolling [`solitaire_data::ReplayHistory`]
/// file (`replays.json`). `None` disables I/O — used by tests and on
/// minimal Linux containers without `dirs::data_dir()`.
///
/// Each `GameWonEvent` appends the freshly-frozen [`Replay`] to the
/// history at this path via
/// [`solitaire_data::append_replay_to_history`], capping at
/// [`solitaire_data::REPLAY_HISTORY_CAP`] so the file never grows
/// unbounded.
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct ReplayPath(pub Option<PathBuf>); pub struct ReplayPath(pub Option<PathBuf>);
@@ -101,9 +115,27 @@ impl Plugin for GamePlugin {
.and_then(load_game_state_from) .and_then(load_game_state_from)
.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawMode::DrawOne)); .unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawMode::DrawOne));
// One-shot migration from the legacy single-slot
// `latest_replay.json` to the rolling history at `replays.json`.
// Runs at plugin construction so the player's last winning
// replay from a pre-history build is the first entry of the
// new history file. The legacy file is intentionally left in
// place for one release as a safety net (see
// `migrate_legacy_latest_replay` doc comment).
let history_path = replay_history_path();
if let (Some(legacy), Some(history)) =
(
#[allow(deprecated)]
latest_replay_path(),
history_path.as_ref(),
)
{
migrate_legacy_latest_replay(&legacy, history);
}
app.insert_resource(GameStateResource(initial_state)) app.insert_resource(GameStateResource(initial_state))
.insert_resource(GameStatePath(path)) .insert_resource(GameStatePath(path))
.insert_resource(ReplayPath(latest_replay_path())) .insert_resource(ReplayPath(history_path))
.init_resource::<RecordingReplay>() .init_resource::<RecordingReplay>()
.init_resource::<DragState>() .init_resource::<DragState>()
.init_resource::<SyncStatusResource>() .init_resource::<SyncStatusResource>()
@@ -188,6 +220,41 @@ fn seed_from_system_time() -> u64 {
.map_or(0, |d| d.as_nanos() as u64) .map_or(0, |d| d.as_nanos() as u64)
} }
/// Walks forward from `initial_seed` (incrementing by 1 with wrapping
/// arithmetic) until the [`solitaire_core::solver`] returns a verdict
/// the engine accepts as winnable, or until [`SOLVER_DEAL_RETRY_CAP`]
/// attempts have elapsed.
///
/// The solver classifies each deal as one of three verdicts:
/// - [`SolverResult::Winnable`] — provably solvable; accept.
/// - [`SolverResult::Inconclusive`] — budget exceeded, no proof
/// either way; accept (we treat "we don't know" as winnable so
/// the toggle never silently drops a player into the retry cap).
/// - [`SolverResult::Unwinnable`] — provably dead; try the next seed.
///
/// If every seed in the retry window is `Unwinnable` (extremely
/// unlikely on real inputs), the function returns the *last* tried
/// seed so the player still gets a deal — better a possibly-unwinnable
/// hand than an infinite loop.
///
/// Pure helper extracted for testability — `new_game_with_solver_*`
/// engine tests in the same file exercise this path.
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: &DrawMode) -> u64 {
let cfg = SolverConfig::default();
let mut seed = initial_seed;
for _ in 0..SOLVER_DEAL_RETRY_CAP {
match try_solve(seed, draw_mode.clone(), &cfg) {
SolverResult::Winnable | SolverResult::Inconclusive => return seed,
SolverResult::Unwinnable => {
seed = seed.wrapping_add(1);
}
}
}
// Retry cap exhausted — accept the latest tried seed rather than
// recurring forever.
seed
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn handle_new_game( fn handle_new_game(
mut commands: Commands, mut commands: Commands,
@@ -229,7 +296,7 @@ fn handle_new_game(
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
let seed = ev.seed.unwrap_or_else(seed_from_system_time); let initial_seed = ev.seed.unwrap_or_else(seed_from_system_time);
// Prefer the draw mode from Settings when starting a fresh game. // Prefer the draw mode from Settings when starting a fresh game.
// Fall back to the current game's draw mode in headless/test contexts // Fall back to the current game's draw mode in headless/test contexts
// where SettingsPlugin is not installed. // where SettingsPlugin is not installed.
@@ -237,7 +304,32 @@ fn handle_new_game(
.as_ref() .as_ref()
.map_or_else(|| game.0.draw_mode.clone(), |s| s.0.draw_mode.clone()); .map_or_else(|| game.0.draw_mode.clone(), |s| s.0.draw_mode.clone());
let mode = ev.mode.unwrap_or(game.0.mode); let mode = ev.mode.unwrap_or(game.0.mode);
game.0 = GameState::new_with_mode(seed, draw_mode, mode);
// Solver-backed retry: when the player has opted in to
// "Winnable deals only" AND this is a random Classic deal
// (no caller-supplied seed), reject deals the solver can
// prove unwinnable and try the next seed. Capped at
// [`SOLVER_DEAL_RETRY_CAP`] so a pathological run can't
// hang the main thread — if every attempt is rejected we
// fall through to the latest tried seed.
//
// **Scope** — the retry deliberately skips:
// - Daily challenges and challenge-mode seeds (caller passes
// `ev.seed = Some(...)` so the player gets the same deal as
// everyone else).
// - Replays (the replay's own seed is authoritative).
// - Any other explicit seed request — the player asked for
// that seed; honour it.
let winnable_only = settings
.as_ref()
.is_some_and(|s| s.0.winnable_deals_only);
let chosen_seed = if winnable_only && mode == GameMode::Classic && ev.seed.is_none() {
choose_winnable_seed(initial_seed, &draw_mode)
} else {
initial_seed
};
game.0 = GameState::new_with_mode(chosen_seed, draw_mode, mode);
// Reset the in-flight replay buffer — a fresh deal starts with // Reset the in-flight replay buffer — a fresh deal starts with
// an empty move list. The previously saved replay on disk // an empty move list. The previously saved replay on disk
// (latest_replay.json) is preserved until the player wins again. // (latest_replay.json) is preserved until the player wins again.
@@ -557,14 +649,15 @@ fn handle_undo(
/// On every `GameWonEvent`, freeze the in-flight [`RecordingReplay`] into /// On every `GameWonEvent`, freeze the in-flight [`RecordingReplay`] into
/// a [`Replay`] tagged with the deal seed/mode, the win's score and /// a [`Replay`] tagged with the deal seed/mode, the win's score and
/// elapsed time, and today's date — then persist it atomically to /// elapsed time, and today's date — then append it to the rolling
/// `<data_dir>/solitaire_quest/latest_replay.json` (or to whichever path /// [`solitaire_data::ReplayHistory`] at the path `ReplayPath` carries
/// `ReplayPath` carries; tests inject a temp path). /// (tests inject a temp path).
/// ///
/// Only the most recent winning replay is retained — the existing file is /// The history is capped at [`solitaire_data::REPLAY_HISTORY_CAP`]
/// overwritten. The recording buffer is left intact after the win so a /// entries; older wins age out automatically when the cap is hit. The
/// subsequent state-change does not erase the move list before the save /// recording buffer is left intact after the win so a subsequent
/// completes; it gets cleared on the next `NewGameRequestEvent`. /// state-change does not erase the move list before the save completes;
/// it gets cleared on the next `NewGameRequestEvent`.
pub fn record_replay_on_win( pub fn record_replay_on_win(
mut wins: MessageReader<GameWonEvent>, mut wins: MessageReader<GameWonEvent>,
game: Res<GameStateResource>, game: Res<GameStateResource>,
@@ -597,8 +690,8 @@ pub fn record_replay_on_win(
// to inspect it without going through the disk. // to inspect it without going through the disk.
continue; continue;
}; };
if let Err(e) = save_latest_replay_to(p, &replay) { if let Err(e) = append_replay_to_history(p, replay) {
warn!("replay: failed to save winning replay: {e}"); warn!("replay: failed to append winning replay to history: {e}");
} }
} }
} }
@@ -1946,11 +2039,13 @@ mod tests {
} }
/// On `GameWonEvent`, the recording is frozen into a `Replay` and /// On `GameWonEvent`, the recording is frozen into a `Replay` and
/// persisted. We point `ReplayPath` at a temp file, fake a win, and /// appended to the rolling [`solitaire_data::ReplayHistory`]. We
/// load the file back to assert the metadata + move list match. /// point `ReplayPath` at a temp file, fake a win, and load the
/// history back to assert the just-saved entry sits at the front
/// with the metadata + move list intact.
#[test] #[test]
fn replay_recording_freezes_into_replay_on_game_won() { fn replay_recording_freezes_into_replay_on_game_won() {
use solitaire_data::load_latest_replay_from; use solitaire_data::load_replay_history_from;
let path = std::env::temp_dir().join("engine_test_replay_freeze.json"); let path = std::env::temp_dir().join("engine_test_replay_freeze.json");
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
@@ -1978,8 +2073,14 @@ mod tests {
}); });
app.update(); app.update();
let loaded = load_latest_replay_from(&path) let history = load_replay_history_from(&path)
.expect("a winning replay must be persisted to ReplayPath"); .expect("a winning replay must be persisted to ReplayPath");
assert_eq!(
history.replays.len(),
1,
"fresh history must contain exactly the just-recorded win",
);
let loaded = &history.replays[0];
assert_eq!(loaded.seed, 7654, "seed must match the live game state"); assert_eq!(loaded.seed, 7654, "seed must match the live game state");
assert_eq!(loaded.draw_mode, DrawMode::DrawOne, "draw_mode must be captured"); assert_eq!(loaded.draw_mode, DrawMode::DrawOne, "draw_mode must be captured");
assert_eq!(loaded.final_score, 4321, "final_score must come from the win event"); assert_eq!(loaded.final_score, 4321, "final_score must come from the win event");
@@ -1998,6 +2099,53 @@ mod tests {
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
} }
/// Successive `GameWonEvent`s must accumulate in the rolling
/// history rather than overwriting one another. Pre-cap, every win
/// joins the front of `history.replays`.
#[test]
fn replay_recording_appends_to_history_across_wins() {
use solitaire_data::load_replay_history_from;
let path = std::env::temp_dir().join("engine_test_replay_history_append.json");
let _ = std::fs::remove_file(&path);
let mut app = test_app(11);
app.insert_resource(ReplayPath(Some(path.clone())));
// First win.
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.clear();
recording.moves.push(ReplayMove::StockClick);
}
app.world_mut().write_message(GameWonEvent {
score: 100,
time_seconds: 60,
});
app.update();
// Second win — different score so we can distinguish.
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.clear();
recording.moves.push(ReplayMove::StockClick);
recording.moves.push(ReplayMove::StockClick);
}
app.world_mut().write_message(GameWonEvent {
score: 200,
time_seconds: 120,
});
app.update();
let history = load_replay_history_from(&path).expect("history must exist");
assert_eq!(history.replays.len(), 2, "both wins must be retained");
// Newest first — second win lands at index 0.
assert_eq!(history.replays[0].final_score, 200);
assert_eq!(history.replays[1].final_score, 100);
let _ = std::fs::remove_file(&path);
}
/// `GameWonEvent` with an empty recording must NOT touch disk. /// `GameWonEvent` with an empty recording must NOT touch disk.
/// Without this guard, parallel-plugin tests that synthesise /// Without this guard, parallel-plugin tests that synthesise
/// win events for XP / streak / weekly-goal logic (without /// win events for XP / streak / weekly-goal logic (without
@@ -2022,4 +2170,154 @@ mod tests {
"no replay must be written when recording is empty", "no replay must be written when recording is empty",
); );
} }
// -----------------------------------------------------------------------
// Solver-backed "Winnable deals only" toggle
//
// Exercises [`choose_winnable_seed`] and the wiring inside
// `handle_new_game` that consults [`Settings::winnable_deals_only`].
// -----------------------------------------------------------------------
/// Inject a `SettingsResource` with the given `winnable_deals_only`
/// flag. The handle_new_game system already reads this resource via
/// `Option<Res<...>>`, so no `SettingsPlugin` boot is needed.
fn insert_settings(app: &mut App, winnable_deals_only: bool) {
let settings = solitaire_data::Settings {
winnable_deals_only,
..solitaire_data::Settings::default()
};
app.insert_resource(crate::settings_plugin::SettingsResource(settings));
}
#[test]
fn new_game_with_solver_toggle_off_uses_requested_seed() {
// Toggle off — the engine must use the seed it was handed and
// never invoke the solver. Seed 999 is just an arbitrary
// deterministic seed; the test asserts the resulting deal
// matches `GameState::new(999, DrawOne)`.
let mut app = test_app(1);
insert_settings(&mut app, false);
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(999),
mode: None,
confirmed: false,
});
app.update();
let actual_seed = app.world().resource::<GameStateResource>().0.seed;
assert_eq!(
actual_seed, 999,
"with solver toggle off, the requested seed must be honoured exactly"
);
// Cross-check: the dealt tableau must match GameState::new(999) byte-for-byte.
let expected = GameState::new(999, DrawMode::DrawOne);
for i in 0..7 {
assert_eq!(
app.world().resource::<GameStateResource>().0.piles[&PileType::Tableau(i)].cards,
expected.piles[&PileType::Tableau(i)].cards,
"tableau column {i} must match the unfiltered seed",
);
}
}
#[test]
fn new_game_with_solver_toggle_off_random_seed_path() {
// When seed is None and toggle is off, the engine uses a
// system-time seed and skips the solver. We can't pin the
// exact seed, but we can assert the seed is *not* the
// sentinel zero (which would only happen if SystemTime is
// before the epoch — practically impossible), AND that no
// resource has been mutated to suggest the solver ran.
// The strongest assertion is "the move runs to completion
// without panicking", which the .update() call covers.
let mut app = test_app(1);
insert_settings(&mut app, false);
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: false,
});
app.update();
// Game state was reseeded — move_count is 0 on the new game.
assert_eq!(app.world().resource::<GameStateResource>().0.move_count, 0);
}
#[test]
fn new_game_with_solver_toggle_on_skips_solver_for_specific_seed() {
// Even with the toggle on, an *explicit* seed must be honoured:
// daily challenges, replay seeding, and challenge-mode all
// pass `Some(seed)` and must never be retried.
let mut app = test_app(1);
insert_settings(&mut app, true);
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(123),
mode: None,
confirmed: false,
});
app.update();
assert_eq!(
app.world().resource::<GameStateResource>().0.seed,
123,
"explicit-seed requests must skip the solver retry loop",
);
}
#[test]
fn choose_winnable_seed_skips_unwinnable_seed() {
// Seed 394 was identified by the offline scan
// (`solver::tests::find_unwinnable`) as the only Unwinnable
// seed in 0..500 under the default solver budget. Seed 395
// resolves as Inconclusive — the engine treats Inconclusive
// as winnable (see `choose_winnable_seed` doc), so the
// helper must return 395 when started at 394.
let chosen = choose_winnable_seed(394, &DrawMode::DrawOne);
assert_eq!(
chosen, 395,
"seed 394 is Unwinnable; the next seed (395, Inconclusive) must be accepted"
);
}
#[test]
fn new_game_with_solver_toggle_on_retries_until_winnable() {
// End-to-end: with the toggle on, fire a NewGameRequestEvent
// with seed=None and *manually pre-seed* the system-time
// path by clearing the GameStateResource so handle_new_game
// takes the random branch. We can't easily inject the
// system-time seed here, so we exercise the helper via a
// separate call and assert the *resource* receives the
// post-retry seed when the helper would have rejected.
//
// We test the integration by setting up an alternative
// scenario: pass `seed: Some(394)` with toggle on. Our
// implementation already documents that explicit seeds skip
// the retry, so this *won't* trigger retry. The cleaner
// integration is captured in `choose_winnable_seed_skips_*`.
// Here we verify the default-seed path doesn't crash when
// toggle is on — exercising the live solver call inside
// handle_new_game without depending on the solver picking
// a specific seed.
let mut app = test_app(1);
insert_settings(&mut app, true);
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: false,
});
app.update();
// The chosen seed is non-deterministic (system time),
// but the new game must have been started cleanly:
// move_count back to 0, undo stack empty.
assert_eq!(app.world().resource::<GameStateResource>().0.move_count, 0);
assert_eq!(
app.world().resource::<GameStateResource>().0.undo_stack_len(),
0
);
}
} }
+12 -1
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,
@@ -123,7 +133,8 @@ pub use selection_plugin::{
}; };
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot}; pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
pub use stats_plugin::{ pub use stats_plugin::{
format_replay_caption, LatestReplayPath, LatestReplayResource, StatsPlugin, StatsResource, format_replay_caption, LatestReplayPath, ReplayHistoryResource, ReplayNextButton,
ReplayPrevButton, ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource,
StatsScreen, StatsUpdate, WatchReplayButton, StatsScreen, StatsUpdate, WatchReplayButton,
}; };
pub use sync_plugin::{SyncPlugin, SyncProviderResource}; pub use sync_plugin::{SyncPlugin, SyncProviderResource};
+565
View File
@@ -0,0 +1,565 @@
//! On-screen overlay shown while a recorded [`Replay`] plays back.
//!
//! The overlay is a thin top-of-window banner with three pieces of UI:
//!
//! - A "Replay" label on the left so the player knows the surface is
//! under playback control rather than live input.
//! - A "Move N of M" progress indicator in the centre, recomputed every
//! frame the cursor advances.
//! - A "Stop" button on the right that aborts playback and returns
//! control to the player.
//!
//! When playback finishes ([`ReplayPlaybackState::Completed`]) the banner
//! label swaps to "Replay complete" and stays visible until the playback
//! core auto-clears the resource back to [`ReplayPlaybackState::Inactive`]
//! a few seconds later, at which point the overlay despawns.
//!
//! The overlay sits at z-layer [`Z_REPLAY_OVERLAY`] — above gameplay but
//! below every modal layer ([`Z_MODAL_SCRIM`] and up). That ordering lets
//! the player still open Settings, Pause, and Help during a replay; those
//! modals will render on top of the banner as expected.
//!
//! [`Replay`]: solitaire_data::Replay
//! [`Z_MODAL_SCRIM`]: crate::ui_theme::Z_MODAL_SCRIM
use bevy::prelude::*;
use crate::font_plugin::FontResource;
use crate::replay_playback::{stop_replay_playback, ReplayPlaybackState};
use crate::ui_modal::{spawn_modal_button, ButtonVariant};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, TEXT_PRIMARY, TYPE_BODY, TYPE_HEADLINE, VAL_SPACE_2,
VAL_SPACE_4, Z_DROP_OVERLAY,
};
// ---------------------------------------------------------------------------
// Z-index — see `ui_theme::Z_MODAL_SCRIM` (200) for the next layer above.
// ---------------------------------------------------------------------------
/// `bevy::ui` `ZIndex` value for the replay overlay banner.
///
/// Numeric value is `Z_DROP_OVERLAY as i32 + 5 = 55`; chosen so the banner
/// sits clearly above the HUD top layer (`Z_HUD_TOP = 60` is intentionally
/// **below** modals, but the overlay needs to be above HUD readouts) yet
/// well below `Z_MODAL_SCRIM = 200` so Settings, Pause, and Help modals
/// continue to render on top of the overlay during a replay.
///
/// The `Z_DROP_OVERLAY + 5` formula in the spec is reproduced here as an
/// integer because `Z_DROP_OVERLAY` itself is a `f32` Sprite-space z used
/// for the drop-target overlay sprites — UI nodes use `i32` `ZIndex`, so
/// we materialise a separate constant rather than reuse the `f32` value.
pub const Z_REPLAY_OVERLAY: i32 = Z_DROP_OVERLAY as i32 + 5;
/// Total height of the banner in pixels. Thin enough to leave the
/// gameplay surface visible underneath, tall enough to comfortably fit
/// the headline-sized "Replay" label.
const BANNER_HEIGHT: f32 = 48.0;
/// Background colour alpha for the banner. `BG_ELEVATED_HI` at this alpha
/// reads as a clear "this is a UI strip" callout while still letting the
/// felt show through enough to anchor the banner to the play surface.
const BANNER_ALPHA: f32 = 0.92;
// ---------------------------------------------------------------------------
// Marker components
// ---------------------------------------------------------------------------
/// Marker on the banner's root `Node`. Used by the spawn / despawn /
/// progress-update systems to find the overlay.
#[derive(Component, Debug)]
pub struct ReplayOverlayRoot;
/// Marker on the left-hand banner label `Text`. Carries either "Replay"
/// (during playback) or "Replay complete" (once finished); the
/// completion-text-update system swaps the contents in place.
#[derive(Component, Debug)]
pub struct ReplayOverlayBannerText;
/// Marker on the centre progress `Text`. Updated every frame to reflect
/// the current `(cursor, total)` returned by
/// [`ReplayPlaybackState::progress`].
#[derive(Component, Debug)]
pub struct ReplayOverlayProgressText;
/// Marker on the right-hand "Stop" button. Click handler queries for this
/// and calls [`stop_replay_playback`] when an `Interaction::Pressed`
/// transition is seen.
#[derive(Component, Debug)]
pub struct ReplayStopButton;
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Bevy plugin that registers every system needed to drive the replay
/// overlay's lifecycle.
///
/// The plugin is independent of [`crate::replay_playback::ReplayPlaybackPlugin`]
/// — it only reads the shared `ReplayPlaybackState` resource. Tests insert
/// the resource manually and exercise the overlay in isolation.
pub struct ReplayOverlayPlugin;
impl Plugin for ReplayOverlayPlugin {
fn build(&self, app: &mut App) {
// The systems are ordered so that, on a single frame:
// 1. The state-watcher spawns or despawns the overlay if the
// `ReplayPlaybackState` resource changed.
// 2. The completion-text update swaps the banner label when the
// state is `Completed`.
// 3. The progress-text update writes the latest "Move N of M".
// 4. The Stop-button click handler reads `Interaction::Pressed`
// and calls `stop_replay_playback` (which mutates the state).
// Putting Stop last means a click in frame N is observed by
// `react_to_state_change` in frame N+1, which then despawns the
// overlay in response — a clean state-driven loop.
app.add_systems(
Update,
(
react_to_state_change,
update_banner_label,
update_progress_text,
handle_stop_button,
)
.chain(),
);
}
}
// ---------------------------------------------------------------------------
// Spawning
// ---------------------------------------------------------------------------
/// Reads [`ReplayPlaybackState`] every time the resource changes and either
/// spawns or despawns the overlay accordingly. Treats the resource as the
/// single source of truth — the spawn / despawn decision is derived from
/// `is_playing() || is_completed()` rather than tracking previous-state
/// transitions explicitly, which keeps the system stateless.
fn react_to_state_change(
mut commands: Commands,
state: Res<ReplayPlaybackState>,
existing: Query<Entity, With<ReplayOverlayRoot>>,
font_res: Option<Res<FontResource>>,
) {
if !state.is_changed() {
return;
}
let should_be_visible = state.is_playing() || state.is_completed();
let already_spawned = existing.iter().next().is_some();
if should_be_visible && !already_spawned {
spawn_overlay(&mut commands, font_res.as_deref(), &state);
} else if !should_be_visible && already_spawned {
for entity in &existing {
commands.entity(entity).despawn();
}
}
// The `should_be_visible && already_spawned` branch is a no-op here —
// the per-frame text update systems below repaint the banner label
// and progress readout in place without a respawn.
}
/// Spawns the banner — a flex-row Node anchored to the top edge of the
/// window with three children: the "Replay" / "Replay complete" label,
/// the centred progress text, and the right-aligned Stop button.
fn spawn_overlay(
commands: &mut Commands,
font_res: Option<&FontResource>,
state: &ReplayPlaybackState,
) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let banner_label = if state.is_completed() {
"Replay complete"
} else {
"Replay"
};
let progress_label = format_progress(state);
let banner_bg = Color::srgba(
BG_ELEVATED_HI.to_srgba().red,
BG_ELEVATED_HI.to_srgba().green,
BG_ELEVATED_HI.to_srgba().blue,
BANNER_ALPHA,
);
commands
.spawn((
ReplayOverlayRoot,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Px(BANNER_HEIGHT),
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
justify_content: JustifyContent::SpaceBetween,
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
column_gap: VAL_SPACE_4,
..default()
},
BackgroundColor(banner_bg),
// Pin the banner to its z layer in both the local and the
// global stacking context — `GlobalZIndex` matters because
// the overlay is a top-level Node (no parent), and Bevy 0.18
// has historically had subtle stacking-context drift here.
ZIndex(Z_REPLAY_OVERLAY),
GlobalZIndex(Z_REPLAY_OVERLAY),
))
.with_children(|banner| {
// Left: "Replay" label in the loud yellow accent so it reads
// unmistakably as a non-gameplay surface.
banner.spawn((
ReplayOverlayBannerText,
Text::new(banner_label),
TextFont {
font: font_handle.clone(),
font_size: TYPE_HEADLINE,
..default()
},
TextColor(ACCENT_PRIMARY),
));
// Centre: progress readout — neutral primary text colour so
// the eye treats it as data, not a callout.
banner.spawn((
ReplayOverlayProgressText,
Text::new(progress_label),
TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_PRIMARY),
));
// Right: Stop button. Tertiary variant — the action is
// available but not the loudest element in the banner; the
// "Replay" yellow accent owns that slot. `spawn_modal_button`
// gives us hover / press paint and focus rings for free via
// the existing `UiModalPlugin` paint system.
banner
.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_2,
..default()
})
.with_children(|wrap| {
spawn_modal_button(
wrap,
ReplayStopButton,
"Stop",
None,
ButtonVariant::Tertiary,
font_res,
);
});
});
}
// ---------------------------------------------------------------------------
// Per-frame text updates
// ---------------------------------------------------------------------------
/// Overwrites the banner label whenever the resource changes — covers the
/// `Playing → Completed` transition by swapping "Replay" for
/// "Replay complete" in place without despawning the overlay.
fn update_banner_label(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayBannerText>>,
) {
if !state.is_changed() {
return;
}
let label = if state.is_completed() {
"Replay complete"
} else if state.is_playing() {
"Replay"
} else {
return;
};
for mut text in &mut q {
**text = label.to_string();
}
}
/// Repaints the "Move N of M" centre readout every frame the cursor moves.
/// Cheap — early-exits if the resource has not changed since the last
/// frame so idle replays don't churn the text mesh.
fn update_progress_text(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayProgressText>>,
) {
if !state.is_changed() {
return;
}
let label = format_progress(&state);
for mut text in &mut q {
**text = label.clone();
}
}
/// Pure helper — formats the centre progress readout for the given state.
/// Exposed at module scope so the spawn path and the per-frame update
/// path produce the exact same string.
fn format_progress(state: &ReplayPlaybackState) -> String {
match state.progress() {
Some((cursor, total)) => format!("Move {cursor} of {total}"),
None if state.is_completed() => "Replay complete".to_string(),
None => String::new(),
}
}
// ---------------------------------------------------------------------------
// Stop button handler
// ---------------------------------------------------------------------------
/// Watches the Stop button for `Interaction::Pressed` transitions. On a
/// click, calls [`stop_replay_playback`] which resets the state to
/// `Inactive`; the next frame's `react_to_state_change` then despawns
/// the overlay.
fn handle_stop_button(
mut commands: Commands,
mut state: ResMut<ReplayPlaybackState>,
buttons: Query<&Interaction, (With<ReplayStopButton>, Changed<Interaction>)>,
) {
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
stop_replay_playback(&mut commands, &mut state);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_data::{Replay, ReplayMove};
/// Build a minimal but well-formed [`Replay`] with `move_count` no-op
/// `StockClick` entries. Tests only ever read `replay.moves.len()`
/// (denominator of the progress indicator), so the move kind is
/// irrelevant beyond producing the right count.
fn synthetic_replay(move_count: usize) -> Replay {
Replay::new(
42,
DrawMode::DrawOne,
GameMode::Classic,
120,
1_000,
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
(0..move_count).map(|_| ReplayMove::StockClick).collect(),
)
}
/// Build a test app that has the overlay plugin but **not** the
/// playback plugin — tests insert `ReplayPlaybackState` manually so
/// they can drive every state transition deterministically.
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(ReplayOverlayPlugin);
app.init_resource::<ReplayPlaybackState>();
app
}
/// Count `ReplayOverlayRoot` entities in the world — the overlay's
/// presence/absence is the spawn-test's primary observable.
fn overlay_root_count(app: &mut App) -> usize {
app.world_mut()
.query::<&ReplayOverlayRoot>()
.iter(app.world())
.count()
}
/// Read the current text content of the unique progress-text entity.
fn progress_text(app: &mut App) -> String {
let mut q = app
.world_mut()
.query_filtered::<&Text, With<ReplayOverlayProgressText>>();
q.iter(app.world())
.next()
.map(|t| t.0.clone())
.unwrap_or_default()
}
/// Read the current text content of the unique banner-label entity.
fn banner_text(app: &mut App) -> String {
let mut q = app
.world_mut()
.query_filtered::<&Text, With<ReplayOverlayBannerText>>();
q.iter(app.world())
.next()
.map(|t| t.0.clone())
.unwrap_or_default()
}
/// Set the playback resource without going through the playback core.
fn set_state(app: &mut App, state: ReplayPlaybackState) {
app.world_mut().insert_resource(state);
}
/// Find the unique `ReplayStopButton` entity for the click-handler
/// test. There must be exactly one.
fn stop_button_entity(app: &mut App) -> Entity {
let mut q = app
.world_mut()
.query_filtered::<Entity, With<ReplayStopButton>>();
q.iter(app.world())
.next()
.expect("Stop button must exist while overlay is spawned")
}
/// Going `Inactive → Playing` spawns exactly one overlay root and
/// the banner label reads "Replay".
#[test]
fn overlay_spawns_when_playback_starts() {
let mut app = headless_app();
// First update with the default `Inactive` resource — overlay
// must not exist yet.
app.update();
assert_eq!(overlay_root_count(&mut app), 0);
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
},
);
app.update();
assert_eq!(
overlay_root_count(&mut app),
1,
"exactly one ReplayOverlayRoot must spawn on Inactive → Playing",
);
assert_eq!(banner_text(&mut app), "Replay");
}
/// The progress-text entity reads `"Move {cursor} of {total}"` for a
/// well-formed `Playing` state.
#[test]
fn overlay_progress_text_reflects_cursor() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
},
);
app.update();
assert_eq!(progress_text(&mut app), "Move 5 of 10");
}
/// Pressing the Stop button resets the state back to `Inactive` and
/// the next frame's `react_to_state_change` despawns the overlay.
/// Mirrors the synthetic `Interaction::Pressed` insertion pattern
/// used elsewhere in the engine for headless click tests.
#[test]
fn overlay_stop_button_click_clears_playback() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
},
);
app.update();
assert_eq!(overlay_root_count(&mut app), 1);
let stop = stop_button_entity(&mut app);
app.world_mut()
.entity_mut(stop)
.insert(Interaction::Pressed);
// Tick once: the click handler runs late in the frame and resets
// the state to `Inactive`.
app.update();
// State must be back to Inactive.
let state = app.world().resource::<ReplayPlaybackState>();
assert!(
matches!(state, ReplayPlaybackState::Inactive),
"Stop click must reset ReplayPlaybackState to Inactive; got {state:?}",
);
// One more tick — `react_to_state_change` sees the resource
// change to Inactive and despawns the overlay.
app.update();
assert_eq!(
overlay_root_count(&mut app),
0,
"overlay must despawn the frame after state returns to Inactive",
);
}
/// Manually flipping the resource back to `Inactive` (e.g. via the
/// playback core's auto-clear after `Completed`) tears the overlay
/// down without any further input.
#[test]
fn overlay_despawns_when_playback_returns_to_inactive() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(3),
cursor: 1,
secs_to_next: 0.5,
},
);
app.update();
assert_eq!(overlay_root_count(&mut app), 1);
set_state(&mut app, ReplayPlaybackState::Inactive);
app.update();
assert_eq!(
overlay_root_count(&mut app),
0,
"overlay must despawn on Playing → Inactive transition",
);
}
/// On `Playing → Completed` the banner label updates in place rather
/// than respawning. The overlay must still be present, and the label
/// must read "Replay complete".
#[test]
fn overlay_text_changes_on_completed() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(7),
cursor: 7,
secs_to_next: 0.0,
},
);
app.update();
assert_eq!(banner_text(&mut app), "Replay");
set_state(&mut app, ReplayPlaybackState::Completed);
app.update();
assert_eq!(
overlay_root_count(&mut app),
1,
"overlay must remain spawned while in Completed state",
);
assert_eq!(
banner_text(&mut app),
"Replay complete",
"banner label must swap on Playing → Completed",
);
}
}
+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",
);
}
}
+51
View File
@@ -132,6 +132,11 @@ struct TooltipDelayText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct TimeBonusMultiplierText; struct TimeBonusMultiplierText;
/// Marks the `Text` node showing the current "Winnable deals only"
/// state ("ON" / "OFF") in the Gameplay section.
#[derive(Component, Debug)]
struct WinnableDealsOnlyText;
/// Marks the scrollable inner card so the mouse-wheel system can target it. /// Marks the scrollable inner card so the mouse-wheel system can target it.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct SettingsPanelScrollable; struct SettingsPanelScrollable;
@@ -176,6 +181,11 @@ enum SettingsButton {
TimeBonusUp, TimeBonusUp,
ToggleTheme, ToggleTheme,
ToggleColorBlind, ToggleColorBlind,
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
/// random Classic-mode deals are filtered through
/// [`solitaire_core::solver::try_solve`] until one is provably
/// winnable (or the retry cap is hit). Off by default.
ToggleWinnableDealsOnly,
SyncNow, SyncNow,
Done, Done,
/// Select a specific card-back by index from the picker row. /// Select a specific card-back by index from the picker row.
@@ -203,6 +213,7 @@ impl SettingsButton {
SettingsButton::MusicUp => 21, SettingsButton::MusicUp => 21,
// Gameplay section // Gameplay section
SettingsButton::ToggleDrawMode => 30, SettingsButton::ToggleDrawMode => 30,
SettingsButton::ToggleWinnableDealsOnly => 35,
SettingsButton::CycleAnimSpeed => 40, SettingsButton::CycleAnimSpeed => 40,
SettingsButton::TooltipDelayDown => 45, SettingsButton::TooltipDelayDown => 45,
SettingsButton::TooltipDelayUp => 46, SettingsButton::TooltipDelayUp => 46,
@@ -299,6 +310,7 @@ impl Plugin for SettingsPlugin {
update_color_blind_text, update_color_blind_text,
update_tooltip_delay_text, update_tooltip_delay_text,
update_time_bonus_multiplier_text, update_time_bonus_multiplier_text,
update_winnable_deals_only_text,
attach_focusable_to_settings_buttons, attach_focusable_to_settings_buttons,
scroll_focus_into_view, scroll_focus_into_view,
), ),
@@ -549,6 +561,21 @@ fn update_color_blind_text(
} }
} }
/// Refreshes the live "Winnable deals only" toggle value in the
/// Gameplay section whenever `SettingsResource` changes (button click,
/// hand-edited `settings.json` reload, etc.).
fn update_winnable_deals_only_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<WinnableDealsOnlyText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = winnable_deals_only_label(settings.0.winnable_deals_only);
}
}
/// Refreshes the live tooltip-delay value in the Gameplay section /// Refreshes the live tooltip-delay value in the Gameplay section
/// whenever `SettingsResource` changes (slider buttons, hand-edited /// whenever `SettingsResource` changes (slider buttons, hand-edited
/// settings.json reload, etc.). /// settings.json reload, etc.).
@@ -758,6 +785,13 @@ fn handle_settings_buttons(
**t = color_blind_label(settings.0.color_blind_mode); **t = color_blind_label(settings.0.color_blind_mode);
} }
} }
SettingsButton::ToggleWinnableDealsOnly => {
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// The Text node is refreshed by `update_winnable_deals_only_text`
// on the next frame via `settings.is_changed()`.
}
SettingsButton::SelectCardBack(idx) => { SettingsButton::SelectCardBack(idx) => {
settings.0.selected_card_back = *idx; settings.0.selected_card_back = *idx;
persist(&path, &settings.0); persist(&path, &settings.0);
@@ -812,6 +846,13 @@ fn color_blind_label(enabled: bool) -> String {
if enabled { "ON".into() } else { "OFF".into() } if enabled { "ON".into() } else { "OFF".into() }
} }
/// Display string for the "Winnable deals only" toggle. Mirrors
/// [`color_blind_label`] — "ON" / "OFF" — so the layout is uniform
/// with the rest of the Gameplay-section toggles.
fn winnable_deals_only_label(enabled: bool) -> String {
if enabled { "ON".into() } else { "OFF".into() }
}
/// Formats the tooltip-hover delay for display in the Settings panel. /// Formats the tooltip-hover delay for display in the Settings panel.
/// `0.0` reads as `"Instant"` so the zero-delay case has a name; any /// `0.0` reads as `"Instant"` so the zero-delay case has a name; any
/// other value prints as `"{n:.1} s"` (e.g. `"0.5 s"`, `"1.2 s"`). /// other value prints as `"{n:.1} s"` (e.g. `"0.5 s"`, `"1.2 s"`).
@@ -1158,6 +1199,16 @@ fn spawn_settings_panel(
"Switch between Draw 1 and Draw 3. Takes effect next deal.", "Switch between Draw 1 and Draw 3. Takes effect next deal.",
font_res, font_res,
); );
toggle_row(
body,
"Winnable deals only",
WinnableDealsOnlyText,
winnable_deals_only_label(settings.winnable_deals_only),
SettingsButton::ToggleWinnableDealsOnly,
"When on, fresh Classic deals are filtered through a solver \
(may take a moment when on).",
font_res,
);
toggle_row( toggle_row(
body, body,
"Anim Speed", "Anim Speed",
+173 -45
View File
@@ -11,8 +11,8 @@ use std::path::PathBuf;
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_data::{ use solitaire_data::{
latest_replay_path, load_latest_replay_from, load_stats_from, save_stats_to, stats_file_path, load_replay_history_from, load_stats_from, replay_history_path, save_stats_to,
PlayerProgress, Replay, StatsExt, StatsSnapshot, WEEKLY_GOALS, stats_file_path, PlayerProgress, Replay, ReplayHistory, StatsExt, StatsSnapshot, WEEKLY_GOALS,
}; };
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
@@ -58,30 +58,57 @@ pub struct StatsScreen;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct StatsCell; pub struct StatsCell;
/// Resource holding the most recently loaded winning [`Replay`], if any. /// Resource holding the rolling [`ReplayHistory`] of recent winning
/// replays.
/// ///
/// Populated from `<data_dir>/solitaire_quest/latest_replay.json` at /// Populated from `<data_dir>/solitaire_quest/replays.json` at startup
/// startup and refreshed in-place whenever the engine writes a new /// and refreshed in-place whenever the engine writes a new winning
/// winning replay (the path the Stats UI calls into is unchanged so a /// replay so the Stats overlay's selector always reflects the current
/// re-open of the modal sees the latest record). /// on-disk history.
/// ///
/// The Stats overlay reads this to decide whether to render the /// `replays[0]` is the most recent win — the Stats overlay's selector
/// "Watch replay" call-to-action or the "No replay recorded yet" /// defaults to that entry and lets the player step backwards through
/// caption. /// up to [`solitaire_data::REPLAY_HISTORY_CAP`] older entries.
#[derive(Resource, Debug, Default, Clone)] #[derive(Resource, Debug, Default, Clone)]
pub struct LatestReplayResource(pub Option<Replay>); pub struct ReplayHistoryResource(pub ReplayHistory);
/// Persistence path for the latest winning replay file. `None` disables /// Currently-selected index into [`ReplayHistoryResource::0`].`replays`.
/// I/O — used by tests and by `StatsPlugin::headless`. ///
/// `0` is the most recent win and is the default on every modal open.
/// The Prev / Next chips wrap-around within the bounds of the current
/// history so the selector is always sat on a valid replay (or on `0`
/// when the history is empty — the chips paint disabled in that case).
#[derive(Resource, Debug, Default, Clone, Copy)]
pub struct SelectedReplayIndex(pub usize);
/// Persistence path for the rolling replay history file
/// (`replays.json`). `None` disables I/O — used by tests and by
/// `StatsPlugin::headless`.
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct LatestReplayPath(pub Option<PathBuf>); pub struct LatestReplayPath(pub Option<PathBuf>);
/// Marker on the "Watch replay" button inside the Stats modal. Clicking /// Marker on the "Watch replay" button inside the Stats modal. Clicking
/// it currently fires an [`InfoToastEvent`] indicating playback ships /// it starts in-engine playback of the selected replay — see
/// in a future build — see [`handle_watch_replay_button`]. /// [`handle_watch_replay_button`].
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct WatchReplayButton; pub struct WatchReplayButton;
/// Marker on the selector's "Previous replay" chip — steps the
/// selection backwards (toward older replays) within
/// [`ReplayHistoryResource`].
#[derive(Component, Debug)]
pub struct ReplayPrevButton;
/// Marker on the selector's "Next replay" chip — steps the selection
/// forwards (toward more recent replays).
#[derive(Component, Debug)]
pub struct ReplayNextButton;
/// Marker on the selector's `"Replay N / M"` caption text node so the
/// repaint system can rewrite the label as the selection changes.
#[derive(Component, Debug)]
pub struct ReplaySelectorCaption;
/// Marker component on each per-mode bests row in the stats overlay. /// Marker component on each per-mode bests row in the stats overlay.
/// ///
/// One row per supported [`solitaire_core::game_state::GameMode`] (Classic, /// One row per supported [`solitaire_core::game_state::GameMode`] (Classic,
@@ -123,14 +150,16 @@ impl Plugin for StatsPlugin {
// Replay file lives next to stats.json — when the StatsPlugin // Replay file lives next to stats.json — when the StatsPlugin
// is in headless mode (storage_path = None), we mirror that // is in headless mode (storage_path = None), we mirror that
// policy and disable replay I/O too. Otherwise resolve the // policy and disable replay I/O too. Otherwise resolve the
// platform-default path via `latest_replay_path()`. // platform-default path via `replay_history_path()`.
let replay_path = self.storage_path.as_ref().and(latest_replay_path()); let replay_path = self.storage_path.as_ref().and(replay_history_path());
let initial_replay = replay_path let initial_history = replay_path
.as_deref() .as_deref()
.and_then(load_latest_replay_from); .and_then(load_replay_history_from)
.unwrap_or_default();
app.insert_resource(StatsResource(loaded)) app.insert_resource(StatsResource(loaded))
.insert_resource(StatsStoragePath(self.storage_path.clone())) .insert_resource(StatsStoragePath(self.storage_path.clone()))
.insert_resource(LatestReplayResource(initial_replay)) .insert_resource(ReplayHistoryResource(initial_history))
.init_resource::<SelectedReplayIndex>()
.insert_resource(LatestReplayPath(replay_path)) .insert_resource(LatestReplayPath(replay_path))
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
@@ -160,19 +189,25 @@ impl Plugin for StatsPlugin {
.add_systems(Update, handle_stats_close_button) .add_systems(Update, handle_stats_close_button)
.add_systems( .add_systems(
Update, Update,
refresh_latest_replay_on_win.after(GameMutation), refresh_replay_history_on_win.after(GameMutation),
) )
.add_systems(Update, handle_watch_replay_button); .add_systems(Update, handle_watch_replay_button)
.add_systems(
Update,
(handle_replay_selector_buttons, repaint_replay_selector_caption).chain(),
);
} }
} }
/// After a win, the engine has just persisted a fresh winning replay. /// After a win, the engine has just appended a fresh winning replay to
/// Re-load it so the next time the player opens the Stats overlay, the /// the rolling history file. Re-load it so the next time the player
/// "Watch replay" call-to-action reflects the most recent victory /// opens the Stats overlay the selector reflects the new entry, and
/// rather than an older session. /// reset [`SelectedReplayIndex`] to `0` so the default selection is the
fn refresh_latest_replay_on_win( /// just-recorded win.
fn refresh_replay_history_on_win(
mut wins: MessageReader<GameWonEvent>, mut wins: MessageReader<GameWonEvent>,
mut latest: ResMut<LatestReplayResource>, mut history: ResMut<ReplayHistoryResource>,
mut selected: ResMut<SelectedReplayIndex>,
path: Res<LatestReplayPath>, path: Res<LatestReplayPath>,
) { ) {
// Only re-load when at least one win actually fired. // Only re-load when at least one win actually fired.
@@ -182,32 +217,123 @@ fn refresh_latest_replay_on_win(
let Some(p) = path.0.as_deref() else { let Some(p) = path.0.as_deref() else {
return; return;
}; };
latest.0 = load_latest_replay_from(p); history.0 = load_replay_history_from(p).unwrap_or_default();
// Snap the selector back to the most recent win — that's the one
// the player just earned.
selected.0 = 0;
} }
/// Click handler for the "Watch replay" button. /// Click handler for the "Watch replay" button.
/// ///
/// Replay playback lives on the sync server's web UI rather than in /// Starts in-engine replay playback for the currently-selected entry in
/// the desktop client. This handler currently surfaces a clear toast /// [`ReplayHistoryResource`] (per [`SelectedReplayIndex`]). If the
/// pointing the player there once the upload + URL is wired; until /// history is empty or the selector points past the end (defensive
/// then it acknowledges the click and signals that the feature is on /// guard), surfaces an [`InfoToastEvent`] instead. The playback path
/// the way. /// resets the live game to the recorded deal and ticks through the
/// move list via [`crate::replay_playback`]; the
/// [`crate::replay_overlay`] banner surfaces while playback runs.
fn handle_watch_replay_button( fn handle_watch_replay_button(
mut commands: Commands,
buttons: Query<&Interaction, (With<WatchReplayButton>, Changed<Interaction>)>, buttons: Query<&Interaction, (With<WatchReplayButton>, Changed<Interaction>)>,
latest: Res<LatestReplayResource>, history: Res<ReplayHistoryResource>,
selected: Res<SelectedReplayIndex>,
playback: Option<ResMut<crate::replay_playback::ReplayPlaybackState>>,
mut toast: MessageWriter<InfoToastEvent>, mut toast: MessageWriter<InfoToastEvent>,
) { ) {
if !buttons.iter().any(|i| *i == Interaction::Pressed) { if !buttons.iter().any(|i| *i == Interaction::Pressed) {
return; return;
} }
let message = match &latest.0 { let chosen = history.0.replays.get(selected.0);
Some(replay) => format!( match (chosen, playback) {
"Replay ready ({}) \u{2014} web playback coming in a future build", (Some(replay), Some(mut playback)) => {
format_replay_caption(replay), crate::replay_playback::start_replay_playback(
), &mut commands,
None => "No replay recorded yet \u{2014} win a game first.".to_string(), &mut playback,
}; replay.clone(),
toast.write(InfoToastEvent(message)); );
}
(Some(replay), None) => {
// ReplayPlaybackPlugin not registered (headless test
// fixtures); fall back to a descriptive toast.
toast.write(InfoToastEvent(format!(
"Replay ready ({})",
format_replay_caption(replay)
)));
}
(None, _) => {
toast.write(InfoToastEvent(
"No replay recorded yet \u{2014} win a game first.".to_string(),
));
}
}
}
/// Click handler for the Prev / Next chips on the Stats overlay's
/// replay selector. Steps [`SelectedReplayIndex`] within the bounds of
/// the current [`ReplayHistoryResource`]; selection wraps so the
/// chooser is always sat on a valid replay.
///
/// No-op when the history is empty — the selector chips paint disabled
/// in that case but a defensive bounds check here keeps things tidy if
/// the click somehow lands.
fn handle_replay_selector_buttons(
prev: Query<&Interaction, (With<ReplayPrevButton>, Changed<Interaction>)>,
next: Query<&Interaction, (With<ReplayNextButton>, Changed<Interaction>)>,
history: Res<ReplayHistoryResource>,
mut selected: ResMut<SelectedReplayIndex>,
) {
let len = history.0.replays.len();
if len == 0 {
return;
}
let prev_pressed = prev.iter().any(|i| *i == Interaction::Pressed);
let next_pressed = next.iter().any(|i| *i == Interaction::Pressed);
if prev_pressed {
// Step toward older replays — wrap to the oldest when at the
// newest (index 0).
selected.0 = if selected.0 == 0 { len - 1 } else { selected.0 - 1 };
}
if next_pressed {
// Step toward more recent replays — wrap to the newest when at
// the oldest.
selected.0 = (selected.0 + 1) % len;
}
}
/// Live-update the `"Replay N / M"` caption text as the selector
/// changes. The caption sits next to the Prev / Next chips above the
/// Watch button so the player can see at a glance which replay they're
/// about to watch.
fn repaint_replay_selector_caption(
history: Res<ReplayHistoryResource>,
selected: Res<SelectedReplayIndex>,
mut q: Query<&mut Text, With<ReplaySelectorCaption>>,
) {
if !history.is_changed() && !selected.is_changed() {
return;
}
for mut text in &mut q {
**text = replay_selector_caption(selected.0, history.0.replays.len());
}
}
/// Pure helper: render the selector caption shown next to the Prev /
/// Next chips. Returns `"No replays"` when the history is empty,
/// otherwise `"Replay {1-based index} / {total}"`.
///
/// `index` is zero-based as it's stored in [`SelectedReplayIndex`].
/// The display flips it to a one-based ordinal so "Replay 1" reads as
/// "the most recent win" — matching the mental model the chooser
/// surfaces.
pub fn replay_selector_caption(index: usize, total: usize) -> String {
if total == 0 {
return "No replays".to_string();
}
// Defensive clamp — the caller is supposed to keep `index` in
// range, but a stale selector after a cap-driven truncation
// shouldn't crash the renderer.
let one_based = index.min(total.saturating_sub(1)) + 1;
format!("Replay {one_based} / {total}")
} }
/// Pure helper: render a one-line caption for a [`Replay`] suitable /// Pure helper: render a one-line caption for a [`Replay`] suitable
@@ -359,7 +485,8 @@ fn toggle_stats_screen(
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
time_attack: Option<Res<TimeAttackResource>>, time_attack: Option<Res<TimeAttackResource>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
latest_replay: Res<LatestReplayResource>, latest_replay: Res<ReplayHistoryResource>,
selected_index: Res<SelectedReplayIndex>,
screens: Query<Entity, With<StatsScreen>>, screens: Query<Entity, With<StatsScreen>>,
) { ) {
let button_clicked = requests.read().count() > 0; let button_clicked = requests.read().count() > 0;
@@ -369,13 +496,14 @@ fn toggle_stats_screen(
if let Ok(entity) = screens.single() { if let Ok(entity) = screens.single() {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} else { } else {
let selected = latest_replay.0.replays.get(selected_index.0);
spawn_stats_screen( spawn_stats_screen(
&mut commands, &mut commands,
&stats.0, &stats.0,
progress.as_deref().map(|p| &p.0), progress.as_deref().map(|p| &p.0),
time_attack.as_deref(), time_attack.as_deref(),
font_res.as_deref(), font_res.as_deref(),
latest_replay.0.as_ref(), selected,
); );
} }
} }