diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5120de1..1e04e0a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -716,11 +716,14 @@ pub struct AchievementDef { | `speed_and_skill` | ??? | Win < 90s without undo | Yes | Card back #4 | | `comeback` | ??? | Win after 3+ stock recycles | Yes | Background #4 | | `zen_winner` | ??? | Win in Zen Mode | Yes | Badge | +| `cinephile` | Cinephile | Watch a saved replay all the way through | No | — | ### Evaluation Timing Achievement conditions are evaluated by `AchievementPlugin` on every `GameWonEvent` and `StateChangedEvent`. The plugin calls `solitaire_core::check_achievements()` which returns a `Vec` of newly unlocked achievements. The plugin then fires `AchievementUnlockedEvent` for each, which the toast and persistence systems handle independently. +A small number of achievements are *event-driven* rather than condition-driven: their `AchievementDef::condition` always returns `false` and their unlock is written from a dedicated observer system instead. `cinephile` is the canonical example — it unlocks when `ReplayPlaybackState` transitions from `Playing` to `Completed` (a saved replay watched to its natural end). The Stop button transitions `Playing → Inactive` directly without entering `Completed`, so manual aborts do not unlock the achievement. + --- ## 12. Progression System diff --git a/README.md b/README.md index 6bcb907..8a6bb96 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ optional self-hosted sync so your stats follow you across machines. move within picker rows, Enter activates; works across every modal and the HUD action bar - **Progression** — XP, levels, unlockable card backs and backgrounds -- **18 Achievements** — including secret ones +- **19 Achievements** — including secret ones - **Daily Challenge** — server-seeded so every player worldwide gets the same deal - **Leaderboard** — opt-in, powered by your own self-hosted server diff --git a/solitaire_core/src/achievement.rs b/solitaire_core/src/achievement.rs index e26bf6b..2faec2a 100644 --- a/solitaire_core/src/achievement.rs +++ b/solitaire_core/src/achievement.rs @@ -140,6 +140,16 @@ fn comeback(c: &AchievementContext) -> bool { fn zen_winner(c: &AchievementContext) -> bool { c.last_win_is_zen } +/// Cinephile is event-driven: it unlocks when the engine observes a +/// `ReplayPlaybackState` transition from `Playing` to `Completed`, not on +/// any field of [`AchievementContext`]. The condition predicate therefore +/// always returns false so [`check_achievements`] never unlocks it from a +/// `GameWonEvent` / `StateChangedEvent` cycle — the unlock is driven by +/// `AchievementUnlockedEvent` written directly from the engine's +/// replay-playback observer. +fn cinephile_never(_c: &AchievementContext) -> bool { + false +} /// All currently-evaluable achievements. Order is stable so persistence files /// remain readable across versions (new achievements append). @@ -288,6 +298,18 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ reward: Some(Reward::Badge), condition: zen_winner, }, + AchievementDef { + id: "cinephile", + name: "Cinephile", + description: "Watch a saved replay all the way through", + secret: false, + reward: None, + // Event-driven unlock: the engine's replay-playback observer fires + // `AchievementUnlockedEvent("cinephile")` directly on a Playing → + // Completed transition. `cinephile_never` keeps the condition path + // a no-op so a `GameWonEvent` evaluation cycle cannot unlock it. + condition: cinephile_never, + }, ]; /// Return every `AchievementDef` whose condition is satisfied by `ctx`. @@ -721,6 +743,31 @@ mod tests { assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does"); } + #[test] + fn cinephile_achievement_in_canonical_list() { + let def = achievement_by_id("cinephile").expect("cinephile must be registered"); + assert_eq!(def.id, "cinephile"); + assert_eq!(def.name, "Cinephile"); + assert!(!def.secret, "cinephile is not a secret achievement"); + // Event-driven: the predicate is a sentinel that always returns + // false. `check_achievements` must never unlock cinephile from a + // GameWonEvent context, even one that satisfies every other gate. + let mut c = ctx(); + c.games_won = 1; + c.win_streak_current = 999; + c.last_win_time_seconds = 1; + c.last_win_used_undo = false; + c.best_single_score = 99_999; + c.lifetime_score = u64::MAX; + c.last_win_is_zen = true; + c.last_win_recycle_count = 99; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!( + !ids.contains(&"cinephile"), + "cinephile must never unlock via condition evaluation; got {ids:?}", + ); + } + #[test] fn perfectionist_score_well_above_threshold_still_passes() { let mut c = ctx(); diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs index 66400eb..bf5150f 100644 --- a/solitaire_engine/src/achievement_plugin.rs +++ b/solitaire_engine/src/achievement_plugin.rs @@ -25,6 +25,7 @@ use crate::events::{ 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}; @@ -116,7 +117,12 @@ impl Plugin for AchievementPlugin { .after(StatsUpdate), ) .add_systems(Update, toggle_achievements_screen) - .add_systems(Update, handle_achievements_close_button); + .add_systems(Update, handle_achievements_close_button) + // 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); } } @@ -222,6 +228,66 @@ fn evaluate_on_win( } } +/// 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 @@ -1149,9 +1215,215 @@ mod tests { ); } - /// 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`. + // ----------------------------------------------------------------------- + // 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, + }; + 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, + }; + 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, + }; + 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, + }; + 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, + }; + 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();