Merge pull request 'feat(engine): surface upstream move-type counters and replay seek' (#173) from feat/upstream-stat-counters into master
This commit was merged in pull request #173.
This commit is contained in:
@@ -490,6 +490,32 @@ impl GameState {
|
|||||||
self.session.stats().stats().recycle_count()
|
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
|
/// Total moves made this game (draws, recycles, and card moves), derived
|
||||||
/// from the session's instruction history length.
|
/// from the session's instruction history length.
|
||||||
pub fn move_count(&self) -> u32 {
|
pub fn move_count(&self) -> u32 {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
//! shake duration elapses.
|
//! shake duration elapses.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
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_core::scoring::compute_time_bonus;
|
||||||
use solitaire_data::AnimSpeed;
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
@@ -93,6 +93,40 @@ pub struct WinSummaryPending {
|
|||||||
/// score-breakdown reveal can format the mode-multiplier row
|
/// score-breakdown reveal can format the mode-multiplier row
|
||||||
/// (e.g. `Zen ×0.0`, `Classic ×1.0`).
|
/// (e.g. `Zen ×0.0`, `Classic ×1.0`).
|
||||||
pub mode: GameMode,
|
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<String> = 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.
|
/// 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.challenge_level = challenge_level;
|
||||||
pending.undo_count = game.0.undo_count();
|
pending.undo_count = game.0.undo_count();
|
||||||
pending.mode = game.0.mode;
|
pending.mode = game.0.mode;
|
||||||
|
pending.move_detail = build_move_detail(&game.0);
|
||||||
|
|
||||||
if is_new_record {
|
if is_new_record {
|
||||||
toast.write(InfoToastEvent("New Record!".to_string()));
|
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;
|
// Achievements unlocked this game — at most 3 shown explicitly;
|
||||||
// excess is summarised with "...and N more".
|
// excess is summarised with "...and N more".
|
||||||
if !session.names.is_empty() {
|
if !session.names.is_empty() {
|
||||||
@@ -1288,6 +1335,49 @@ mod tests {
|
|||||||
assert_eq!(p.mode, GameMode::Classic);
|
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]
|
#[test]
|
||||||
fn build_xp_detail_slow_win_with_undo() {
|
fn build_xp_detail_slow_win_with_undo() {
|
||||||
// 300s >= 120s → no speed bonus; undo used → no no-undo bonus.
|
// 300s >= 120s → no speed bonus; undo used → no no-undo bonus.
|
||||||
|
|||||||
@@ -315,10 +315,9 @@ btnPlay.addEventListener("click", () => {
|
|||||||
}, STEP_INTERVAL_MS);
|
}, STEP_INTERVAL_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Step the player back one move. Re-creates the ReplayPlayer and fast-
|
/// Step the player back one move via the wasm-side seek (rewinds to the
|
||||||
/// forwards to (step_idx - 1) without rendering intermediate frames, then
|
/// recorded deal and fast-forwards internally), then renders once so the
|
||||||
/// renders once so the CSS transition animates each card to its previous
|
/// CSS transition animates each card to its previous position.
|
||||||
/// position.
|
|
||||||
function stepBack() {
|
function stepBack() {
|
||||||
if (!player || player.step_idx() === 0) return;
|
if (!player || player.step_idx() === 0) return;
|
||||||
if (playInterval) {
|
if (playInterval) {
|
||||||
@@ -326,12 +325,7 @@ function stepBack() {
|
|||||||
playInterval = null;
|
playInterval = null;
|
||||||
btnPlay.textContent = "▶ Play";
|
btnPlay.textContent = "▶ Play";
|
||||||
}
|
}
|
||||||
const target = player.step_idx() - 1;
|
render(player.seek(player.step_idx() - 1));
|
||||||
player = new ReplayPlayer(replayJson);
|
|
||||||
for (let i = 0; i < target; i++) {
|
|
||||||
player.step();
|
|
||||||
}
|
|
||||||
render(player.state());
|
|
||||||
btnPrev.disabled = player.step_idx() === 0;
|
btnPrev.disabled = player.step_idx() === 0;
|
||||||
btnRestart.disabled = player.step_idx() === 0;
|
btnRestart.disabled = player.step_idx() === 0;
|
||||||
btnStep.disabled = false;
|
btnStep.disabled = false;
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ impl From<&(Card, bool)> for CardSnapshot {
|
|||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub struct ReplayPlayer {
|
pub struct ReplayPlayer {
|
||||||
game: GameState,
|
game: GameState,
|
||||||
|
/// The recorded deal before any instruction, kept so [`Self::seek_native`]
|
||||||
|
/// can rewind without reparsing the replay JSON.
|
||||||
|
initial: GameState,
|
||||||
moves: Vec<KlondikeInstruction>,
|
moves: Vec<KlondikeInstruction>,
|
||||||
step_idx: usize,
|
step_idx: usize,
|
||||||
}
|
}
|
||||||
@@ -144,12 +147,30 @@ impl ReplayPlayer {
|
|||||||
// the current build maps seeds to deals.
|
// the current build maps seeds to deals.
|
||||||
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
|
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
initial: game.clone(),
|
||||||
game,
|
game,
|
||||||
moves,
|
moves,
|
||||||
step_idx: 0,
|
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<StateSnapshot, MoveError> {
|
||||||
|
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.
|
/// Apply the next move. Returns `Ok(None)` once the list is exhausted.
|
||||||
pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> {
|
pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> {
|
||||||
if self.step_idx >= self.moves.len() {
|
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<JsValue, JsValue> {
|
||||||
|
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.
|
/// Total number of moves the replay contains.
|
||||||
pub fn total_steps(&self) -> usize {
|
pub fn total_steps(&self) -> usize {
|
||||||
self.moves.len()
|
self.moves.len()
|
||||||
@@ -1062,6 +1102,57 @@ mod tests {
|
|||||||
assert_eq!(orig["stock"], repl["stock"], "stock deal must match");
|
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]
|
#[test]
|
||||||
fn debug_api_autonomous_seed_batch_smoke() {
|
fn debug_api_autonomous_seed_batch_smoke() {
|
||||||
for seed in 0_u64..128_u64 {
|
for seed in 0_u64..128_u64 {
|
||||||
|
|||||||
Reference in New Issue
Block a user