diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 2cca090..5ebdaae 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -132,6 +132,17 @@ pub struct Settings { /// `#[serde(default = ...)]`. #[serde(default = "default_theme_id")] pub selected_theme_id: String, + /// Set to `true` once the achievement-onboarding info-toast has been + /// shown to the player after their very first win. Acts as a + /// one-shot teach: subsequent wins must not re-fire the cue. Older + /// `settings.json` files written before this field existed + /// deserialize cleanly to `false` thanks to `#[serde(default)]` — + /// players who already had wins recorded before this field was + /// introduced are guarded by the post-condition `games_won == 1` + /// checked by `achievement_plugin::fire_achievement_onboarding_toast`, + /// so the toast still does not fire for them. + #[serde(default)] + pub shown_achievement_onboarding: bool, } fn default_draw_mode() -> DrawMode { @@ -165,6 +176,7 @@ impl Default for Settings { color_blind_mode: false, window_geometry: None, selected_theme_id: default_theme_id(), + shown_achievement_onboarding: false, } } } @@ -318,6 +330,7 @@ mod tests { color_blind_mode: false, window_geometry: None, selected_theme_id: "default".to_string(), + shown_achievement_onboarding: false, }; save_settings_to(&path, &s).expect("save"); let loaded = load_settings_from(&path); @@ -506,4 +519,48 @@ mod tests { let s: Settings = serde_json::from_slice(json).unwrap_or_default(); assert!(s.window_geometry.is_none()); } + + // ----------------------------------------------------------------------- + // shown_achievement_onboarding — first-win cue one-shot guard + // ----------------------------------------------------------------------- + + #[test] + fn settings_shown_achievement_onboarding_default_is_false() { + assert!( + !Settings::default().shown_achievement_onboarding, + "default shown_achievement_onboarding must be false so the cue fires once" + ); + } + + #[test] + fn settings_shown_achievement_onboarding_round_trip() { + let path = tmp_path("achievement_onboarding_round_trip"); + let _ = fs::remove_file(&path); + let s = Settings { + shown_achievement_onboarding: true, + ..Settings::default() + }; + save_settings_to(&path, &s).expect("save"); + let loaded = load_settings_from(&path); + assert!( + loaded.shown_achievement_onboarding, + "shown_achievement_onboarding must survive serde round-trip" + ); + let _ = fs::remove_file(&path); + } + + #[test] + fn legacy_settings_without_shown_achievement_onboarding_deserializes_to_false() { + // A settings.json written by an older version of the game will be + // missing this field entirely. `#[serde(default)]` on the field + // must yield `false` — the cue then fires on the next win, but + // only when stats.games_won == 1, so existing players who have + // already won past their first game won't see the toast either. + let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#; + let s: Settings = serde_json::from_slice(json).unwrap_or_default(); + assert!( + !s.shown_achievement_onboarding, + "legacy settings.json missing shown_achievement_onboarding must deserialize to false" + ); + } } diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs index a6e5f2f..66400eb 100644 --- a/solitaire_engine/src/achievement_plugin.rs +++ b/solitaire_engine/src/achievement_plugin.rs @@ -14,17 +14,19 @@ use solitaire_core::achievement::{ ALL_ACHIEVEMENTS, }; use solitaire_data::{ - achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord, - save_progress_to, + achievements_file_path, load_achievements_from, save_achievements_to, save_settings_to, + AchievementRecord, save_progress_to, }; use crate::events::{ - AchievementUnlockedEvent, GameWonEvent, ToggleAchievementsRequestEvent, XpAwardedEvent, + 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::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, @@ -91,6 +93,7 @@ impl Plugin for AchievementPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() // Run after GameMutation (so GameWonEvent is available), after // StatsUpdate (so stats reflect this win), and after ProgressUpdate @@ -102,6 +105,16 @@ impl Plugin for AchievementPlugin { .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); } @@ -209,6 +222,67 @@ fn evaluate_on_win( } } +/// 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 { @@ -921,4 +995,187 @@ mod tests { 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" + ); + } + + /// Without any `GameWonEvent` arriving the system must be a no-op: + /// no toast, no flag flip — even on update ticks where stats happen + /// to read `games_won == 1`. + #[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" + ); + } }