//! 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); /// Persistence path for `AchievementsResource`. `None` disables I/O. #[derive(Resource, Debug, Clone)] pub struct AchievementsStoragePath(pub Option); pub struct AchievementPlugin { pub storage_path: Option, } 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::() .add_message::() .add_message::() .add_message::() .add_message::() // `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::() .add_message::() // 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) .add_systems(Update, crate::ui_modal::touch_scroll_panel::) // Event-driven unlock: observe `ReplayPlaybackState` and unlock // `cinephile` the first time playback runs to natural completion. // Reads the resource via `Option>` 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, mut unlocks: MessageWriter, mut levelups: MessageWriter, mut xp_awarded: MessageWriter, game: Res, stats: Res, path: Res, progress_path: Res, mut achievements: ResMut, mut progress: ResMut, ) { 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` — without /// this, a freshly-spawned `Completed` state would re-fire each frame /// during the linger window. /// /// Reads `ReplayPlaybackState` via `Option>` so achievement tests /// that omit `ReplayPlaybackPlugin` still build cleanly. fn evaluate_cinephile_on_replay_completion( state: Option>, // `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, mut achievements: ResMut, mut unlocks: MessageWriter, path: Res, ) { 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, stats: Res, mut settings: Option>, settings_path: Option>, mut toast: MessageWriter, ) { // 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>, mut requests: MessageReader, achievements: Res, font_res: Option>, screens: Query>, ) { 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, Changed)>, screens: Query>, ) { 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, mut scrollables: Query<&mut ScrollPosition, With>, ) { 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, "+ ") } else { (TEXT_DISABLED, TEXT_DISABLED, "- ") }; 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: ." — celebrates the prize. /// * Unlocked, no reward → "Earned!". /// * Locked, non-secret → "How to unlock: ." 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; under // MinimalPlugins it isn't auto-registered. app.init_resource::>(); app.update(); app } #[test] fn resource_is_populated_with_all_known_ids() { let app = headless_app(); let records = &app.world().resource::().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::() .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::>(); let mut cursor = events.get_cursor(); let fired: Vec = 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::>() .clear(); app.world_mut().write_message(GameWonEvent { score: 1000, time_seconds: 300, }); app.update(); let events = app.world().resource::>(); let mut cursor = events.get_cursor(); let fired: Vec = 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::>(); let mut cursor = events.get_cursor(); let xp_events: Vec = 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::() .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::>(); let mut cursor = events.get_cursor(); let xp_events: Vec = 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::().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::() .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::().0; assert_eq!(stats.draw_three_wins, 10); let unlocked = app .world() .resource::() .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::>(); let mut cursor = events.get_cursor(); let fired: Vec = 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::().0.draw_three_wins = 8; app.world_mut() .resource_mut::() .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::().0; assert_eq!(stats.draw_three_wins, 9); let unlocked = app .world() .resource::() .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::>(); let mut cursor = events.get_cursor(); let fired: Vec = 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::() .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::() .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::>(); let mut cursor = events.get_cursor(); let fired: Vec = 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::().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::() .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::>(); let mut cursor = events.get_cursor(); let fired: Vec = 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::>(); 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>(); 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 { let mut q = app .world_mut() .query_filtered::<&Tooltip, With>(); 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::(); 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::() .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::>(); app.update(); app } /// Collects every `InfoToastEvent` written so tests can assert on /// count and message contents. fn drain_info_toasts(app: &App) -> Vec { let events = app.world().resource::>(); 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::().0.games_won, 0); assert!( !app.world() .resource::() .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::() .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::() .0 .shown_achievement_onboarding = true; app.world_mut().write_message(GameWonEvent { score: 1000, time_seconds: 300, }); app.update(); let onboarding_toasts: Vec = 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::().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::() .0 .shown_achievement_onboarding ); app.world_mut().write_message(GameWonEvent { score: 1000, time_seconds: 300, }); app.update(); let onboarding_toasts: Vec = 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::() .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>` so the absence of the playback plugin is safe. fn cinephile_app() -> App { let mut app = headless_app(); app.init_resource::(); 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::() .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::>(); 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::().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::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::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::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::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::Playing { replay: dummy_replay(), cursor: 0, secs_to_next: 0.0, paused: false, }; app.update(); *app.world_mut().resource_mut::() = 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::>() .clear(); // Second cycle: Inactive → Playing → Completed once more. *app.world_mut().resource_mut::() = ReplayPlaybackState::Inactive; app.update(); *app.world_mut().resource_mut::() = ReplayPlaybackState::Playing { replay: dummy_replay(), cursor: 0, secs_to_next: 0.0, paused: false, }; app.update(); *app.world_mut().resource_mut::() = 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::Playing { replay: dummy_replay(), cursor: 0, secs_to_next: 0.0, paused: false, }; app.update(); *app.world_mut().resource_mut::() = 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::().0.games_won = 1; app.update(); let onboarding_toasts: Vec = 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::() .0 .shown_achievement_onboarding, "flag must not flip without a win event" ); } }