diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 650b453..eb6b424 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -490,6 +490,32 @@ impl GameState { self.session.stats().stats().recycle_count() } + /// Number of cards moved onto foundations this game, read from the + /// upstream session stats. Cumulative like [`Self::recycle_count`] — + /// not rolled back on undo. + pub fn move_to_foundation_count(&self) -> u32 { + self.session.stats().stats().move_to_foundation_count() + } + + /// Number of stacks moved onto tableaus (from stock or another tableau) + /// this game, read from the upstream session stats. Cumulative — not + /// rolled back on undo. + pub fn move_to_tableau_count(&self) -> u32 { + self.session.stats().stats().move_to_tableau_count() + } + + /// Number of cards taken back off a foundation this game, read from the + /// upstream session stats. Cumulative — not rolled back on undo. + pub fn move_from_foundation_count(&self) -> u32 { + self.session.stats().stats().move_from_foundation_count() + } + + /// Number of face-down cards revealed (flipped up) this game, read from + /// the upstream session stats. Cumulative — not rolled back on undo. + pub fn flip_up_count(&self) -> u32 { + self.session.stats().stats().flip_up_bonus_count() + } + /// Total moves made this game (draws, recycles, and card moves), derived /// from the session's instruction history length. pub fn move_count(&self) -> u32 { diff --git a/solitaire_engine/src/win_summary_plugin.rs b/solitaire_engine/src/win_summary_plugin.rs index e8041a8..b1867e3 100644 --- a/solitaire_engine/src/win_summary_plugin.rs +++ b/solitaire_engine/src/win_summary_plugin.rs @@ -18,7 +18,7 @@ //! shake duration elapses. use bevy::prelude::*; -use solitaire_core::game_state::GameMode; +use solitaire_core::game_state::{GameMode, GameState}; use solitaire_core::scoring::compute_time_bonus; use solitaire_data::AnimSpeed; @@ -93,6 +93,40 @@ pub struct WinSummaryPending { /// score-breakdown reveal can format the mode-multiplier row /// (e.g. `Zen ×0.0`, `Classic ×1.0`). pub mode: GameMode, + /// Per-move-type recap of the winning game, e.g. + /// `"21 to foundation · 14 tableau moves · 9 flips · 1 recycle"`. + /// Built from the upstream session counters at win time; empty when + /// every counter is zero (synthesised test wins). + pub move_detail: String, +} + +/// Formats the per-move-type recap line for the win modal from the +/// upstream session counters. Zero-valued components are omitted so the +/// line stays short; returns an empty string when nothing was counted +/// (only possible for synthesised test wins). +fn build_move_detail(game: &GameState) -> String { + let mut parts: Vec = Vec::new(); + let foundation = game.move_to_foundation_count(); + if foundation > 0 { + parts.push(format!("{foundation} to foundation")); + } + let tableau = game.move_to_tableau_count(); + if tableau > 0 { + parts.push(format!("{tableau} tableau moves")); + } + let flips = game.flip_up_count(); + if flips > 0 { + parts.push(format!("{flips} flips")); + } + let recycles = game.recycle_count(); + if recycles > 0 { + parts.push(format!("{recycles} recycles")); + } + let returns = game.move_from_foundation_count(); + if returns > 0 { + parts.push(format!("{returns} foundation returns")); + } + parts.join(" \u{00B7} ") } /// Builds a human-readable XP breakdown string for the win modal. @@ -492,6 +526,7 @@ fn cache_win_data( pending.challenge_level = challenge_level; pending.undo_count = game.0.undo_count(); pending.mode = game.0.mode; + pending.move_detail = build_move_detail(&game.0); if is_new_record { toast.write(InfoToastEvent("New Record!".to_string())); @@ -913,6 +948,18 @@ fn spawn_overlay( )); } + // Move-type recap (same quiet styling as the XP breakdown) + if !pending.move_detail.is_empty() { + card.spawn(( + Text::new(pending.move_detail.clone()), + TextFont { + font_size: 15.0, + ..default() + }, + TextColor(TEXT_SECONDARY), + )); + } + // Achievements unlocked this game — at most 3 shown explicitly; // excess is summarised with "...and N more". if !session.names.is_empty() { @@ -1288,6 +1335,49 @@ mod tests { assert_eq!(p.mode, GameMode::Classic); } + #[test] + fn build_move_detail_fresh_game_is_empty() { + let game = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne); + assert!( + build_move_detail(&game).is_empty(), + "no moves yet, so the recap line must be empty" + ); + } + + #[test] + fn build_move_detail_reports_played_move_types() { + use solitaire_core::KlondikeInstruction; + // Drive a real deal forward so the upstream counters accumulate: + // prefer foundation moves, then anything else, drawing as needed. + let mut game = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne); + for _ in 0..80 { + let instructions = game.possible_instructions(); + let next = instructions + .iter() + .copied() + .find(|i| matches!(i, KlondikeInstruction::DstFoundation(_))) + .or_else(|| instructions.into_iter().next()); + match next { + Some(i) => { + let _ = game.apply_instruction(i); + } + None => break, + } + if game.move_to_foundation_count() > 0 && game.move_to_tableau_count() > 0 { + break; + } + } + let detail = build_move_detail(&game); + assert!( + detail.contains("to foundation"), + "seed 42 reaches a foundation move within 80 plies; got: {detail}" + ); + assert!( + !detail.contains("0 "), + "zero-valued components must be omitted; got: {detail}" + ); + } + #[test] fn build_xp_detail_slow_win_with_undo() { // 300s >= 120s → no speed bonus; undo used → no no-undo bonus. diff --git a/solitaire_server/web/replay.js b/solitaire_server/web/replay.js index 5b38f5c..453e249 100644 --- a/solitaire_server/web/replay.js +++ b/solitaire_server/web/replay.js @@ -315,10 +315,9 @@ btnPlay.addEventListener("click", () => { }, STEP_INTERVAL_MS); }); -/// Step the player back one move. Re-creates the ReplayPlayer and fast- -/// forwards to (step_idx - 1) without rendering intermediate frames, then -/// renders once so the CSS transition animates each card to its previous -/// position. +/// Step the player back one move via the wasm-side seek (rewinds to the +/// recorded deal and fast-forwards internally), then renders once so the +/// CSS transition animates each card to its previous position. function stepBack() { if (!player || player.step_idx() === 0) return; if (playInterval) { @@ -326,12 +325,7 @@ function stepBack() { playInterval = null; btnPlay.textContent = "▶ Play"; } - const target = player.step_idx() - 1; - player = new ReplayPlayer(replayJson); - for (let i = 0; i < target; i++) { - player.step(); - } - render(player.state()); + render(player.seek(player.step_idx() - 1)); btnPrev.disabled = player.step_idx() === 0; btnRestart.disabled = player.step_idx() === 0; btnStep.disabled = false; diff --git a/solitaire_wasm/src/lib.rs b/solitaire_wasm/src/lib.rs index 6b70957..59eb44a 100644 --- a/solitaire_wasm/src/lib.rs +++ b/solitaire_wasm/src/lib.rs @@ -110,6 +110,9 @@ impl From<&(Card, bool)> for CardSnapshot { #[wasm_bindgen] pub struct ReplayPlayer { game: GameState, + /// The recorded deal before any instruction, kept so [`Self::seek_native`] + /// can rewind without reparsing the replay JSON. + initial: GameState, moves: Vec, step_idx: usize, } @@ -144,12 +147,30 @@ impl ReplayPlayer { // the current build maps seeds to deals. let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode); Ok(Self { + initial: game.clone(), game, moves, step_idx: 0, }) } + /// Jump to `step` (clamped to the move count): the board state after + /// `step` moves have been applied. Rewinds by resetting to the stored + /// initial deal, then fast-forwards — a few hundred instruction + /// applications, microseconds in practice. + pub fn seek_native(&mut self, step: usize) -> Result { + let target = step.min(self.moves.len()); + if target < self.step_idx { + self.game = self.initial.clone(); + self.step_idx = 0; + } + while self.step_idx < target { + self.game.apply_instruction(self.moves[self.step_idx])?; + self.step_idx += 1; + } + Ok(self.snapshot()) + } + /// Apply the next move. Returns `Ok(None)` once the list is exhausted. pub fn step_native(&mut self) -> Result, MoveError> { if self.step_idx >= self.moves.len() { @@ -236,6 +257,25 @@ impl ReplayPlayer { } } + /// Jump directly to `step` moves applied (clamped to the move count) + /// and return the snapshot there. Backwards seeks rewind to the + /// recorded deal and fast-forward, so any position is O(replay length) + /// at worst — no JSON reparse, no intermediate renders. + /// + /// Throws `"replay_desync"` if a recorded move is illegal during the + /// fast-forward (corrupt recording). + pub fn seek(&mut self, step: usize) -> Result { + match self.seek_native(step) { + Ok(snap) => { + serde_wasm_bindgen::to_value(&snap).map_err(|e| JsValue::from_str(&e.to_string())) + } + Err(e) => { + log_replay_move_error(&e); + Err(JsValue::from_str("replay_desync")) + } + } + } + /// Total number of moves the replay contains. pub fn total_steps(&self) -> usize { self.moves.len() @@ -1062,6 +1102,57 @@ mod tests { assert_eq!(orig["stock"], repl["stock"], "stock deal must match"); } + /// `seek` must land on exactly the state produced by stepping — both + /// forwards (fast-forward from the current position) and backwards + /// (rewind to the recorded deal, then fast-forward). + #[test] + fn seek_matches_stepping_in_both_directions() { + let mut game = SolitaireGame { + game: GameState::new_with_mode(51, DrawStockConfig::DrawOne, GameMode::Classic), + }; + for _ in 0..24 { + let legal_moves = game.legal_moves_native(); + if legal_moves.is_empty() { + break; + } + let idx = pick_move_index(&legal_moves).unwrap_or_default(); + game.apply_legal_move_native(idx).expect("advance game"); + } + let replay_json = game + .replay_export_native(60, "2026-07-10") + .expect("export replay"); + + let mut stepped = ReplayPlayer::from_json(&replay_json).expect("player A"); + let mut seeker = ReplayPlayer::from_json(&replay_json).expect("player B"); + let total = stepped.total_steps(); + assert!(total >= 4, "test needs a few moves, got {total}"); + + // Forward: step A to k, seek B to k, compare snapshots. + let k = total / 2; + for _ in 0..k { + stepped.step_native().expect("step").expect("mid-replay"); + } + let sought = seeker.seek_native(k).expect("seek forward"); + assert_eq!(sought, stepped.snapshot(), "forward seek diverged at {k}"); + + // Backward: seek B to k - 2 and compare against a fresh stepper. + let back = k - 2; + let mut fresh = ReplayPlayer::from_json(&replay_json).expect("player C"); + for _ in 0..back { + fresh.step_native().expect("step").expect("mid-replay"); + } + let sought_back = seeker.seek_native(back).expect("seek backward"); + assert_eq!( + sought_back, + fresh.snapshot(), + "backward seek diverged at {back}" + ); + + // Clamping: past-the-end seeks stop at the final state. + let end = seeker.seek_native(usize::MAX).expect("seek to end"); + assert_eq!(end.step_idx, total); + } + #[test] fn debug_api_autonomous_seed_batch_smoke() { for seed in 0_u64..128_u64 {