feat(engine): one-shot achievement-onboarding toast on first win
After the player's very first win the engine now writes "First win! Press A to see your achievements." via InfoToastEvent, then flips a persisted Settings.shown_achievement_onboarding flag so the cue never re-fires. Mentions the A hotkey by name so the toast is actionable on its own. The toast path runs after StatsUpdate so games_won has been incremented to 1 when the system reads it; .after(GameMutation) keeps the post-move state visible. Three guards: first win only, flag was false, GameWonEvent fired this tick. Persistence mirrors onboarding_plugin's complete_onboarding pattern: save via save_settings_to with the existing SettingsStoragePath/Option<&PathBuf> graceful-fallback shape. Atomic .tmp+rename writes are unchanged. Settings gains a single bool field with #[serde(default)] so legacy settings.json files deserialize cleanly to false. The field is local-only by design — it's about UI teaching for THIS device, not progression — so SyncPayload and merge logic are untouched. Seven new tests pin the contract: default value is false, field round-trips through save/load, legacy JSON without the field deserializes to false, first win fires the toast and flips the flag, subsequent wins are silent, the fifth win on a synced device is silent (won't fire when games_won has been bumped via sync), and no win event means no toast. Toast duration is the existing animation_plugin QUEUED_TOAST_SECS = 2.5 s — InfoToastEvent is a tuple struct with no duration parameter, so the agent kept the existing event shape rather than expanding it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<AchievementUnlockedEvent>()
|
||||
.add_message::<GameWonEvent>()
|
||||
.add_message::<XpAwardedEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ToggleAchievementsRequestEvent>()
|
||||
// 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<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 {
|
||||
@@ -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::<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"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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::<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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user