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:
@@ -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<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.
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user