fbe48acef6
Third commit on the B-2 replay screen-takeover redesign. Adds the
ability to pause an in-flight replay, step through it one move at
a time while paused, and resume — both via on-screen buttons
(UI-first contract per CLAUDE.md §3.3) and the optional `Space`
keyboard accelerator.
State shape: a new `paused: bool` field on
`ReplayPlaybackState::Playing`. The `tick_replay_playback` system
skips the `secs_to_next` decrement entirely while `paused` is set
so cursor and timer freeze together — resuming starts the next
move from a full interval. Stepping fires the next move directly
via a new `step_replay_playback` API that bypasses the tick path
and is hard-gated to `Playing { paused: true }` so it can't race
the running tick loop.
Public API additions:
- `toggle_pause_replay_playback(state)` — flips the flag, returns
the new value (or None when not Playing).
- `step_replay_playback(state, moves_writer, draws_writer)` —
advances exactly one move when paused; returns true on dispatch,
false on any guard miss.
UI:
- Pause / Resume button next to Stop. Label repaints reactively
via `update_pause_button_label`, which walks `Children` from
the marked button to its inner `Text` so the spawn path doesn't
need a second marker.
- Step button next to Pause. Click fires the next move; while
unpaused the click is a no-op (guarded inside
`step_replay_playback`).
- `Space` keyboard handler reads `Option<Res<ButtonInput>>` and
no-ops when missing — keeps test-app compatibility under
`MinimalPlugins`.
Test coverage: pause-button label truth table, label repaint on
state change, click-toggles-paused, step advances cursor exactly
one with paused flag preserved, step-while-running is no-op,
Space toggles paused flag. 8 new tests (1220 → 1228).
Side-effect: 25 existing `Playing { ... }` construction sites
across `replay_overlay`, `achievement_plugin`, and
`replay_playback` tests gained `paused: false` to satisfy the new
field requirement. Mechanical edit; no behavioral change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1613 lines
59 KiB
Rust
1613 lines
59 KiB
Rust
//! Evaluates achievements on `GameWonEvent`, persists unlocks, and fires
|
|
//! `AchievementUnlockedEvent` for each newly unlocked achievement.
|
|
//!
|
|
//! The persistence path is configurable via `AchievementPlugin::storage_path`.
|
|
//! `AchievementPlugin::default()` uses the platform data dir;
|
|
//! `AchievementPlugin::headless()` disables I/O entirely (for tests).
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
|
use bevy::prelude::*;
|
|
use chrono::{Local, Timelike, Utc};
|
|
use solitaire_core::achievement::{
|
|
achievement_by_id, check_achievements, AchievementContext, AchievementDef, Reward,
|
|
ALL_ACHIEVEMENTS,
|
|
};
|
|
use solitaire_data::{
|
|
achievements_file_path, load_achievements_from, save_achievements_to, save_settings_to,
|
|
AchievementRecord, save_progress_to,
|
|
};
|
|
|
|
use crate::events::{
|
|
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, ToggleAchievementsRequestEvent,
|
|
XpAwardedEvent,
|
|
};
|
|
use crate::font_plugin::FontResource;
|
|
use crate::game_plugin::GameMutation;
|
|
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
|
|
use crate::replay_playback::ReplayPlaybackState;
|
|
use crate::resources::GameStateResource;
|
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
|
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
|
use crate::ui_modal::{
|
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
|
ScrimDismissible,
|
|
};
|
|
use crate::ui_theme::{
|
|
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
|
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, Z_MODAL_PANEL,
|
|
};
|
|
use crate::ui_tooltip::Tooltip;
|
|
|
|
/// Marker on the achievements overlay root node.
|
|
#[derive(Component, Debug)]
|
|
pub struct AchievementsScreen;
|
|
|
|
/// Marker on each per-achievement row inside the Achievements modal. Used by
|
|
/// hover-tooltip plumbing and tests so a row can be identified independently
|
|
/// of its visible text.
|
|
#[derive(Component, Debug)]
|
|
pub struct AchievementRow;
|
|
|
|
/// Marker on the scrollable body Node inside the Achievements modal.
|
|
///
|
|
/// The Achievements list can grow to ~19 rows which overflows the modal at
|
|
/// the 800x600 minimum window. This marker tags the inner container that
|
|
/// carries `Overflow::scroll_y()` plus a `max_height` constraint so the
|
|
/// content scrolls instead of clipping. Mirrors the
|
|
/// `SettingsPanelScrollable` pattern in `settings_plugin`.
|
|
///
|
|
/// `scroll_achievements_panel` reads this marker to route mouse-wheel
|
|
/// events into the body's `ScrollPosition`.
|
|
#[derive(Component, Debug)]
|
|
pub struct AchievementsScrollable;
|
|
|
|
/// All per-player achievement records (one per known achievement).
|
|
#[derive(Resource, Debug, Clone)]
|
|
pub struct AchievementsResource(pub Vec<AchievementRecord>);
|
|
|
|
/// Persistence path for `AchievementsResource`. `None` disables I/O.
|
|
#[derive(Resource, Debug, Clone)]
|
|
pub struct AchievementsStoragePath(pub Option<PathBuf>);
|
|
|
|
pub struct AchievementPlugin {
|
|
pub storage_path: Option<PathBuf>,
|
|
}
|
|
|
|
impl Default for AchievementPlugin {
|
|
fn default() -> Self {
|
|
Self {
|
|
storage_path: achievements_file_path(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AchievementPlugin {
|
|
/// Plugin configured with no persistence.
|
|
pub fn headless() -> Self {
|
|
Self { storage_path: None }
|
|
}
|
|
}
|
|
|
|
impl Plugin for AchievementPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
let mut records = match &self.storage_path {
|
|
Some(path) => load_achievements_from(path),
|
|
None => Vec::new(),
|
|
};
|
|
// Ensure every known achievement has a record. Keeps file forward-compatible
|
|
// when new achievements are added in future releases.
|
|
for def in ALL_ACHIEVEMENTS {
|
|
if !records.iter().any(|r| r.id == def.id) {
|
|
records.push(AchievementRecord::locked(def.id));
|
|
}
|
|
}
|
|
|
|
app.insert_resource(AchievementsResource(records))
|
|
.insert_resource(AchievementsStoragePath(self.storage_path.clone()))
|
|
.add_message::<AchievementUnlockedEvent>()
|
|
.add_message::<GameWonEvent>()
|
|
.add_message::<XpAwardedEvent>()
|
|
.add_message::<InfoToastEvent>()
|
|
.add_message::<ToggleAchievementsRequestEvent>()
|
|
// `MouseWheel` is emitted by Bevy's input plugin under
|
|
// `DefaultPlugins`; register it explicitly so the
|
|
// achievements-scroll system also runs cleanly under
|
|
// `MinimalPlugins` in tests.
|
|
.add_message::<MouseWheel>()
|
|
// Run after GameMutation (so GameWonEvent is available), after
|
|
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
|
// (so daily_challenge_streak is up to date for daily_devotee).
|
|
.add_systems(
|
|
Update,
|
|
evaluate_on_win
|
|
.after(GameMutation)
|
|
.after(StatsUpdate)
|
|
.after(ProgressUpdate),
|
|
)
|
|
// Achievement-onboarding cue: fires once after the player's very
|
|
// first win to teach the Achievements panel exists. Must run
|
|
// `.after(StatsUpdate)` so `stats.games_won` reflects the win
|
|
// that just landed (StatsUpdate increments it on `GameWonEvent`).
|
|
.add_systems(
|
|
Update,
|
|
fire_achievement_onboarding_toast
|
|
.after(GameMutation)
|
|
.after(StatsUpdate),
|
|
)
|
|
.add_systems(Update, toggle_achievements_screen)
|
|
.add_systems(Update, handle_achievements_close_button)
|
|
.add_systems(Update, scroll_achievements_panel)
|
|
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
|
|
// `cinephile` the first time playback runs to natural completion.
|
|
// Reads the resource via `Option<Res<_>>` so headless tests that
|
|
// omit `ReplayPlaybackPlugin` still build.
|
|
.add_systems(Update, evaluate_cinephile_on_replay_completion);
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn evaluate_on_win(
|
|
mut wins: MessageReader<GameWonEvent>,
|
|
mut unlocks: MessageWriter<AchievementUnlockedEvent>,
|
|
mut levelups: MessageWriter<LevelUpEvent>,
|
|
mut xp_awarded: MessageWriter<XpAwardedEvent>,
|
|
game: Res<GameStateResource>,
|
|
stats: Res<StatsResource>,
|
|
path: Res<AchievementsStoragePath>,
|
|
progress_path: Res<ProgressStoragePath>,
|
|
mut achievements: ResMut<AchievementsResource>,
|
|
mut progress: ResMut<ProgressResource>,
|
|
) {
|
|
let Some(ev) = wins.read().last() else {
|
|
return;
|
|
};
|
|
|
|
let ctx = AchievementContext {
|
|
games_played: stats.0.games_played,
|
|
games_won: stats.0.games_won,
|
|
win_streak_current: stats.0.win_streak_current,
|
|
best_single_score: stats.0.best_single_score,
|
|
lifetime_score: stats.0.lifetime_score,
|
|
draw_three_wins: stats.0.draw_three_wins,
|
|
daily_challenge_streak: progress.0.daily_challenge_streak,
|
|
last_win_score: ev.score,
|
|
last_win_time_seconds: ev.time_seconds,
|
|
last_win_used_undo: game.0.undo_count > 0,
|
|
wall_clock_hour: Some(Local::now().hour()),
|
|
last_win_recycle_count: game.0.recycle_count,
|
|
last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen,
|
|
};
|
|
|
|
let hits = check_achievements(&ctx);
|
|
if hits.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let now = Utc::now();
|
|
let mut achievements_changed = false;
|
|
let mut progress_changed = false;
|
|
|
|
for def in hits {
|
|
let Some(record) = achievements.0.iter_mut().find(|r| r.id == def.id) else {
|
|
continue;
|
|
};
|
|
if record.unlocked {
|
|
continue;
|
|
}
|
|
record.unlock(now);
|
|
achievements_changed = true;
|
|
|
|
// Grant the reward on first unlock.
|
|
if !record.reward_granted {
|
|
if let Some(reward) = def.reward {
|
|
match reward {
|
|
Reward::CardBack(idx) => {
|
|
if !progress.0.unlocked_card_backs.contains(&idx) {
|
|
progress.0.unlocked_card_backs.push(idx);
|
|
progress_changed = true;
|
|
}
|
|
}
|
|
Reward::Background(idx) => {
|
|
if !progress.0.unlocked_backgrounds.contains(&idx) {
|
|
progress.0.unlocked_backgrounds.push(idx);
|
|
progress_changed = true;
|
|
}
|
|
}
|
|
Reward::BonusXp(amount) => {
|
|
xp_awarded.write(XpAwardedEvent { amount });
|
|
let prev_level = progress.0.add_xp(amount);
|
|
if progress.0.leveled_up_from(prev_level) {
|
|
levelups.write(LevelUpEvent {
|
|
previous_level: prev_level,
|
|
new_level: progress.0.level,
|
|
total_xp: progress.0.total_xp,
|
|
});
|
|
}
|
|
progress_changed = true;
|
|
}
|
|
Reward::Badge => {}
|
|
}
|
|
}
|
|
record.reward_granted = true;
|
|
}
|
|
|
|
unlocks.write(AchievementUnlockedEvent(record.clone()));
|
|
}
|
|
|
|
if achievements_changed
|
|
&& let Some(target) = &path.0
|
|
&& let Err(e) = save_achievements_to(target, &achievements.0) {
|
|
warn!("failed to save achievements: {e}");
|
|
}
|
|
|
|
if progress_changed
|
|
&& let Some(target) = &progress_path.0
|
|
&& let Err(e) = save_progress_to(target, &progress.0) {
|
|
warn!("failed to save progress after reward: {e}");
|
|
}
|
|
}
|
|
|
|
/// Cinephile unlock observer.
|
|
///
|
|
/// Watches [`ReplayPlaybackState`] and unlocks the `cinephile` achievement
|
|
/// the first time the resource transitions from `Playing` to `Completed` —
|
|
/// i.e. the player watched a saved replay all the way through. The Stop
|
|
/// button transitions `Playing` → `Inactive` directly (never via
|
|
/// `Completed`), so manual aborts do not trigger the unlock.
|
|
///
|
|
/// Idempotent: once the record is unlocked, subsequent Playing → Completed
|
|
/// transitions are a no-op (no extra `AchievementUnlockedEvent`, no extra
|
|
/// disk write). The transition itself is debounced by tracking the
|
|
/// previous frame's `is_playing()` state in a `Local<bool>` — without
|
|
/// this, a freshly-spawned `Completed` state would re-fire each frame
|
|
/// during the linger window.
|
|
///
|
|
/// Reads `ReplayPlaybackState` via `Option<Res<_>>` so achievement tests
|
|
/// that omit `ReplayPlaybackPlugin` still build cleanly.
|
|
fn evaluate_cinephile_on_replay_completion(
|
|
state: Option<Res<ReplayPlaybackState>>,
|
|
// `Local` collides with `chrono::Local` imported at the top of this
|
|
// module — fully qualify so the Bevy system parameter resolves
|
|
// correctly.
|
|
mut last_was_playing: bevy::prelude::Local<bool>,
|
|
mut achievements: ResMut<AchievementsResource>,
|
|
mut unlocks: MessageWriter<AchievementUnlockedEvent>,
|
|
path: Res<AchievementsStoragePath>,
|
|
) {
|
|
let Some(state) = state else {
|
|
return;
|
|
};
|
|
|
|
// Detect the Playing → Completed transition: was playing last frame,
|
|
// is now completed. Direct Playing → Inactive (Stop button) does not
|
|
// satisfy this guard because it never enters `Completed`.
|
|
let now_playing = state.is_playing();
|
|
let now_completed = state.is_completed();
|
|
let just_completed = *last_was_playing && now_completed;
|
|
*last_was_playing = now_playing;
|
|
|
|
if !just_completed {
|
|
return;
|
|
}
|
|
|
|
let Some(record) = achievements.0.iter_mut().find(|r| r.id == "cinephile") else {
|
|
return;
|
|
};
|
|
if record.unlocked {
|
|
return;
|
|
}
|
|
record.unlock(Utc::now());
|
|
record.reward_granted = true;
|
|
unlocks.write(AchievementUnlockedEvent(record.clone()));
|
|
|
|
if let Some(target) = &path.0
|
|
&& let Err(e) = save_achievements_to(target, &achievements.0)
|
|
{
|
|
warn!("failed to save achievements after cinephile unlock: {e}");
|
|
}
|
|
}
|
|
|
|
/// Achievement-onboarding cue.
|
|
///
|
|
/// On the player's very first win — and only their first — fires a single
|
|
/// `InfoToastEvent` nudging them toward the Achievements panel (`A` hotkey)
|
|
/// so they discover the progression layer.
|
|
///
|
|
/// Three guards prevent spurious or repeat firings:
|
|
///
|
|
/// * `stats.games_won == 1` — the post-condition is checked **after**
|
|
/// `StatsUpdate` increments `games_won`, so the cue only fires for the
|
|
/// true first win, not (for example) a player who imported existing
|
|
/// sync data and won a later game.
|
|
/// * `!settings.shown_achievement_onboarding` — flips to `true` after
|
|
/// the toast fires, persists to `settings.json`, and serves as the
|
|
/// one-shot guard across launches and merged sync.
|
|
/// * The system bails immediately when no `GameWonEvent` arrived this
|
|
/// frame so it is a no-op outside the post-win frame.
|
|
///
|
|
/// The `A` hotkey is mentioned verbatim in the toast text so players who
|
|
/// dismiss the cue still know where to find the panel.
|
|
fn fire_achievement_onboarding_toast(
|
|
mut wins: MessageReader<GameWonEvent>,
|
|
stats: Res<StatsResource>,
|
|
mut settings: Option<ResMut<SettingsResource>>,
|
|
settings_path: Option<Res<SettingsStoragePath>>,
|
|
mut toast: MessageWriter<InfoToastEvent>,
|
|
) {
|
|
// Drain the event queue regardless — multiple wins on a single frame
|
|
// only need a single onboarding toast at most.
|
|
let any_win = wins.read().last().is_some();
|
|
if !any_win {
|
|
return;
|
|
}
|
|
|
|
// Without a `SettingsResource` (headless tests that omit `SettingsPlugin`)
|
|
// we have no flag to consult; bail out cleanly.
|
|
let Some(settings) = settings.as_mut() else {
|
|
return;
|
|
};
|
|
if settings.0.shown_achievement_onboarding {
|
|
return;
|
|
}
|
|
if stats.0.games_won != 1 {
|
|
return;
|
|
}
|
|
|
|
toast.write(InfoToastEvent(
|
|
"First win! Press A to see your achievements.".to_string(),
|
|
));
|
|
settings.0.shown_achievement_onboarding = true;
|
|
|
|
// Persist so the cue stays one-shot across launches. `None` storage
|
|
// (headless / test) is a documented no-op.
|
|
if let Some(path) = settings_path.as_ref()
|
|
&& let Some(target) = path.0.as_deref()
|
|
&& let Err(e) = save_settings_to(target, &settings.0)
|
|
{
|
|
warn!("failed to save settings (achievement onboarding): {e}");
|
|
}
|
|
}
|
|
|
|
/// Convenience: resolve an achievement ID to its human-readable name.
|
|
/// Used by the toast renderer in `animation_plugin`.
|
|
pub fn display_name_for(id: &str) -> String {
|
|
achievement_by_id(id).map_or_else(|| id.to_string(), |d| d.name.to_string())
|
|
}
|
|
|
|
/// Marker on the "Done" button inside the Achievements modal.
|
|
#[derive(Component, Debug)]
|
|
pub struct AchievementsCloseButton;
|
|
|
|
/// Toggle the achievements overlay — `A` keyboard accelerator or
|
|
/// `ToggleAchievementsRequestEvent` from the HUD Menu popover.
|
|
fn toggle_achievements_screen(
|
|
mut commands: Commands,
|
|
keys: Res<ButtonInput<KeyCode>>,
|
|
mut requests: MessageReader<ToggleAchievementsRequestEvent>,
|
|
achievements: Res<AchievementsResource>,
|
|
font_res: Option<Res<FontResource>>,
|
|
screens: Query<Entity, With<AchievementsScreen>>,
|
|
) {
|
|
let button_clicked = requests.read().count() > 0;
|
|
if !keys.just_pressed(KeyCode::KeyA) && !button_clicked {
|
|
return;
|
|
}
|
|
if let Ok(entity) = screens.single() {
|
|
commands.entity(entity).despawn();
|
|
} else {
|
|
spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref());
|
|
}
|
|
}
|
|
|
|
/// Click handler for the modal's "Done" button — despawns the overlay
|
|
/// the same way the `A` accelerator does.
|
|
fn handle_achievements_close_button(
|
|
mut commands: Commands,
|
|
close_buttons: Query<&Interaction, (With<AchievementsCloseButton>, Changed<Interaction>)>,
|
|
screens: Query<Entity, With<AchievementsScreen>>,
|
|
) {
|
|
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
|
return;
|
|
}
|
|
for entity in &screens {
|
|
commands.entity(entity).despawn();
|
|
}
|
|
}
|
|
|
|
/// Routes mouse-wheel events into the Achievements modal's scrollable body
|
|
/// while the panel is open.
|
|
///
|
|
/// `offset_y` increases downward (0 = top). Scrolling down (`ev.y < 0`) adds
|
|
/// to the offset; scrolling up subtracts. Clamped to >= 0 so the viewport
|
|
/// never scrolls past the top. Mirrors `scroll_settings_panel` in
|
|
/// `settings_plugin`. The query is empty when no `AchievementsScrollable`
|
|
/// is in the world (modal closed) so this is a no-op outside the open
|
|
/// state without an explicit gate resource.
|
|
fn scroll_achievements_panel(
|
|
mut scroll_evr: MessageReader<MouseWheel>,
|
|
mut scrollables: Query<&mut ScrollPosition, With<AchievementsScrollable>>,
|
|
) {
|
|
if scrollables.is_empty() {
|
|
scroll_evr.clear();
|
|
return;
|
|
}
|
|
let delta_y: f32 = scroll_evr
|
|
.read()
|
|
.map(|ev| match ev.unit {
|
|
MouseScrollUnit::Line => ev.y * 50.0,
|
|
MouseScrollUnit::Pixel => ev.y,
|
|
})
|
|
.sum();
|
|
if delta_y == 0.0 {
|
|
return;
|
|
}
|
|
for mut sp in scrollables.iter_mut() {
|
|
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
|
}
|
|
}
|
|
|
|
fn spawn_achievements_screen(
|
|
commands: &mut Commands,
|
|
records: &[AchievementRecord],
|
|
font_res: Option<&FontResource>,
|
|
) {
|
|
let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect();
|
|
let total = ALL_ACHIEVEMENTS.len();
|
|
let header = format!("Achievements ({}/{})", unlocked.len(), total);
|
|
|
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
|
let font_name = TextFont {
|
|
font: font_handle.clone(),
|
|
font_size: TYPE_BODY_LG,
|
|
..default()
|
|
};
|
|
let font_desc = TextFont {
|
|
font: font_handle.clone(),
|
|
font_size: TYPE_BODY,
|
|
..default()
|
|
};
|
|
let font_meta = TextFont {
|
|
font: font_handle,
|
|
font_size: TYPE_CAPTION,
|
|
..default()
|
|
};
|
|
|
|
let any_unlocked = records.iter().any(|r| r.unlocked);
|
|
|
|
let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
|
|
spawn_modal_header(card, header, font_res);
|
|
|
|
// First-time hint — shown until the player has unlocked anything.
|
|
// The list itself describes individual rewards, but a top-level
|
|
// explanation gives newer players context for the otherwise dense
|
|
// greyed-out grid.
|
|
if !any_unlocked {
|
|
card.spawn((
|
|
Text::new(
|
|
"Complete games and try new modes to unlock achievements and rewards.",
|
|
),
|
|
TextFont {
|
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
|
font_size: TYPE_CAPTION,
|
|
..default()
|
|
},
|
|
TextColor(TEXT_SECONDARY),
|
|
));
|
|
}
|
|
|
|
// Scrollable body — the achievements list grows to ~19 rows which
|
|
// overflows the modal on the 800x600 minimum window. Wrapping the
|
|
// row list in an `Overflow::scroll_y()` Node with a constrained
|
|
// `max_height` keeps every row reachable. The Done button below
|
|
// sits outside the scroll so it's always one click away. Mirrors
|
|
// the `SettingsPanelScrollable` pattern.
|
|
card.spawn((
|
|
AchievementsScrollable,
|
|
ScrollPosition::default(),
|
|
Node {
|
|
flex_direction: FlexDirection::Column,
|
|
row_gap: VAL_SPACE_1,
|
|
max_height: Val::Vh(70.0),
|
|
overflow: Overflow::scroll_y(),
|
|
..default()
|
|
},
|
|
))
|
|
.with_children(|body| {
|
|
// Achievement rows — unlocked first, then locked alphabetical.
|
|
let mut sorted: Vec<_> = records.iter().collect();
|
|
sorted.sort_by_key(|r| (!r.unlocked, r.id.clone()));
|
|
|
|
for record in &sorted {
|
|
let def = achievement_by_id(&record.id);
|
|
let (name, description) =
|
|
def.map_or((record.id.as_str(), ""), |d| (d.name, d.description));
|
|
|
|
// Hide secret locked achievements so they remain a surprise.
|
|
let is_secret = def.is_some_and(|d| d.secret);
|
|
if is_secret && !record.unlocked {
|
|
continue;
|
|
}
|
|
|
|
let (name_color, desc_color, prefix) = if record.unlocked {
|
|
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ")
|
|
} else {
|
|
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
|
|
};
|
|
|
|
let tooltip_text = tooltip_for_row(record.unlocked, def);
|
|
|
|
body.spawn((
|
|
Node {
|
|
flex_direction: FlexDirection::Column,
|
|
row_gap: VAL_SPACE_1,
|
|
..default()
|
|
},
|
|
AchievementRow,
|
|
Tooltip::new(tooltip_text),
|
|
))
|
|
.with_children(|row| {
|
|
row.spawn((
|
|
Text::new(format!("{prefix}{name}")),
|
|
font_name.clone(),
|
|
TextColor(name_color),
|
|
));
|
|
if !description.is_empty() {
|
|
row.spawn((
|
|
Text::new(format!(" {description}")),
|
|
font_desc.clone(),
|
|
TextColor(desc_color),
|
|
));
|
|
}
|
|
if let Some(reward_str) = def.and_then(|d| d.reward).map(format_reward) {
|
|
row.spawn((
|
|
Text::new(format!(" Reward: {reward_str}")),
|
|
font_meta.clone(),
|
|
TextColor(STATE_SUCCESS),
|
|
));
|
|
}
|
|
if let Some(date) = record.unlock_date {
|
|
row.spawn((
|
|
Text::new(format!(" Unlocked {}", date.format("%Y-%m-%d"))),
|
|
font_meta.clone(),
|
|
TextColor(TEXT_SECONDARY),
|
|
));
|
|
}
|
|
});
|
|
|
|
// Subtle row separator — keeps the long list scannable.
|
|
body.spawn((
|
|
Node {
|
|
height: Val::Px(1.0),
|
|
..default()
|
|
},
|
|
BackgroundColor(BORDER_SUBTLE),
|
|
));
|
|
}
|
|
});
|
|
|
|
spawn_modal_actions(card, |actions| {
|
|
spawn_modal_button(
|
|
actions,
|
|
AchievementsCloseButton,
|
|
"Done",
|
|
Some("A"),
|
|
ButtonVariant::Primary,
|
|
font_res,
|
|
);
|
|
});
|
|
});
|
|
// Achievements is a read-only list — clicking the scrim outside
|
|
// the card dismisses alongside the existing A / Done paths.
|
|
commands.entity(scrim).insert(ScrimDismissible);
|
|
}
|
|
|
|
fn format_reward(reward: Reward) -> String {
|
|
match reward {
|
|
Reward::CardBack(idx) => format!("Card Back #{idx}"),
|
|
Reward::Background(idx) => format!("Background #{idx}"),
|
|
Reward::BonusXp(xp) => format!("+{xp} XP"),
|
|
Reward::Badge => "Badge".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Compose the per-row hover-tooltip string. Surfaces information that the
|
|
/// row itself does not always make obvious:
|
|
///
|
|
/// * Unlocked + reward → "Reward: <reward>." — celebrates the prize.
|
|
/// * Unlocked, no reward → "Earned!".
|
|
/// * Locked, non-secret → "How to unlock: <description>." plus the reward
|
|
/// when one is defined; the visible row already shows the same lines, but
|
|
/// gathering them in one tooltip keeps the long list scannable on hover.
|
|
/// * Locked, secret rows are filtered out before they reach this helper —
|
|
/// they get no tooltip so the unlock condition stays a surprise.
|
|
///
|
|
/// Defs are looked up at the call site; `None` means the record refers to an
|
|
/// achievement no longer present in `ALL_ACHIEVEMENTS` (forward-compat) and
|
|
/// gets a generic fallback.
|
|
fn tooltip_for_row(unlocked: bool, def: Option<&AchievementDef>) -> String {
|
|
if unlocked {
|
|
match def.and_then(|d| d.reward).map(format_reward) {
|
|
Some(reward) => format!("Reward: {reward}."),
|
|
None => "Earned!".to_string(),
|
|
}
|
|
} else {
|
|
let description = def.map_or("", |d| d.description);
|
|
let how = if description.is_empty() {
|
|
"How to unlock: keep playing.".to_string()
|
|
} else {
|
|
format!("How to unlock: {description}.")
|
|
};
|
|
match def.and_then(|d| d.reward).map(format_reward) {
|
|
Some(reward) => format!("{how} Reward: {reward}."),
|
|
None => how,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::game_plugin::GamePlugin;
|
|
use crate::stats_plugin::StatsPlugin;
|
|
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())
|
|
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
|
.add_plugins(AchievementPlugin::headless());
|
|
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
|
|
// MinimalPlugins it isn't auto-registered.
|
|
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
|
app.update();
|
|
app
|
|
}
|
|
|
|
#[test]
|
|
fn resource_is_populated_with_all_known_ids() {
|
|
let app = headless_app();
|
|
let records = &app.world().resource::<AchievementsResource>().0;
|
|
assert_eq!(records.len(), ALL_ACHIEVEMENTS.len());
|
|
for def in ALL_ACHIEVEMENTS {
|
|
assert!(records.iter().any(|r| r.id == def.id && !r.unlocked));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn win_unlocks_first_win_and_fires_event() {
|
|
let mut app = headless_app();
|
|
|
|
// StatsPlugin runs update_stats_on_win first (after GameMutation); that
|
|
// bumps games_won to 1 before evaluate_on_win reads StatsResource.
|
|
app.world_mut().write_message(GameWonEvent {
|
|
score: 1000,
|
|
time_seconds: 300,
|
|
});
|
|
app.update();
|
|
|
|
let unlocked_first_win = app
|
|
.world()
|
|
.resource::<AchievementsResource>()
|
|
.0
|
|
.iter()
|
|
.find(|r| r.id == "first_win")
|
|
.map(|r| r.unlocked)
|
|
.unwrap_or(false);
|
|
assert!(unlocked_first_win);
|
|
|
|
// Verify the event was emitted.
|
|
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
|
assert!(fired.contains(&"first_win".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn repeated_win_does_not_refire_already_unlocked_achievement() {
|
|
let mut app = headless_app();
|
|
|
|
app.world_mut().write_message(GameWonEvent {
|
|
score: 1000,
|
|
time_seconds: 300,
|
|
});
|
|
app.update();
|
|
|
|
// Clear events from first win.
|
|
app.world_mut()
|
|
.resource_mut::<Messages<AchievementUnlockedEvent>>()
|
|
.clear();
|
|
|
|
app.world_mut().write_message(GameWonEvent {
|
|
score: 1000,
|
|
time_seconds: 300,
|
|
});
|
|
app.update();
|
|
|
|
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
|
assert!(
|
|
!fired.contains(&"first_win".to_string()),
|
|
"first_win must not re-fire on subsequent wins"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn display_name_resolves_known_and_unknown_ids() {
|
|
assert_eq!(display_name_for("first_win"), "First Win");
|
|
assert_eq!(display_name_for("bogus"), "bogus");
|
|
}
|
|
|
|
#[test]
|
|
fn bonus_xp_reward_fires_xp_awarded_event() {
|
|
let mut app = headless_app();
|
|
// "no_undo" achievement awards BonusXp(25). Trigger it by sending a
|
|
// GameWonEvent with undo_count == 0 (default) and enough stats to match.
|
|
app.world_mut().write_message(GameWonEvent {
|
|
score: 1000,
|
|
time_seconds: 300,
|
|
});
|
|
app.update();
|
|
|
|
let events = app.world().resource::<Messages<XpAwardedEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
let xp_events: Vec<u64> = cursor.read(events).map(|e| e.amount).collect();
|
|
// The no_undo achievement (BonusXp 25) must have fired an XpAwardedEvent.
|
|
assert!(
|
|
xp_events.contains(&25),
|
|
"BonusXp(25) must fire XpAwardedEvent; got {xp_events:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_undo_achievement_does_not_fire_when_undo_was_used() {
|
|
let mut app = headless_app();
|
|
// Simulate a win where the player used undo at least once.
|
|
app.world_mut()
|
|
.resource_mut::<GameStateResource>()
|
|
.0
|
|
.undo_count = 1;
|
|
|
|
app.world_mut().write_message(GameWonEvent {
|
|
score: 1000,
|
|
time_seconds: 300,
|
|
});
|
|
app.update();
|
|
|
|
// "no_undo" awards BonusXp(25). If undo was used it must NOT fire.
|
|
let events = app.world().resource::<Messages<XpAwardedEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
let xp_events: Vec<u64> = cursor.read(events).map(|e| e.amount).collect();
|
|
assert!(
|
|
!xp_events.contains(&25),
|
|
"BonusXp(25) must not fire when undo_count > 0; got {xp_events:?}"
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// draw_three_master integration
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn draw_three_master_fires_on_tenth_draw_three_win() {
|
|
let mut app = headless_app();
|
|
|
|
// Pre-seed nine prior Draw-Three wins. The pending GameWonEvent will
|
|
// trigger update_stats_on_win first (StatsUpdate runs before
|
|
// evaluate_on_win), bumping draw_three_wins to 10 — the unlock
|
|
// threshold for the draw_three_master achievement.
|
|
app.world_mut().resource_mut::<StatsResource>().0.draw_three_wins = 9;
|
|
|
|
// The current game must be in DrawThree mode so update_on_win
|
|
// increments draw_three_wins (and not draw_one_wins).
|
|
app.world_mut()
|
|
.resource_mut::<GameStateResource>()
|
|
.0
|
|
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
|
|
|
|
app.world_mut().write_message(GameWonEvent {
|
|
score: 500,
|
|
time_seconds: 240,
|
|
});
|
|
app.update();
|
|
|
|
// Sanity-check that the win was actually attributed to Draw-Three so
|
|
// the achievement reads the correct counter.
|
|
let stats = &app.world().resource::<StatsResource>().0;
|
|
assert_eq!(stats.draw_three_wins, 10);
|
|
|
|
let unlocked = app
|
|
.world()
|
|
.resource::<AchievementsResource>()
|
|
.0
|
|
.iter()
|
|
.find(|r| r.id == "draw_three_master")
|
|
.map(|r| r.unlocked)
|
|
.unwrap_or(false);
|
|
assert!(unlocked, "draw_three_master must unlock at the 10th Draw-Three win");
|
|
|
|
// Verify the AchievementUnlockedEvent fired for this id.
|
|
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
|
assert!(
|
|
fired.contains(&"draw_three_master".to_string()),
|
|
"AchievementUnlockedEvent for draw_three_master must fire; got {fired:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn draw_three_master_does_not_fire_at_nine_wins() {
|
|
let mut app = headless_app();
|
|
|
|
// Pre-seed eight prior Draw-Three wins. The pending GameWonEvent
|
|
// brings draw_three_wins to 9 — one short of the threshold.
|
|
app.world_mut().resource_mut::<StatsResource>().0.draw_three_wins = 8;
|
|
app.world_mut()
|
|
.resource_mut::<GameStateResource>()
|
|
.0
|
|
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
|
|
|
|
app.world_mut().write_message(GameWonEvent {
|
|
score: 500,
|
|
time_seconds: 240,
|
|
});
|
|
app.update();
|
|
|
|
let stats = &app.world().resource::<StatsResource>().0;
|
|
assert_eq!(stats.draw_three_wins, 9);
|
|
|
|
let unlocked = app
|
|
.world()
|
|
.resource::<AchievementsResource>()
|
|
.0
|
|
.iter()
|
|
.find(|r| r.id == "draw_three_master")
|
|
.map(|r| r.unlocked)
|
|
.unwrap_or(false);
|
|
assert!(!unlocked, "draw_three_master must remain locked at 9 Draw-Three wins");
|
|
|
|
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
|
assert!(
|
|
!fired.contains(&"draw_three_master".to_string()),
|
|
"draw_three_master must not fire below threshold; got {fired:?}"
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// zen_winner integration
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn zen_winner_fires_on_zen_mode_win() {
|
|
let mut app = headless_app();
|
|
|
|
// Put the active game in Zen mode. evaluate_on_win reads
|
|
// GameStateResource.mode directly to populate last_win_is_zen.
|
|
app.world_mut()
|
|
.resource_mut::<GameStateResource>()
|
|
.0
|
|
.mode = solitaire_core::game_state::GameMode::Zen;
|
|
|
|
app.world_mut().write_message(GameWonEvent {
|
|
score: 0,
|
|
time_seconds: 600,
|
|
});
|
|
app.update();
|
|
|
|
let unlocked = app
|
|
.world()
|
|
.resource::<AchievementsResource>()
|
|
.0
|
|
.iter()
|
|
.find(|r| r.id == "zen_winner")
|
|
.map(|r| r.unlocked)
|
|
.unwrap_or(false);
|
|
assert!(unlocked, "zen_winner must unlock when the game mode is Zen");
|
|
|
|
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
|
assert!(
|
|
fired.contains(&"zen_winner".to_string()),
|
|
"AchievementUnlockedEvent for zen_winner must fire; got {fired:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn zen_winner_does_not_fire_for_classic_win() {
|
|
let mut app = headless_app();
|
|
|
|
// Default GameMode is Classic; assert and rely on it.
|
|
assert_eq!(
|
|
app.world().resource::<GameStateResource>().0.mode,
|
|
solitaire_core::game_state::GameMode::Classic
|
|
);
|
|
|
|
app.world_mut().write_message(GameWonEvent {
|
|
score: 1000,
|
|
time_seconds: 300,
|
|
});
|
|
app.update();
|
|
|
|
let unlocked = app
|
|
.world()
|
|
.resource::<AchievementsResource>()
|
|
.0
|
|
.iter()
|
|
.find(|r| r.id == "zen_winner")
|
|
.map(|r| r.unlocked)
|
|
.unwrap_or(false);
|
|
assert!(!unlocked, "zen_winner must remain locked outside Zen mode");
|
|
|
|
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
|
|
assert!(
|
|
!fired.contains(&"zen_winner".to_string()),
|
|
"zen_winner must not fire on a Classic-mode win; got {fired:?}"
|
|
);
|
|
}
|
|
|
|
fn press(app: &mut App, key: KeyCode) {
|
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
input.release(key);
|
|
input.clear();
|
|
input.press(key);
|
|
}
|
|
|
|
#[test]
|
|
fn pressing_a_spawns_achievements_screen() {
|
|
let mut app = headless_app();
|
|
press(&mut app, KeyCode::KeyA);
|
|
app.update();
|
|
let count = app
|
|
.world_mut()
|
|
.query::<&AchievementsScreen>()
|
|
.iter(app.world())
|
|
.count();
|
|
assert_eq!(count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn pressing_a_twice_dismisses_screen() {
|
|
let mut app = headless_app();
|
|
press(&mut app, KeyCode::KeyA);
|
|
app.update();
|
|
press(&mut app, KeyCode::KeyA);
|
|
app.update();
|
|
let count = app
|
|
.world_mut()
|
|
.query::<&AchievementsScreen>()
|
|
.iter(app.world())
|
|
.count();
|
|
assert_eq!(count, 0);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Scrollable body
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Spawning the modal must place exactly one `AchievementsScrollable`
|
|
/// marker in the world so the row list scrolls instead of clipping at
|
|
/// the 800x600 minimum window.
|
|
#[test]
|
|
fn achievements_modal_body_is_scrollable() {
|
|
let mut app = headless_app();
|
|
press(&mut app, KeyCode::KeyA);
|
|
app.update();
|
|
|
|
let count = app
|
|
.world_mut()
|
|
.query::<&AchievementsScrollable>()
|
|
.iter(app.world())
|
|
.count();
|
|
assert_eq!(
|
|
count, 1,
|
|
"Achievements modal must spawn exactly one AchievementsScrollable body"
|
|
);
|
|
}
|
|
|
|
/// The scrollable body must constrain its `max_height` so the modal
|
|
/// actually engages scrolling on tall content. Without this the inner
|
|
/// flex column would expand to fit every row and `Overflow::scroll_y`
|
|
/// would have nothing to clip.
|
|
#[test]
|
|
fn achievements_modal_body_has_max_height() {
|
|
let mut app = headless_app();
|
|
press(&mut app, KeyCode::KeyA);
|
|
app.update();
|
|
|
|
let mut q = app
|
|
.world_mut()
|
|
.query_filtered::<&Node, With<AchievementsScrollable>>();
|
|
let nodes: Vec<&Node> = q.iter(app.world()).collect();
|
|
assert_eq!(nodes.len(), 1, "expected exactly one scrollable body");
|
|
let node = nodes[0];
|
|
|
|
// `Val::Auto` is the default; assert the body's `max_height` was
|
|
// explicitly set to something else so scroll engages.
|
|
assert_ne!(
|
|
node.max_height,
|
|
Val::Auto,
|
|
"scrollable body must set a non-default max_height; got {:?}",
|
|
node.max_height
|
|
);
|
|
// And the overflow axis must be y-scroll.
|
|
assert_eq!(
|
|
node.overflow,
|
|
Overflow::scroll_y(),
|
|
"scrollable body must use Overflow::scroll_y(); got {:?}",
|
|
node.overflow
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// format_reward
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn format_reward_card_back() {
|
|
assert_eq!(format_reward(Reward::CardBack(2)), "Card Back #2");
|
|
}
|
|
|
|
#[test]
|
|
fn format_reward_background() {
|
|
assert_eq!(format_reward(Reward::Background(3)), "Background #3");
|
|
}
|
|
|
|
#[test]
|
|
fn format_reward_bonus_xp() {
|
|
assert_eq!(format_reward(Reward::BonusXp(25)), "+25 XP");
|
|
}
|
|
|
|
#[test]
|
|
fn format_reward_badge() {
|
|
assert_eq!(format_reward(Reward::Badge), "Badge");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Per-row tooltips
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Collects every `Tooltip` string attached to an `AchievementRow` in the
|
|
/// current world. Order is unspecified — callers should search for a
|
|
/// substring rather than rely on positions.
|
|
fn collect_row_tooltips(app: &mut App) -> Vec<String> {
|
|
let mut q = app
|
|
.world_mut()
|
|
.query_filtered::<&Tooltip, With<AchievementRow>>();
|
|
q.iter(app.world())
|
|
.map(|t| t.0.clone().into_owned())
|
|
.collect()
|
|
}
|
|
|
|
/// `on_a_roll` is unlocked and has `Reward::CardBack(1)`. Its row's
|
|
/// tooltip must surface that reward — the row UI already lists it, but
|
|
/// the tooltip exists so the value is never just below the fold on
|
|
/// long lists.
|
|
#[test]
|
|
fn unlocked_achievement_row_carries_tooltip_with_reward() {
|
|
let mut app = headless_app();
|
|
|
|
// Pre-unlock on_a_roll directly on the resource so the row renders
|
|
// in the "unlocked" branch when the screen spawns.
|
|
{
|
|
let mut achievements = app.world_mut().resource_mut::<AchievementsResource>();
|
|
let record = achievements
|
|
.0
|
|
.iter_mut()
|
|
.find(|r| r.id == "on_a_roll")
|
|
.expect("on_a_roll record must be seeded by AchievementPlugin");
|
|
record.unlock(Utc::now());
|
|
record.reward_granted = true;
|
|
}
|
|
|
|
press(&mut app, KeyCode::KeyA);
|
|
app.update();
|
|
|
|
let tips = collect_row_tooltips(&mut app);
|
|
assert!(
|
|
!tips.is_empty(),
|
|
"spawning the achievements screen must attach Tooltips to rows"
|
|
);
|
|
|
|
// The reward for on_a_roll is `Card Back #1`. Find a tooltip
|
|
// mentioning "Card back" (case-insensitive on "Back" → match the
|
|
// exact format_reward output).
|
|
let has_card_back_reward = tips.iter().any(|t| t.contains("Card Back"));
|
|
assert!(
|
|
has_card_back_reward,
|
|
"expected an unlocked-row tooltip to mention the Card Back reward; got: {tips:?}"
|
|
);
|
|
}
|
|
|
|
/// Locked secret achievements are filtered out of the row list, so the
|
|
/// screen must not contain a row tooltip carrying the secret
|
|
/// achievement's reward (`Card Back #4` for `speed_and_skill`) — the
|
|
/// only fingerprint that would betray the row's identity even though
|
|
/// the canonical description is already cryptic.
|
|
#[test]
|
|
fn locked_secret_achievement_does_not_reveal_condition() {
|
|
let mut app = headless_app();
|
|
|
|
// `speed_and_skill` starts locked under headless_app(); confirm.
|
|
let locked = app
|
|
.world()
|
|
.resource::<AchievementsResource>()
|
|
.0
|
|
.iter()
|
|
.find(|r| r.id == "speed_and_skill")
|
|
.map(|r| !r.unlocked)
|
|
.unwrap_or(false);
|
|
assert!(
|
|
locked,
|
|
"precondition: speed_and_skill must be locked in a fresh headless app"
|
|
);
|
|
|
|
press(&mut app, KeyCode::KeyA);
|
|
app.update();
|
|
|
|
let tips = collect_row_tooltips(&mut app);
|
|
// No row may carry the secret reward — that's the only way the
|
|
// secret row's identity could leak through the tooltip surface.
|
|
for t in &tips {
|
|
assert!(
|
|
!t.contains("Card Back #4"),
|
|
"tooltip leaks the secret reward: {t:?}"
|
|
);
|
|
}
|
|
|
|
// No row may quote the verbatim secret-condition vocabulary. The
|
|
// canonical secret description in `solitaire_core` is already
|
|
// generic ("A secret achievement"); these checks guard against a
|
|
// future leak where someone replaces it with the literal predicate.
|
|
let leaked_predicate = tips.iter().any(|t| {
|
|
t.contains("90") && t.to_lowercase().contains("without undo")
|
|
});
|
|
assert!(
|
|
!leaked_predicate,
|
|
"no tooltip may state the speed_and_skill predicate: {tips:?}"
|
|
);
|
|
|
|
// Sanity: the screen actually rendered some rows. If the spawn
|
|
// path were broken there'd be nothing to leak in the first place.
|
|
assert!(!tips.is_empty(), "screen must have rendered rows");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// tooltip_for_row policy
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn tooltip_for_row_unlocked_with_reward_mentions_reward() {
|
|
let def = achievement_by_id("on_a_roll").expect("on_a_roll exists");
|
|
let s = tooltip_for_row(true, Some(def));
|
|
assert!(s.contains("Card Back"), "got {s:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn tooltip_for_row_unlocked_without_reward_says_earned() {
|
|
let def = achievement_by_id("first_win").expect("first_win exists");
|
|
assert_eq!(tooltip_for_row(true, Some(def)), "Earned!");
|
|
}
|
|
|
|
#[test]
|
|
fn tooltip_for_row_locked_includes_description_and_reward() {
|
|
let def = achievement_by_id("lightning").expect("lightning exists");
|
|
let s = tooltip_for_row(false, Some(def));
|
|
assert!(s.contains("How to unlock"));
|
|
assert!(s.contains("under 90 seconds"));
|
|
assert!(s.contains("Card Back #2"));
|
|
}
|
|
|
|
#[test]
|
|
fn tooltip_for_row_locked_no_reward_omits_reward() {
|
|
let def = achievement_by_id("first_win").expect("first_win exists");
|
|
let s = tooltip_for_row(false, Some(def));
|
|
assert!(s.contains("How to unlock"));
|
|
assert!(!s.contains("Reward"), "got {s:?}");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Achievement-onboarding cue (`fire_achievement_onboarding_toast`)
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Builds a headless app that **also** includes `SettingsPlugin::headless()`
|
|
/// so the achievement-onboarding system (which reads `SettingsResource`)
|
|
/// has a flag to consult and persist into.
|
|
fn onboarding_test_app() -> App {
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(GamePlugin)
|
|
.add_plugins(TablePlugin)
|
|
.add_plugins(StatsPlugin::headless())
|
|
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
|
.add_plugins(crate::settings_plugin::SettingsPlugin::headless())
|
|
.add_plugins(AchievementPlugin::headless());
|
|
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
|
app.update();
|
|
app
|
|
}
|
|
|
|
/// Collects every `InfoToastEvent` written so tests can assert on
|
|
/// count and message contents.
|
|
fn drain_info_toasts(app: &App) -> Vec<String> {
|
|
let events = app.world().resource::<Messages<InfoToastEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
cursor.read(events).map(|e| e.0.clone()).collect()
|
|
}
|
|
|
|
/// First-win path: with the flag false and `games_won` about to be
|
|
/// 1, exactly one `InfoToastEvent` mentioning the `A` hotkey must
|
|
/// fire and the flag must flip to `true`.
|
|
#[test]
|
|
fn first_win_fires_achievement_onboarding_toast() {
|
|
let mut app = onboarding_test_app();
|
|
|
|
// Sanity: fresh app starts with games_won = 0 and the flag unset.
|
|
assert_eq!(app.world().resource::<StatsResource>().0.games_won, 0);
|
|
assert!(
|
|
!app.world()
|
|
.resource::<SettingsResource>()
|
|
.0
|
|
.shown_achievement_onboarding
|
|
);
|
|
|
|
// StatsPlugin (StatsUpdate) increments games_won to 1 *before* the
|
|
// achievement-onboarding system reads stats — our system runs
|
|
// `.after(StatsUpdate)`. The system then sees games_won == 1 and
|
|
// the cue fires.
|
|
app.world_mut().write_message(GameWonEvent {
|
|
score: 1000,
|
|
time_seconds: 300,
|
|
});
|
|
app.update();
|
|
|
|
let toasts = drain_info_toasts(&app);
|
|
let onboarding_toasts: Vec<&String> = toasts
|
|
.iter()
|
|
.filter(|t| t.contains("Press A") && t.contains("achievements"))
|
|
.collect();
|
|
assert_eq!(
|
|
onboarding_toasts.len(),
|
|
1,
|
|
"exactly one achievement-onboarding toast must fire on the first win; \
|
|
saw all toasts: {toasts:?}"
|
|
);
|
|
assert!(
|
|
app.world()
|
|
.resource::<SettingsResource>()
|
|
.0
|
|
.shown_achievement_onboarding,
|
|
"shown_achievement_onboarding must flip to true after the toast fires"
|
|
);
|
|
}
|
|
|
|
/// Second-win path: with the flag already `true` (player already
|
|
/// saw the cue on a previous run), no onboarding toast may fire.
|
|
#[test]
|
|
fn subsequent_wins_do_not_fire_achievement_onboarding_toast() {
|
|
let mut app = onboarding_test_app();
|
|
|
|
// Pre-set the flag to simulate a player who already dismissed
|
|
// the cue on a previous run.
|
|
app.world_mut()
|
|
.resource_mut::<SettingsResource>()
|
|
.0
|
|
.shown_achievement_onboarding = true;
|
|
|
|
app.world_mut().write_message(GameWonEvent {
|
|
score: 1000,
|
|
time_seconds: 300,
|
|
});
|
|
app.update();
|
|
|
|
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
|
|
.into_iter()
|
|
.filter(|t| t.contains("Press A") && t.contains("achievements"))
|
|
.collect();
|
|
assert!(
|
|
onboarding_toasts.is_empty(),
|
|
"no onboarding toast must fire when shown_achievement_onboarding is already true; \
|
|
got: {onboarding_toasts:?}"
|
|
);
|
|
}
|
|
|
|
/// Sync-import path: a player imports stats with `games_won = 5`
|
|
/// already on the books. The flag is still `false` (they were on a
|
|
/// pre-cue release on this device), but the cue must NOT fire because
|
|
/// this isn't actually their first win — the post-condition
|
|
/// `games_won == 1` guards against retroactive nagging.
|
|
#[test]
|
|
fn non_first_win_does_not_fire_achievement_onboarding_toast() {
|
|
let mut app = onboarding_test_app();
|
|
|
|
// Pre-seed games_won = 5 BEFORE the win lands. StatsUpdate will
|
|
// bump it to 6 on the GameWonEvent, taking the system well past
|
|
// the `games_won == 1` post-condition.
|
|
app.world_mut().resource_mut::<StatsResource>().0.games_won = 5;
|
|
|
|
// Confirm the flag is still false so we know the guard that
|
|
// prevents firing is the games-won post-condition, not the flag.
|
|
assert!(
|
|
!app.world()
|
|
.resource::<SettingsResource>()
|
|
.0
|
|
.shown_achievement_onboarding
|
|
);
|
|
|
|
app.world_mut().write_message(GameWonEvent {
|
|
score: 1000,
|
|
time_seconds: 300,
|
|
});
|
|
app.update();
|
|
|
|
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
|
|
.into_iter()
|
|
.filter(|t| t.contains("Press A") && t.contains("achievements"))
|
|
.collect();
|
|
assert!(
|
|
onboarding_toasts.is_empty(),
|
|
"no onboarding toast must fire on a non-first win; got: {onboarding_toasts:?}"
|
|
);
|
|
// And the flag must remain false so the cue can still teach a
|
|
// genuinely-fresh second device or a wiped install.
|
|
assert!(
|
|
!app.world()
|
|
.resource::<SettingsResource>()
|
|
.0
|
|
.shown_achievement_onboarding,
|
|
"shown_achievement_onboarding must remain false when the cue did not fire"
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Cinephile (event-driven via ReplayPlaybackState)
|
|
// -----------------------------------------------------------------------
|
|
|
|
use crate::replay_playback::ReplayPlaybackState;
|
|
use solitaire_data::{Replay, ReplayMove};
|
|
use chrono::NaiveDate;
|
|
use solitaire_core::game_state::{DrawMode, GameMode};
|
|
|
|
/// Headless app variant that injects a default `ReplayPlaybackState`
|
|
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
|
|
/// by hand. The achievement plugin's cinephile observer reads it via
|
|
/// `Option<Res<_>>` so the absence of the playback plugin is safe.
|
|
fn cinephile_app() -> App {
|
|
let mut app = headless_app();
|
|
app.init_resource::<ReplayPlaybackState>();
|
|
app
|
|
}
|
|
|
|
fn dummy_replay() -> Replay {
|
|
Replay::new(
|
|
1,
|
|
DrawMode::DrawOne,
|
|
GameMode::Classic,
|
|
10,
|
|
100,
|
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
|
vec![ReplayMove::StockClick],
|
|
)
|
|
}
|
|
|
|
fn cinephile_unlocked(app: &App) -> bool {
|
|
app.world()
|
|
.resource::<AchievementsResource>()
|
|
.0
|
|
.iter()
|
|
.find(|r| r.id == "cinephile")
|
|
.map(|r| r.unlocked)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn cinephile_unlocks_emitted(app: &App) -> usize {
|
|
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
|
|
let mut cursor = events.get_cursor();
|
|
cursor
|
|
.read(events)
|
|
.filter(|e| e.0.id == "cinephile")
|
|
.count()
|
|
}
|
|
|
|
/// The cinephile record must be seeded on plugin init like every other
|
|
/// achievement, so the observer can find and mutate it later.
|
|
#[test]
|
|
fn cinephile_record_seeded_by_plugin() {
|
|
let app = cinephile_app();
|
|
let records = &app.world().resource::<AchievementsResource>().0;
|
|
assert!(
|
|
records.iter().any(|r| r.id == "cinephile" && !r.unlocked),
|
|
"cinephile record must be seeded as locked",
|
|
);
|
|
}
|
|
|
|
/// Drive Inactive → Playing → Completed and assert the cinephile
|
|
/// achievement unlocks and exactly one `AchievementUnlockedEvent` is
|
|
/// emitted.
|
|
#[test]
|
|
fn cinephile_unlocks_on_replay_completion() {
|
|
let mut app = cinephile_app();
|
|
|
|
// Frame 1: enter Playing. The observer's first sample sees
|
|
// `last_was_playing = false` and `now_playing = true`.
|
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
|
ReplayPlaybackState::Playing {
|
|
replay: dummy_replay(),
|
|
cursor: 0,
|
|
secs_to_next: 0.0,
|
|
paused: false,
|
|
};
|
|
app.update();
|
|
assert!(
|
|
!cinephile_unlocked(&app),
|
|
"Playing alone must not unlock cinephile",
|
|
);
|
|
|
|
// Frame 2: transition to Completed. The observer must detect
|
|
// `last_was_playing = true && now_completed = true` and unlock.
|
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
|
ReplayPlaybackState::Completed;
|
|
app.update();
|
|
|
|
assert!(
|
|
cinephile_unlocked(&app),
|
|
"cinephile must unlock on Playing → Completed transition",
|
|
);
|
|
assert_eq!(
|
|
cinephile_unlocks_emitted(&app),
|
|
1,
|
|
"exactly one AchievementUnlockedEvent must fire for cinephile",
|
|
);
|
|
}
|
|
|
|
/// Stop button transitions Playing → Inactive directly (not via
|
|
/// Completed). Drive that path and assert no cinephile unlock.
|
|
#[test]
|
|
fn cinephile_does_not_unlock_on_stop_button_abort() {
|
|
let mut app = cinephile_app();
|
|
|
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
|
ReplayPlaybackState::Playing {
|
|
replay: dummy_replay(),
|
|
cursor: 0,
|
|
secs_to_next: 0.0,
|
|
paused: false,
|
|
};
|
|
app.update();
|
|
|
|
// Direct Playing → Inactive — the path the Stop button takes via
|
|
// `stop_replay_playback`. Must not unlock cinephile.
|
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
|
ReplayPlaybackState::Inactive;
|
|
app.update();
|
|
|
|
assert!(
|
|
!cinephile_unlocked(&app),
|
|
"Stop button (Playing → Inactive) must not unlock cinephile",
|
|
);
|
|
assert_eq!(
|
|
cinephile_unlocks_emitted(&app),
|
|
0,
|
|
"no AchievementUnlockedEvent for cinephile on a Stop transition",
|
|
);
|
|
}
|
|
|
|
/// A second Playing → Completed cycle on an already-unlocked record
|
|
/// must be idempotent: no additional `AchievementUnlockedEvent`.
|
|
#[test]
|
|
fn cinephile_does_not_double_fire() {
|
|
let mut app = cinephile_app();
|
|
|
|
// First completion cycle to unlock.
|
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
|
ReplayPlaybackState::Playing {
|
|
replay: dummy_replay(),
|
|
cursor: 0,
|
|
secs_to_next: 0.0,
|
|
paused: false,
|
|
};
|
|
app.update();
|
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
|
ReplayPlaybackState::Completed;
|
|
app.update();
|
|
assert!(cinephile_unlocked(&app), "precondition: first cycle must unlock");
|
|
|
|
// Drain the event queue so the next assertion doesn't double-count
|
|
// the legitimate first-time unlock event.
|
|
app.world_mut()
|
|
.resource_mut::<Messages<AchievementUnlockedEvent>>()
|
|
.clear();
|
|
|
|
// Second cycle: Inactive → Playing → Completed once more.
|
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
|
ReplayPlaybackState::Inactive;
|
|
app.update();
|
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
|
ReplayPlaybackState::Playing {
|
|
replay: dummy_replay(),
|
|
cursor: 0,
|
|
secs_to_next: 0.0,
|
|
paused: false,
|
|
};
|
|
app.update();
|
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
|
ReplayPlaybackState::Completed;
|
|
app.update();
|
|
|
|
assert_eq!(
|
|
cinephile_unlocks_emitted(&app),
|
|
0,
|
|
"cinephile must not re-fire on a second Playing → Completed cycle",
|
|
);
|
|
}
|
|
|
|
/// `Completed` lingers across multiple frames before the auto-clear
|
|
/// transitions back to `Inactive`. The observer must fire exactly
|
|
/// once during that linger window — not once per frame.
|
|
#[test]
|
|
fn cinephile_fires_once_across_completed_linger() {
|
|
let mut app = cinephile_app();
|
|
|
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
|
ReplayPlaybackState::Playing {
|
|
replay: dummy_replay(),
|
|
cursor: 0,
|
|
secs_to_next: 0.0,
|
|
paused: false,
|
|
};
|
|
app.update();
|
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
|
ReplayPlaybackState::Completed;
|
|
app.update();
|
|
// Stay in Completed for a few more frames as the real auto-clear
|
|
// does. Each subsequent frame the resource is still `Completed`
|
|
// but the observer has already counted this transition.
|
|
app.update();
|
|
app.update();
|
|
app.update();
|
|
|
|
assert_eq!(
|
|
cinephile_unlocks_emitted(&app),
|
|
1,
|
|
"cinephile must fire exactly once across the Completed linger window",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_win_event_means_no_achievement_onboarding_toast() {
|
|
let mut app = onboarding_test_app();
|
|
|
|
// Pre-seed games_won = 1 to simulate the misleading mid-frame
|
|
// state without actually firing a GameWonEvent.
|
|
app.world_mut().resource_mut::<StatsResource>().0.games_won = 1;
|
|
|
|
app.update();
|
|
|
|
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
|
|
.into_iter()
|
|
.filter(|t| t.contains("Press A") && t.contains("achievements"))
|
|
.collect();
|
|
assert!(
|
|
onboarding_toasts.is_empty(),
|
|
"no onboarding toast must fire without a GameWonEvent; got: {onboarding_toasts:?}"
|
|
);
|
|
assert!(
|
|
!app.world()
|
|
.resource::<SettingsResource>()
|
|
.0
|
|
.shown_achievement_onboarding,
|
|
"flag must not flip without a win event"
|
|
);
|
|
}
|
|
}
|