ddc8f27c82
Three small UX improvements bundled because they share ui_theme token
edits.
Tooltip-delay slider in Settings → Gameplay
- Settings.tooltip_delay_secs (f32, #[serde(default)] = 0.5) tunable
via "−" / "+" icon buttons next to a value readout. Range
[TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS] = [0.0, 1.5] in
TOOLTIP_DELAY_STEP_SECS (0.1) increments. "Instant" label when
value is 0; "{n:.1} s" otherwise.
- ui_tooltip's hover-delay comparison reads from SettingsResource
with MOTION_TOOLTIP_DELAY_SECS as the fallback when the resource
is absent (test path). New tooltip_should_show(elapsed, delay)
pure helper covers the boundary cases.
- adjust_tooltip_delay clamps; sanitized() carries the clamp through
load. Five round-trip / default / legacy-deserialise tests.
Win-streak milestone fire animation
- New WinStreakMilestoneEvent { streak: u32 } fired from stats_plugin
when win_streak_current crosses any of [3, 5, 10] (only the
threshold crossing — not every subsequent win). HUD streak readout
scale-pulses 1.0 → 1.20 → 1.0 over MOTION_STREAK_FLOURISH_SECS
(0.6 s) on receipt; mirrors the foundation-flourish curve shape.
- Three threshold-crossing tests pin the firing contract.
Score-breakdown reveal on the win modal
- Win modal body replaces the single "Score: N" line with a
per-component reveal: Base score, Time bonus (m:ss), No-undo
bonus, Mode multiplier, separator, Total. Rows fade in over
MOTION_SCORE_BREAKDOWN_FADE_SECS (0.12 s) staggered by
MOTION_SCORE_BREAKDOWN_STAGGER_SECS (0.15 s) so the math reads as
it animates. Skipped rows: zero time bonus, undo-tainted no-undo
bonus, multiplier == 1.0.
- Honours AnimSpeed::Instant: rows spawn fully visible, no stagger.
- New ScoreBreakdown::compute helper sources base from
GameWonEvent.score, time bonus from
solitaire_core::scoring::compute_time_bonus, no-undo from a +25
constant when undo_count == 0, mode multiplier from GameMode (Zen
zeros the total). 9 new tests cover the math and the reveal
cadence.
Test count net: +25 across the workspace (1007 → 1031).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1057 lines
36 KiB
Rust
1057 lines
36 KiB
Rust
//! Loads, updates, and persists `StatsSnapshot` in response to game events,
|
|
//! and provides a toggleable full-window stats overlay (press `S`).
|
|
//!
|
|
//! The persistence path is configurable via `StatsPlugin::storage_path`.
|
|
//! In production, `StatsPlugin::default()` loads/saves from the platform
|
|
//! data dir. In tests, use `StatsPlugin::headless()` to disable all file
|
|
//! I/O so the user's real stats file is neither read nor overwritten.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use bevy::input::ButtonInput;
|
|
use bevy::prelude::*;
|
|
use solitaire_data::{
|
|
load_stats_from, save_stats_to, stats_file_path, PlayerProgress, StatsExt, StatsSnapshot,
|
|
WEEKLY_GOALS,
|
|
};
|
|
|
|
use crate::auto_complete_plugin::AutoCompleteState;
|
|
use crate::challenge_plugin::challenge_progress_label;
|
|
use crate::events::{
|
|
ForfeitEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, ToggleStatsRequestEvent,
|
|
WinStreakMilestoneEvent,
|
|
};
|
|
use crate::game_plugin::GameMutation;
|
|
use crate::progress_plugin::ProgressResource;
|
|
use crate::font_plugin::FontResource;
|
|
use crate::resources::GameStateResource;
|
|
use crate::time_attack_plugin::TimeAttackResource;
|
|
use crate::ui_modal::{
|
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
|
};
|
|
use crate::ui_theme::{
|
|
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES,
|
|
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2,
|
|
VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
|
|
};
|
|
|
|
/// Bevy resource wrapping the current stats.
|
|
#[derive(Resource, Debug, Clone)]
|
|
pub struct StatsResource(pub StatsSnapshot);
|
|
|
|
/// Persistence path for `StatsResource`. `None` disables I/O.
|
|
#[derive(Resource, Debug, Clone)]
|
|
pub struct StatsStoragePath(pub Option<PathBuf>);
|
|
|
|
/// System set for the stats-mutating systems. Downstream plugins that read
|
|
/// `StatsResource` after a win/abandon should run `.after(StatsUpdate)`.
|
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub struct StatsUpdate;
|
|
|
|
/// Marker component on the stats overlay root node.
|
|
#[derive(Component, Debug)]
|
|
pub struct StatsScreen;
|
|
|
|
/// Marker component on an individual stat cell inside the stats overlay.
|
|
///
|
|
/// Each cell contains a large value label and a small descriptor label below it.
|
|
#[derive(Component, Debug)]
|
|
pub struct StatsCell;
|
|
|
|
/// Registers stats resources, update systems, and the UI toggle.
|
|
pub struct StatsPlugin {
|
|
/// Where to persist stats. `None` disables all file I/O (for tests).
|
|
pub storage_path: Option<PathBuf>,
|
|
}
|
|
|
|
impl Default for StatsPlugin {
|
|
fn default() -> Self {
|
|
Self {
|
|
storage_path: stats_file_path(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl StatsPlugin {
|
|
/// Plugin configured with no persistence. Use in tests and headless apps
|
|
/// where touching `~/.local/share/solitaire_quest/stats.json` would be
|
|
/// incorrect.
|
|
pub fn headless() -> Self {
|
|
Self { storage_path: None }
|
|
}
|
|
}
|
|
|
|
impl Plugin for StatsPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
let loaded = match &self.storage_path {
|
|
Some(path) => load_stats_from(path),
|
|
None => StatsSnapshot::default(),
|
|
};
|
|
app.insert_resource(StatsResource(loaded))
|
|
.insert_resource(StatsStoragePath(self.storage_path.clone()))
|
|
.add_message::<GameWonEvent>()
|
|
.add_message::<NewGameRequestEvent>()
|
|
.add_message::<ForfeitEvent>()
|
|
.add_message::<InfoToastEvent>()
|
|
.add_message::<ToggleStatsRequestEvent>()
|
|
.add_message::<WinStreakMilestoneEvent>()
|
|
// record_abandoned must read `move_count` BEFORE handle_new_game
|
|
// clobbers it with a fresh game. These are NOT in StatsUpdate because
|
|
// StatsUpdate (as a set) is ordered after GameMutation by external
|
|
// constraints (win_summary_plugin: cache_win_data.before(StatsUpdate)),
|
|
// and a system cannot be both inside a set and individually before a
|
|
// set-level ordering constraint.
|
|
.add_systems(
|
|
Update,
|
|
update_stats_on_new_game.before(GameMutation),
|
|
)
|
|
.add_systems(
|
|
Update,
|
|
update_stats_on_win.after(GameMutation).in_set(StatsUpdate),
|
|
)
|
|
.add_systems(
|
|
Update,
|
|
handle_forfeit.before(GameMutation),
|
|
)
|
|
.add_systems(Update, toggle_stats_screen.after(GameMutation))
|
|
.add_systems(Update, handle_stats_close_button);
|
|
}
|
|
}
|
|
|
|
fn persist(path: &StatsStoragePath, stats: &StatsSnapshot, context: &str) {
|
|
let Some(target) = &path.0 else {
|
|
return;
|
|
};
|
|
if let Err(e) = save_stats_to(target, stats) {
|
|
warn!("failed to save stats after {context}: {e}");
|
|
}
|
|
}
|
|
|
|
fn update_stats_on_win(
|
|
mut events: MessageReader<GameWonEvent>,
|
|
game: Res<GameStateResource>,
|
|
mut stats: ResMut<StatsResource>,
|
|
path: Res<StatsStoragePath>,
|
|
mut milestone: MessageWriter<WinStreakMilestoneEvent>,
|
|
mut toast: MessageWriter<InfoToastEvent>,
|
|
) {
|
|
for ev in events.read() {
|
|
let prev_streak = stats.0.win_streak_current;
|
|
stats
|
|
.0
|
|
.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode);
|
|
let new_streak = stats.0.win_streak_current;
|
|
// Fire the streak-milestone event only on the threshold
|
|
// crossing — `prev < threshold && new >= threshold`. This
|
|
// guarantees the flourish never retriggers at every win past
|
|
// the highest milestone.
|
|
if let Some(crossed) = streak_milestone_crossed(prev_streak, new_streak) {
|
|
milestone.write(WinStreakMilestoneEvent { streak: crossed });
|
|
toast.write(InfoToastEvent(format!(
|
|
"Win streak: {crossed}! \u{1F525}"
|
|
)));
|
|
}
|
|
persist(&path, &stats.0, "win");
|
|
}
|
|
}
|
|
|
|
/// Returns the milestone value that the player just crossed, if any.
|
|
///
|
|
/// A milestone is "crossed" when `prev < threshold && new >= threshold`
|
|
/// for some `threshold` in [`STREAK_MILESTONES`]. Returns the largest
|
|
/// such threshold (so a single win that vaults the player from a
|
|
/// streak of 0 directly to 5 — implausible, but defensive — fires the
|
|
/// most-celebrated milestone, not the smallest).
|
|
///
|
|
/// Returns `None` when no threshold was crossed, i.e. either:
|
|
/// - the streak did not change,
|
|
/// - the streak rose but stayed below every threshold, or
|
|
/// - the streak rose past a threshold that `prev` was already at or
|
|
/// above.
|
|
///
|
|
/// Pure function exposed for unit testing without Bevy.
|
|
pub fn streak_milestone_crossed(prev: u32, new: u32) -> Option<u32> {
|
|
if new <= prev {
|
|
return None;
|
|
}
|
|
STREAK_MILESTONES
|
|
.iter()
|
|
.copied()
|
|
.filter(|&t| prev < t && new >= t)
|
|
.max()
|
|
}
|
|
|
|
fn update_stats_on_new_game(
|
|
mut events: MessageReader<NewGameRequestEvent>,
|
|
game: Res<GameStateResource>,
|
|
mut stats: ResMut<StatsResource>,
|
|
path: Res<StatsStoragePath>,
|
|
mut toast: MessageWriter<InfoToastEvent>,
|
|
) {
|
|
for _ in events.read() {
|
|
if game.0.move_count > 0 && !game.0.is_won {
|
|
let streak = stats.0.win_streak_current;
|
|
stats.0.record_abandoned();
|
|
persist(&path, &stats.0, "abandoned game");
|
|
if streak > 1 {
|
|
toast.write(InfoToastEvent(format!("Streak of {streak} broken!")));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// When the player presses G to forfeit, record the game as abandoned, save
|
|
/// stats, fire an informational toast, and start a new game.
|
|
///
|
|
/// `AutoCompleteState` is reset here so the "AUTO" badge and chime do not bleed
|
|
/// into the new deal (task #41).
|
|
fn handle_forfeit(
|
|
mut events: MessageReader<ForfeitEvent>,
|
|
game: Res<GameStateResource>,
|
|
mut stats: ResMut<StatsResource>,
|
|
path: Res<StatsStoragePath>,
|
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
|
mut toast: MessageWriter<InfoToastEvent>,
|
|
mut auto_complete: Option<ResMut<AutoCompleteState>>,
|
|
) {
|
|
for _ in events.read() {
|
|
if game.0.move_count > 0 && !game.0.is_won {
|
|
let streak = stats.0.win_streak_current;
|
|
stats.0.record_abandoned();
|
|
persist(&path, &stats.0, "forfeit");
|
|
if streak > 1 {
|
|
toast.write(InfoToastEvent(format!("Streak of {streak} broken!")));
|
|
}
|
|
}
|
|
// Reset auto-complete so the badge and chime don't carry over to the
|
|
// new game that is about to start.
|
|
if let Some(ref mut ac) = auto_complete {
|
|
**ac = AutoCompleteState::default();
|
|
}
|
|
toast.write(InfoToastEvent("Game forfeited".to_string()));
|
|
new_game.write(NewGameRequestEvent::default());
|
|
}
|
|
}
|
|
|
|
/// Marker on the "Done" button inside the Stats modal. Click despawns
|
|
/// the overlay; `S` keyboard shortcut toggles it the same way.
|
|
#[derive(Component, Debug)]
|
|
pub struct StatsCloseButton;
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn toggle_stats_screen(
|
|
mut commands: Commands,
|
|
keys: Res<ButtonInput<KeyCode>>,
|
|
mut requests: MessageReader<ToggleStatsRequestEvent>,
|
|
stats: Res<StatsResource>,
|
|
progress: Option<Res<ProgressResource>>,
|
|
time_attack: Option<Res<TimeAttackResource>>,
|
|
font_res: Option<Res<FontResource>>,
|
|
screens: Query<Entity, With<StatsScreen>>,
|
|
) {
|
|
let button_clicked = requests.read().count() > 0;
|
|
if !keys.just_pressed(KeyCode::KeyS) && !button_clicked {
|
|
return;
|
|
}
|
|
if let Ok(entity) = screens.single() {
|
|
commands.entity(entity).despawn();
|
|
} else {
|
|
spawn_stats_screen(
|
|
&mut commands,
|
|
&stats.0,
|
|
progress.as_deref().map(|p| &p.0),
|
|
time_attack.as_deref(),
|
|
font_res.as_deref(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Click handler for the modal's "Done" button — despawns the overlay
|
|
/// the same way the `S` accelerator does.
|
|
fn handle_stats_close_button(
|
|
mut commands: Commands,
|
|
close_buttons: Query<&Interaction, (With<StatsCloseButton>, Changed<Interaction>)>,
|
|
screens: Query<Entity, With<StatsScreen>>,
|
|
) {
|
|
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
|
return;
|
|
}
|
|
for entity in &screens {
|
|
commands.entity(entity).despawn();
|
|
}
|
|
}
|
|
|
|
fn spawn_stats_screen(
|
|
commands: &mut Commands,
|
|
stats: &StatsSnapshot,
|
|
progress: Option<&PlayerProgress>,
|
|
time_attack: Option<&TimeAttackResource>,
|
|
font_res: Option<&FontResource>,
|
|
) {
|
|
// --- primary stat cells ---
|
|
// First-launch zero-state: when no games have been played yet, render
|
|
// every top-level cell as an em-dash so the panel doesn't read as a
|
|
// mix of "0" counters and "—" sentinels (which feels buggy).
|
|
let is_first_launch = stats.games_played == 0;
|
|
let dash = "\u{2014}".to_string();
|
|
let win_rate_str = if is_first_launch { dash.clone() } else { format_win_rate(stats) };
|
|
let played_str = if is_first_launch { dash.clone() } else { format_stat_value(stats.games_played) };
|
|
let won_str = if is_first_launch { dash.clone() } else { format_stat_value(stats.games_won) };
|
|
let lost_str = if is_first_launch { dash.clone() } else { format_stat_value(stats.games_lost) };
|
|
let fastest_str = if is_first_launch { dash.clone() } else { format_fastest_win(stats.fastest_win_seconds) };
|
|
let avg_time_str = if is_first_launch { dash.clone() } else { format_avg_time(stats) };
|
|
let best_score_str = if is_first_launch { dash.clone() } else { format_optional_u32(stats.best_single_score) };
|
|
let best_streak_str = if is_first_launch { dash.clone() } else { format_stat_value(stats.win_streak_best) };
|
|
|
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
|
let font_section = TextFont {
|
|
font: font_handle.clone(),
|
|
font_size: TYPE_BODY_LG,
|
|
..default()
|
|
};
|
|
let font_row = TextFont {
|
|
font: font_handle,
|
|
font_size: TYPE_BODY,
|
|
..default()
|
|
};
|
|
|
|
spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| {
|
|
spawn_modal_header(card, "Statistics", font_res);
|
|
|
|
// First-launch caption — sits above the grid as gentle nudge so
|
|
// the wall of em-dashes reads as "nothing to track yet" rather
|
|
// than as broken state.
|
|
if is_first_launch {
|
|
card.spawn((
|
|
Text::new("Play a game to start tracking stats."),
|
|
TextFont {
|
|
font_size: TYPE_CAPTION,
|
|
..default()
|
|
},
|
|
TextColor(TEXT_SECONDARY),
|
|
Node {
|
|
margin: UiRect {
|
|
bottom: VAL_SPACE_2,
|
|
..default()
|
|
},
|
|
..default()
|
|
},
|
|
));
|
|
}
|
|
|
|
// --- primary stat cells grid ---
|
|
card.spawn(Node {
|
|
flex_direction: FlexDirection::Row,
|
|
flex_wrap: FlexWrap::Wrap,
|
|
justify_content: JustifyContent::Center,
|
|
align_items: AlignItems::FlexStart,
|
|
column_gap: VAL_SPACE_4,
|
|
row_gap: VAL_SPACE_3,
|
|
width: Val::Percent(100.0),
|
|
..default()
|
|
})
|
|
.with_children(|grid| {
|
|
spawn_stat_cell(grid, &win_rate_str, "Win Rate");
|
|
spawn_stat_cell(grid, &played_str, "Games Played");
|
|
spawn_stat_cell(grid, &won_str, "Games Won");
|
|
spawn_stat_cell(grid, &lost_str, "Games Lost");
|
|
spawn_stat_cell(grid, &fastest_str, "Fastest Win");
|
|
spawn_stat_cell(grid, &avg_time_str, "Avg Time");
|
|
spawn_stat_cell(grid, &best_score_str, "Best Score");
|
|
spawn_stat_cell(grid, &best_streak_str, "Best Streak");
|
|
});
|
|
|
|
// --- progression section ---
|
|
if let Some(p) = progress {
|
|
card.spawn((
|
|
Text::new("Progression"),
|
|
font_section.clone(),
|
|
TextColor(STATE_INFO),
|
|
));
|
|
|
|
let level_str = format_stat_value(p.level);
|
|
let xp_str = format_stat_value(p.total_xp as u32);
|
|
let next_label = xp_to_next_level_label(p.total_xp, p.level);
|
|
let daily_str = format_stat_value(p.daily_challenge_streak);
|
|
let challenge_str = challenge_progress_label(p.challenge_index);
|
|
|
|
card.spawn(Node {
|
|
flex_direction: FlexDirection::Row,
|
|
flex_wrap: FlexWrap::Wrap,
|
|
justify_content: JustifyContent::Center,
|
|
align_items: AlignItems::FlexStart,
|
|
column_gap: VAL_SPACE_4,
|
|
row_gap: VAL_SPACE_3,
|
|
width: Val::Percent(100.0),
|
|
..default()
|
|
})
|
|
.with_children(|grid| {
|
|
spawn_stat_cell(grid, &level_str, "Level");
|
|
spawn_stat_cell(grid, &xp_str, "Total XP");
|
|
spawn_stat_cell(grid, &next_label, "Next Level");
|
|
spawn_stat_cell(grid, &daily_str, "Daily Streak");
|
|
spawn_stat_cell(grid, &challenge_str, "Challenge");
|
|
});
|
|
|
|
// Weekly goals
|
|
card.spawn((
|
|
Text::new("Weekly Goals"),
|
|
font_section.clone(),
|
|
TextColor(TEXT_SECONDARY),
|
|
));
|
|
for goal in WEEKLY_GOALS {
|
|
let pv = p.weekly_goal_progress.get(goal.id).copied().unwrap_or(0);
|
|
card.spawn((
|
|
Text::new(format!(" {}: {}/{}", goal.description, pv, goal.target)),
|
|
font_row.clone(),
|
|
TextColor(TEXT_PRIMARY),
|
|
));
|
|
}
|
|
|
|
// Unlocks line
|
|
card.spawn((
|
|
Text::new(format!(
|
|
"Card Backs: {} | Backgrounds: {}",
|
|
format_id_list(&p.unlocked_card_backs),
|
|
format_id_list(&p.unlocked_backgrounds),
|
|
)),
|
|
font_row.clone(),
|
|
TextColor(TEXT_SECONDARY),
|
|
));
|
|
}
|
|
|
|
// --- Time Attack section ---
|
|
if let Some(ta) = time_attack
|
|
&& ta.active {
|
|
let mins = (ta.remaining_secs / 60.0).floor() as u64;
|
|
let secs = (ta.remaining_secs % 60.0).floor() as u64;
|
|
card.spawn((
|
|
Text::new(format!(
|
|
"Time Attack \u{2014} {mins}m {secs:02}s left | Wins: {}",
|
|
ta.wins
|
|
)),
|
|
font_section.clone(),
|
|
TextColor(STATE_WARNING),
|
|
));
|
|
}
|
|
|
|
spawn_modal_actions(card, |actions| {
|
|
spawn_modal_button(
|
|
actions,
|
|
StatsCloseButton,
|
|
"Done",
|
|
Some("S"),
|
|
ButtonVariant::Primary,
|
|
font_res,
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Spawn a single stat cell: a large value label on top and a small
|
|
/// descriptor below, inside a fixed-min-width column with a subtle
|
|
/// border. Recoloured to use ui_theme tokens — the prior 6%-alpha-white
|
|
/// fill clashed against the new midnight-purple modal surface.
|
|
fn spawn_stat_cell(parent: &mut ChildSpawnerCommands, value: &str, label: &str) {
|
|
parent
|
|
.spawn((
|
|
StatsCell,
|
|
Node {
|
|
flex_direction: FlexDirection::Column,
|
|
align_items: AlignItems::Center,
|
|
justify_content: JustifyContent::Center,
|
|
min_width: Val::Px(110.0),
|
|
padding: UiRect::all(VAL_SPACE_2),
|
|
border: UiRect::all(Val::Px(1.0)),
|
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
|
..default()
|
|
},
|
|
BorderColor::all(BORDER_SUBTLE),
|
|
))
|
|
.with_children(|cell| {
|
|
// Large value label — accent yellow makes the number sing
|
|
// against the dark card surface.
|
|
cell.spawn((
|
|
Text::new(value.to_string()),
|
|
TextFont {
|
|
font_size: TYPE_HEADLINE,
|
|
..default()
|
|
},
|
|
TextColor(ACCENT_PRIMARY),
|
|
));
|
|
// Small descriptor below the value.
|
|
cell.spawn((
|
|
Text::new(label.to_string()),
|
|
TextFont {
|
|
font_size: TYPE_BODY,
|
|
..default()
|
|
},
|
|
TextColor(TEXT_SECONDARY),
|
|
));
|
|
});
|
|
}
|
|
|
|
/// Format a win-rate value for display.
|
|
///
|
|
/// Returns `"—"` when no games have been played, otherwise `"N%"`.
|
|
pub fn format_win_rate(stats: &StatsSnapshot) -> String {
|
|
match stats.win_rate() {
|
|
None => "\u{2014}".to_string(),
|
|
Some(r) => format!("{}%", (r) as u32),
|
|
}
|
|
}
|
|
|
|
/// Format `fastest_win_seconds` for display.
|
|
///
|
|
/// Returns `"—"` when the value is `u64::MAX` (sentinel for "no wins yet") or
|
|
/// zero. Otherwise delegates to [`format_duration`].
|
|
pub fn format_fastest_win(fastest_win_seconds: u64) -> String {
|
|
if fastest_win_seconds == u64::MAX || fastest_win_seconds == 0 {
|
|
"\u{2014}".to_string()
|
|
} else {
|
|
format_duration(fastest_win_seconds)
|
|
}
|
|
}
|
|
|
|
/// Format `avg_time_seconds` for display.
|
|
///
|
|
/// Returns `"—"` when no games have been won yet (`games_won == 0`), otherwise
|
|
/// delegates to [`format_duration`].
|
|
pub fn format_avg_time(stats: &StatsSnapshot) -> String {
|
|
if stats.games_won == 0 {
|
|
"\u{2014}".to_string()
|
|
} else {
|
|
format_duration(stats.avg_time_seconds)
|
|
}
|
|
}
|
|
|
|
/// Format an optional `u32` statistic.
|
|
///
|
|
/// Returns `"—"` when `value` is `0`, otherwise the decimal representation.
|
|
pub fn format_optional_u32(value: u32) -> String {
|
|
if value == 0 {
|
|
"\u{2014}".to_string()
|
|
} else {
|
|
value.to_string()
|
|
}
|
|
}
|
|
|
|
/// Format any `u32`-like stat value as a decimal string.
|
|
///
|
|
/// Unlike [`format_optional_u32`], this always shows the number (even if zero).
|
|
pub fn format_stat_value<T: std::fmt::Display>(value: T) -> String {
|
|
format!("{value}")
|
|
}
|
|
|
|
/// Returns XP remaining until next level, formatted as "N XP (P%)".
|
|
fn xp_to_next_level_label(total_xp: u64, level: u32) -> String {
|
|
let xp_current = if level < 10 {
|
|
level as u64 * 500
|
|
} else {
|
|
5_000 + (level as u64 - 10) * 1_000
|
|
};
|
|
let xp_next = if level < 10 {
|
|
(level as u64 + 1) * 500
|
|
} else {
|
|
5_000 + (level as u64 - 9) * 1_000
|
|
};
|
|
let span = xp_next - xp_current;
|
|
let done = total_xp.saturating_sub(xp_current).min(span);
|
|
let pct = if span == 0 { 100 } else { done.saturating_mul(100).checked_div(span).unwrap_or(100) };
|
|
let remaining = span - done;
|
|
format!("{remaining} XP ({pct}%)")
|
|
}
|
|
|
|
/// Format a duration given in whole seconds as `"M:SS"`.
|
|
///
|
|
/// Example: `90` → `"1:30"`.
|
|
pub fn format_duration(secs: u64) -> String {
|
|
let m = secs / 60;
|
|
let s = secs % 60;
|
|
format!("{m}:{s:02}")
|
|
}
|
|
|
|
/// Renders a sorted, comma-separated list of unlock indexes for the overlay.
|
|
/// Empty list shows as "None".
|
|
fn format_id_list(ids: &[usize]) -> String {
|
|
if ids.is_empty() {
|
|
return "None".to_string();
|
|
}
|
|
let mut sorted: Vec<usize> = ids.to_vec();
|
|
sorted.sort_unstable();
|
|
sorted.dedup();
|
|
sorted
|
|
.iter()
|
|
.map(|i| format!("#{i}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::game_plugin::GamePlugin;
|
|
use crate::table_plugin::TablePlugin;
|
|
|
|
fn headless_app() -> App {
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(GamePlugin)
|
|
.add_plugins(TablePlugin)
|
|
.add_plugins(StatsPlugin::headless());
|
|
// MinimalPlugins doesn't register keyboard input — add it so the
|
|
// toggle system can read ButtonInput<KeyCode> in tests.
|
|
app.init_resource::<ButtonInput<KeyCode>>();
|
|
// ProgressResource is an optional dependency for the stats screen;
|
|
// include it so toggle tests exercise the progression panel.
|
|
app.add_plugins(crate::progress_plugin::ProgressPlugin::headless());
|
|
app.update();
|
|
app
|
|
}
|
|
|
|
#[test]
|
|
fn stats_resource_exists_after_startup() {
|
|
let app = headless_app();
|
|
assert!(app.world().get_resource::<StatsResource>().is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn headless_plugin_starts_with_default_stats() {
|
|
let app = headless_app();
|
|
let stats = &app.world().resource::<StatsResource>().0;
|
|
assert_eq!(stats, &StatsSnapshot::default());
|
|
}
|
|
|
|
#[test]
|
|
fn win_event_increments_games_won() {
|
|
let mut app = headless_app();
|
|
app.world_mut().write_message(GameWonEvent {
|
|
score: 1000,
|
|
time_seconds: 120,
|
|
});
|
|
app.update();
|
|
|
|
let stats = &app.world().resource::<StatsResource>().0;
|
|
assert_eq!(stats.games_won, 1);
|
|
assert_eq!(stats.games_played, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn draw_three_win_increments_draw_three_wins_only() {
|
|
let mut app = headless_app();
|
|
app.world_mut()
|
|
.resource_mut::<crate::resources::GameStateResource>()
|
|
.0
|
|
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
|
|
|
|
app.world_mut().write_message(GameWonEvent {
|
|
score: 500,
|
|
time_seconds: 200,
|
|
});
|
|
app.update();
|
|
|
|
let stats = &app.world().resource::<StatsResource>().0;
|
|
assert_eq!(stats.draw_three_wins, 1, "draw_three_wins must increment for DrawThree mode");
|
|
assert_eq!(stats.draw_one_wins, 0, "draw_one_wins must not increment for DrawThree mode");
|
|
}
|
|
|
|
#[test]
|
|
fn new_game_after_moves_records_abandoned() {
|
|
let mut app = headless_app();
|
|
|
|
app.world_mut()
|
|
.resource_mut::<crate::resources::GameStateResource>()
|
|
.0
|
|
.move_count = 3;
|
|
|
|
app.world_mut()
|
|
.write_message(NewGameRequestEvent { seed: Some(999), mode: None, confirmed: false });
|
|
app.update();
|
|
|
|
let stats = &app.world().resource::<StatsResource>().0;
|
|
assert_eq!(stats.games_played, 1);
|
|
assert_eq!(stats.games_lost, 1);
|
|
assert_eq!(stats.win_streak_current, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn new_game_without_moves_does_not_record_abandoned() {
|
|
let mut app = headless_app();
|
|
app.world_mut()
|
|
.write_message(NewGameRequestEvent { seed: Some(42), mode: None, confirmed: false });
|
|
app.update();
|
|
|
|
let stats = &app.world().resource::<StatsResource>().0;
|
|
assert_eq!(stats.games_played, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn pressing_s_spawns_stats_screen() {
|
|
let mut app = headless_app();
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&StatsScreen>()
|
|
.iter(app.world())
|
|
.count(),
|
|
0
|
|
);
|
|
|
|
app.world_mut()
|
|
.resource_mut::<ButtonInput<KeyCode>>()
|
|
.press(KeyCode::KeyS);
|
|
app.update();
|
|
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&StatsScreen>()
|
|
.iter(app.world())
|
|
.count(),
|
|
1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pressing_s_twice_closes_stats_screen() {
|
|
let mut app = headless_app();
|
|
|
|
app.world_mut()
|
|
.resource_mut::<ButtonInput<KeyCode>>()
|
|
.press(KeyCode::KeyS);
|
|
app.update();
|
|
|
|
// Release + clear + press: `press()` is a no-op if the key is already
|
|
// in `pressed`, and MinimalPlugins doesn't include bevy_input's
|
|
// per-frame updater to drain `just_pressed`, so we cycle manually.
|
|
{
|
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
input.release(KeyCode::KeyS);
|
|
input.clear();
|
|
input.press(KeyCode::KeyS);
|
|
}
|
|
app.update();
|
|
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&StatsScreen>()
|
|
.iter(app.world())
|
|
.count(),
|
|
0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_id_list_renders_empty_as_none() {
|
|
assert_eq!(format_id_list(&[]), "None");
|
|
}
|
|
|
|
#[test]
|
|
fn format_id_list_sorts_dedups_and_prefixes() {
|
|
assert_eq!(format_id_list(&[3, 1, 1, 2]), "#1, #2, #3");
|
|
}
|
|
|
|
#[test]
|
|
fn xp_to_next_level_label_at_zero_xp() {
|
|
// Level 0, 0 XP: 500 needed, 0% done.
|
|
assert_eq!(xp_to_next_level_label(0, 0), "500 XP (0%)");
|
|
}
|
|
|
|
#[test]
|
|
fn xp_to_next_level_label_halfway_through_level_1() {
|
|
// Level 1 starts at 500 XP, level 2 at 1000 XP.
|
|
// At 750 XP: 250 done of 500, 50%, 250 remaining.
|
|
assert_eq!(xp_to_next_level_label(750, 1), "250 XP (50%)");
|
|
}
|
|
|
|
#[test]
|
|
fn xp_to_next_level_label_at_level_10_boundary() {
|
|
// Level 10 starts at 5000 XP, level 11 at 6000 XP.
|
|
// At 5000 XP: 0 done, 0%, 1000 remaining.
|
|
assert_eq!(xp_to_next_level_label(5_000, 10), "1000 XP (0%)");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// format_duration
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn format_duration_zero_seconds() {
|
|
assert_eq!(format_duration(0), "0:00");
|
|
}
|
|
|
|
#[test]
|
|
fn format_duration_pads_seconds_to_two_digits() {
|
|
assert_eq!(format_duration(65), "1:05");
|
|
}
|
|
|
|
#[test]
|
|
fn format_duration_exactly_one_hour() {
|
|
assert_eq!(format_duration(3600), "60:00");
|
|
}
|
|
|
|
#[test]
|
|
fn format_duration_handles_sub_minute() {
|
|
assert_eq!(format_duration(59), "0:59");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Task #65 — win rate and stat cell pure-function tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn format_win_rate_zero() {
|
|
// 0 wins, 0 played → "—"
|
|
let s = StatsSnapshot::default();
|
|
assert_eq!(format_win_rate(&s), "\u{2014}");
|
|
}
|
|
|
|
#[test]
|
|
fn format_win_rate_half() {
|
|
// 5 wins out of 10 played → "50%"
|
|
let s = StatsSnapshot {
|
|
games_played: 10,
|
|
games_won: 5,
|
|
..StatsSnapshot::default()
|
|
};
|
|
assert_eq!(format_win_rate(&s), "50%");
|
|
}
|
|
|
|
#[test]
|
|
fn format_stat_value_zero_returns_zero() {
|
|
assert_eq!(format_stat_value(0u32), "0");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Task #66 — fastest win, best score, streak pure-function tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn format_fastest_win_unset() {
|
|
// fastest_win_seconds == u64::MAX → "—"
|
|
assert_eq!(format_fastest_win(u64::MAX), "\u{2014}");
|
|
}
|
|
|
|
#[test]
|
|
fn format_fastest_win_90s() {
|
|
// 90 seconds → "1:30"
|
|
assert_eq!(format_fastest_win(90), "1:30");
|
|
}
|
|
|
|
#[test]
|
|
fn best_score_display_zero() {
|
|
// best_single_score == 0 → "—"
|
|
assert_eq!(format_optional_u32(0), "\u{2014}");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Task #38 — avg time pure-function tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn format_avg_time_no_wins_shows_dash() {
|
|
// games_won == 0 → "—"
|
|
let s = StatsSnapshot::default();
|
|
assert_eq!(format_avg_time(&s), "\u{2014}");
|
|
}
|
|
|
|
#[test]
|
|
fn format_avg_time_after_single_win() {
|
|
// After one win of 90 s avg should be "1:30"
|
|
let s = StatsSnapshot {
|
|
games_won: 1,
|
|
avg_time_seconds: 90,
|
|
..StatsSnapshot::default()
|
|
};
|
|
assert_eq!(format_avg_time(&s), "1:30");
|
|
}
|
|
|
|
#[test]
|
|
fn format_avg_time_after_multiple_wins() {
|
|
// avg_time_seconds = 200 s → "3:20"
|
|
let s = StatsSnapshot {
|
|
games_won: 3,
|
|
avg_time_seconds: 200,
|
|
..StatsSnapshot::default()
|
|
};
|
|
assert_eq!(format_avg_time(&s), "3:20");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Task #49 — streak-broken toast on forfeit
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn forfeit_with_streak_fires_streak_broken_toast() {
|
|
let mut app = headless_app();
|
|
|
|
// Set up a streak of 3 and at least one move so forfeit counts.
|
|
{
|
|
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
|
stats.0.win_streak_current = 3;
|
|
}
|
|
app.world_mut()
|
|
.resource_mut::<crate::resources::GameStateResource>()
|
|
.0
|
|
.move_count = 1;
|
|
|
|
app.world_mut().write_message(ForfeitEvent);
|
|
app.update();
|
|
|
|
let events = app.world().resource::<Messages<InfoToastEvent>>();
|
|
let mut reader = events.get_cursor();
|
|
let messages: Vec<&str> = reader
|
|
.read(events)
|
|
.map(|e| e.0.as_str())
|
|
.collect();
|
|
|
|
assert!(
|
|
messages.contains(&"Streak of 3 broken!"),
|
|
"expected 'Streak of 3 broken!' in toasts, got: {messages:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn forfeit_with_streak_of_one_does_not_fire_streak_broken_toast() {
|
|
let mut app = headless_app();
|
|
|
|
{
|
|
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
|
stats.0.win_streak_current = 1;
|
|
}
|
|
app.world_mut()
|
|
.resource_mut::<crate::resources::GameStateResource>()
|
|
.0
|
|
.move_count = 1;
|
|
|
|
app.world_mut().write_message(ForfeitEvent);
|
|
app.update();
|
|
|
|
let events = app.world().resource::<Messages<InfoToastEvent>>();
|
|
let mut reader = events.get_cursor();
|
|
let messages: Vec<&str> = reader
|
|
.read(events)
|
|
.map(|e| e.0.as_str())
|
|
.collect();
|
|
|
|
assert!(
|
|
!messages.iter().any(|m| m.contains("broken")),
|
|
"expected no streak-broken toast for streak of 1, got: {messages:?}"
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Streak-milestone flourish — pure helper + event-firing tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Pure helper: every threshold in `STREAK_MILESTONES` (3, 5, 10) must
|
|
/// fire when the streak crosses it from below.
|
|
#[test]
|
|
fn streak_milestone_helper_fires_at_each_threshold() {
|
|
for &threshold in STREAK_MILESTONES {
|
|
assert_eq!(
|
|
streak_milestone_crossed(threshold - 1, threshold),
|
|
Some(threshold),
|
|
"expected milestone {threshold} to fire when crossed from below",
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Pure helper: rising past 10 to 11, 12, … must NOT fire — the
|
|
/// flourish is a threshold-crossing event, not a "every win past 10"
|
|
/// event.
|
|
#[test]
|
|
fn streak_milestone_helper_does_not_fire_past_highest() {
|
|
// prev=10 → new=11: above the highest threshold, no crossing.
|
|
assert_eq!(streak_milestone_crossed(10, 11), None);
|
|
// prev=15 → new=16: well past every threshold, no crossing.
|
|
assert_eq!(streak_milestone_crossed(15, 16), None);
|
|
// prev=2 → new=2: no change → no crossing.
|
|
assert_eq!(streak_milestone_crossed(2, 2), None);
|
|
}
|
|
|
|
/// Pure helper: rising 1 → 2 stays below the lowest threshold (3),
|
|
/// must NOT fire.
|
|
#[test]
|
|
fn streak_milestone_helper_does_not_fire_below_threshold() {
|
|
assert_eq!(streak_milestone_crossed(1, 2), None);
|
|
assert_eq!(streak_milestone_crossed(0, 1), None);
|
|
}
|
|
|
|
/// Integration: pre-set streak to 2, fire a win that bumps it to 3,
|
|
/// assert exactly one `WinStreakMilestoneEvent { streak: 3 }` is
|
|
/// written by the win handler.
|
|
#[test]
|
|
fn streak_milestone_event_fires_at_threshold_crossing() {
|
|
let mut app = headless_app();
|
|
{
|
|
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
|
stats.0.win_streak_current = 2;
|
|
}
|
|
app.world_mut().write_message(GameWonEvent {
|
|
score: 500,
|
|
time_seconds: 90,
|
|
});
|
|
app.update();
|
|
|
|
let events = app.world().resource::<Messages<WinStreakMilestoneEvent>>();
|
|
let mut reader = events.get_cursor();
|
|
let collected: Vec<u32> = reader.read(events).map(|e| e.streak).collect();
|
|
|
|
assert_eq!(
|
|
collected,
|
|
vec![3],
|
|
"expected one WinStreakMilestoneEvent {{ streak: 3 }} after crossing 2 → 3",
|
|
);
|
|
}
|
|
|
|
/// Integration: pre-set streak to 1, fire a win that bumps it to 2 —
|
|
/// no threshold is crossed, no event must be fired.
|
|
#[test]
|
|
fn streak_milestone_event_does_not_fire_at_non_threshold() {
|
|
let mut app = headless_app();
|
|
{
|
|
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
|
stats.0.win_streak_current = 1;
|
|
}
|
|
app.world_mut().write_message(GameWonEvent {
|
|
score: 500,
|
|
time_seconds: 90,
|
|
});
|
|
app.update();
|
|
|
|
let events = app.world().resource::<Messages<WinStreakMilestoneEvent>>();
|
|
let mut reader = events.get_cursor();
|
|
let collected: Vec<u32> = reader.read(events).map(|e| e.streak).collect();
|
|
|
|
assert!(
|
|
collected.is_empty(),
|
|
"expected no WinStreakMilestoneEvent for non-threshold streak crossing 1 → 2, got {collected:?}",
|
|
);
|
|
}
|
|
|
|
/// Integration: pre-set streak to 10, fire a win that bumps it to 11.
|
|
/// Past the highest threshold, no event must fire — the flourish
|
|
/// is reserved for the threshold crossing itself.
|
|
#[test]
|
|
fn streak_milestone_event_does_not_fire_past_10() {
|
|
let mut app = headless_app();
|
|
{
|
|
let mut stats = app.world_mut().resource_mut::<StatsResource>();
|
|
stats.0.win_streak_current = 10;
|
|
}
|
|
app.world_mut().write_message(GameWonEvent {
|
|
score: 500,
|
|
time_seconds: 90,
|
|
});
|
|
app.update();
|
|
|
|
let events = app.world().resource::<Messages<WinStreakMilestoneEvent>>();
|
|
let mut reader = events.get_cursor();
|
|
let collected: Vec<u32> = reader.read(events).map(|e| e.streak).collect();
|
|
|
|
assert!(
|
|
collected.is_empty(),
|
|
"expected no WinStreakMilestoneEvent past the highest threshold, got {collected:?}",
|
|
);
|
|
}
|
|
}
|