Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -8,12 +8,12 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use solitaire_data::{
|
||||
load_replay_history_from, load_stats_from, replay_history_path, save_stats_to,
|
||||
stats_file_path, PlayerProgress, Replay, ReplayHistory, StatsExt, StatsSnapshot, WEEKLY_GOALS,
|
||||
PlayerProgress, Replay, ReplayHistory, StatsExt, StatsSnapshot, WEEKLY_GOALS,
|
||||
load_replay_history_from, load_stats_from, replay_history_path, save_stats_to, stats_file_path,
|
||||
};
|
||||
|
||||
use crate::auto_complete_plugin::AutoCompleteState;
|
||||
@@ -22,21 +22,20 @@ use crate::events::{
|
||||
ForfeitEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, ToggleStatsRequestEvent,
|
||||
WinStreakMilestoneEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::platform::ClipboardBackendResource;
|
||||
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,
|
||||
ModalButton, ScrimDismissible,
|
||||
ButtonVariant, ModalButton, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||
spawn_modal_button, spawn_modal_header,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
|
||||
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
||||
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
||||
Z_MODAL_PANEL,
|
||||
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||
};
|
||||
|
||||
/// Bevy resource wrapping the current stats.
|
||||
@@ -211,10 +210,7 @@ impl Plugin for StatsPlugin {
|
||||
// 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_new_game.before(GameMutation))
|
||||
.add_systems(
|
||||
Update,
|
||||
update_stats_on_win.after(GameMutation).in_set(StatsUpdate),
|
||||
@@ -231,10 +227,7 @@ impl Plugin for StatsPlugin {
|
||||
)
|
||||
.add_systems(Update, toggle_stats_screen.after(GameMutation))
|
||||
.add_systems(Update, handle_stats_close_button)
|
||||
.add_systems(
|
||||
Update,
|
||||
refresh_replay_history_on_win.after(GameMutation),
|
||||
)
|
||||
.add_systems(Update, refresh_replay_history_on_win.after(GameMutation))
|
||||
.add_systems(Update, handle_watch_replay_button)
|
||||
.add_systems(Update, handle_copy_share_link_button)
|
||||
.add_systems(
|
||||
@@ -247,7 +240,10 @@ impl Plugin for StatsPlugin {
|
||||
.chain(),
|
||||
)
|
||||
.add_systems(Update, scroll_stats_panel)
|
||||
.add_systems(Update, crate::ui_modal::touch_scroll_panel::<StatsScrollable>);
|
||||
.add_systems(
|
||||
Update,
|
||||
crate::ui_modal::touch_scroll_panel::<StatsScrollable>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,9 +285,11 @@ fn refresh_replay_history_on_win(
|
||||
path: Res<LatestReplayPath>,
|
||||
) {
|
||||
// Only re-load when at least one win actually fired.
|
||||
if wins.read().next().is_none() {
|
||||
let mut win_events = wins.read();
|
||||
if win_events.next().is_none() {
|
||||
return;
|
||||
}
|
||||
win_events.for_each(|_| {});
|
||||
let Some(p) = path.0.as_deref() else {
|
||||
return;
|
||||
};
|
||||
@@ -415,7 +413,11 @@ fn handle_replay_selector_buttons(
|
||||
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 };
|
||||
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
|
||||
@@ -523,31 +525,33 @@ fn update_stats_on_win(
|
||||
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);
|
||||
// Per-mode best score / fastest win — additive on top of the
|
||||
// lifetime totals tracked by `update_on_win`. TimeAttack is a
|
||||
// no-op inside the helper because it has its own session-level
|
||||
// scoring model.
|
||||
stats
|
||||
.0
|
||||
.update_per_mode_bests(ev.score, ev.time_seconds, game.0.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");
|
||||
let mut win_events = events.read();
|
||||
let Some(ev) = win_events.next() else {
|
||||
return;
|
||||
};
|
||||
win_events.for_each(|_| {});
|
||||
|
||||
let prev_streak = stats.0.win_streak_current;
|
||||
stats
|
||||
.0
|
||||
.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode);
|
||||
// Per-mode best score / fastest win — additive on top of the
|
||||
// lifetime totals tracked by `update_on_win`. TimeAttack is a
|
||||
// no-op inside the helper because it has its own session-level
|
||||
// scoring model.
|
||||
stats
|
||||
.0
|
||||
.update_per_mode_bests(ev.score, ev.time_seconds, game.0.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.
|
||||
@@ -695,14 +699,46 @@ fn spawn_stats_screen(
|
||||
// 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 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 {
|
||||
@@ -771,13 +807,13 @@ fn spawn_stats_screen(
|
||||
..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, &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");
|
||||
});
|
||||
|
||||
@@ -829,10 +865,10 @@ fn spawn_stats_screen(
|
||||
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 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);
|
||||
|
||||
body.spawn(Node {
|
||||
@@ -846,10 +882,10 @@ fn spawn_stats_screen(
|
||||
..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, &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");
|
||||
});
|
||||
|
||||
@@ -882,18 +918,19 @@ fn spawn_stats_screen(
|
||||
|
||||
// --- 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;
|
||||
body.spawn((
|
||||
Text::new(format!(
|
||||
"Time Attack \u{2014} {mins}m {secs:02}s left | Wins: {}",
|
||||
ta.wins
|
||||
)),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_WARNING),
|
||||
));
|
||||
}
|
||||
&& ta.active
|
||||
{
|
||||
let mins = (ta.remaining_secs / 60.0).floor() as u64;
|
||||
let secs = (ta.remaining_secs % 60.0).floor() as u64;
|
||||
body.spawn((
|
||||
Text::new(format!(
|
||||
"Time Attack \u{2014} {mins}m {secs:02}s left | Wins: {}",
|
||||
ta.wins
|
||||
)),
|
||||
font_section.clone(),
|
||||
TextColor(STATE_WARNING),
|
||||
));
|
||||
}
|
||||
|
||||
// --- Replay selector ---
|
||||
// Prev / Next chips step through the full replay history;
|
||||
@@ -1197,7 +1234,11 @@ fn xp_to_next_level_label(total_xp: u64, level: u32) -> String {
|
||||
};
|
||||
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 pct = if span == 0 {
|
||||
100
|
||||
} else {
|
||||
done.saturating_mul(100).checked_div(span).unwrap_or(100)
|
||||
};
|
||||
let remaining = span - done;
|
||||
format!("{remaining} XP ({pct}%)")
|
||||
}
|
||||
@@ -1291,8 +1332,34 @@ mod tests {
|
||||
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");
|
||||
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 multiple_win_events_in_one_frame_increment_once() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 1000,
|
||||
time_seconds: 120,
|
||||
});
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 1500,
|
||||
time_seconds: 90,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let stats = &app.world().resource::<StatsResource>().0;
|
||||
assert_eq!(stats.games_won, 1);
|
||||
assert_eq!(stats.games_played, 1);
|
||||
assert_eq!(stats.best_single_score, 1000);
|
||||
assert_eq!(stats.fastest_win_seconds, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1304,8 +1371,11 @@ mod tests {
|
||||
.0
|
||||
.move_count = 3;
|
||||
|
||||
app.world_mut()
|
||||
.write_message(NewGameRequestEvent { seed: Some(999), mode: None, confirmed: false });
|
||||
app.world_mut().write_message(NewGameRequestEvent {
|
||||
seed: Some(999),
|
||||
mode: None,
|
||||
confirmed: false,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let stats = &app.world().resource::<StatsResource>().0;
|
||||
@@ -1317,8 +1387,11 @@ mod tests {
|
||||
#[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.world_mut().write_message(NewGameRequestEvent {
|
||||
seed: Some(42),
|
||||
mode: None,
|
||||
confirmed: false,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let stats = &app.world().resource::<StatsResource>().0;
|
||||
@@ -1629,10 +1702,7 @@ mod tests {
|
||||
|
||||
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();
|
||||
let messages: Vec<&str> = reader.read(events).map(|e| e.0.as_str()).collect();
|
||||
|
||||
assert!(
|
||||
messages.contains(&"Streak of 3 broken!"),
|
||||
@@ -1658,10 +1728,7 @@ mod tests {
|
||||
|
||||
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();
|
||||
let messages: Vec<&str> = reader.read(events).map(|e| e.0.as_str()).collect();
|
||||
|
||||
assert!(
|
||||
!messages.iter().any(|m| m.contains("broken")),
|
||||
@@ -1820,8 +1887,7 @@ mod tests {
|
||||
let texts: Vec<String> = q.iter(app.world()).map(|t| t.0.clone()).collect();
|
||||
assert_eq!(texts.len(), 1);
|
||||
assert_eq!(
|
||||
texts[0],
|
||||
"Replay 1 / 1",
|
||||
texts[0], "Replay 1 / 1",
|
||||
"caption must show '1 / 1' for a single-replay history"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user