From 82fa584cbb08378c8a3069838feb2b2cee548a47 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 24 Apr 2026 12:50:46 -0700 Subject: [PATCH] feat(core): add achievement module with 14 unlock conditions Introduces AchievementContext (stats + last-win snapshot), AchievementDef, ALL_ACHIEVEMENTS, and check_achievements. Adds undo_count to GameState so the no_undo and speed_and_skill conditions are evaluable. Skipped achievements that depend on features not yet built: daily_devotee (progress), comeback (recycle counter), zen_winner (modes), perfectionist (max-score calc). They land in later phases. Co-Authored-By: Claude Opus 4.7 --- solitaire_core/src/achievement.rs | 318 ++++++++++++++++++++++++++++++ solitaire_core/src/game_state.rs | 5 + solitaire_core/src/lib.rs | 1 + 3 files changed, 324 insertions(+) create mode 100644 solitaire_core/src/achievement.rs diff --git a/solitaire_core/src/achievement.rs b/solitaire_core/src/achievement.rs new file mode 100644 index 0000000..193add8 --- /dev/null +++ b/solitaire_core/src/achievement.rs @@ -0,0 +1,318 @@ +//! Static achievement definitions + evaluation. +//! +//! `solitaire_core` cannot import from `solitaire_data`, so conditions are +//! not given `StatsSnapshot` directly — the engine packages the relevant +//! stats fields into an [`AchievementContext`] at evaluation time. +//! +//! Evaluation is called once per [`GameWonEvent`] in the engine: the engine +//! walks `ALL_ACHIEVEMENTS`, evaluates each `condition`, and emits an +//! unlock event for any `AchievementDef` whose record is not yet unlocked. + +/// Fields needed by achievement conditions. Constructed by the engine from +/// `StatsSnapshot`, the final `GameState`, and wall-clock time. +#[derive(Debug, Clone)] +pub struct AchievementContext { + // Stats (after this win has been recorded). + pub games_played: u32, + pub games_won: u32, + pub win_streak_current: u32, + pub best_single_score: u32, + pub lifetime_score: u64, + pub draw_three_wins: u32, + + // Last-win facts (GameWonEvent + GameState at win time). + pub last_win_score: i32, + pub last_win_time_seconds: u64, + /// `true` if `undo()` was called at least once during the won game. + pub last_win_used_undo: bool, + + /// Local hour (0–23) at the time of win. `None` if unknown. + pub wall_clock_hour: Option, +} + +/// A single achievement's static metadata + unlock condition. +#[derive(Debug, Clone, Copy)] +pub struct AchievementDef { + pub id: &'static str, + pub name: &'static str, + pub description: &'static str, + /// Hidden from the achievements screen until unlocked. + pub secret: bool, + pub condition: fn(&AchievementContext) -> bool, +} + +impl AchievementDef { + pub fn is_unlocked_by(&self, ctx: &AchievementContext) -> bool { + (self.condition)(ctx) + } +} + +// --------------------------------------------------------------------------- +// Condition predicates +// --------------------------------------------------------------------------- + +fn first_win(c: &AchievementContext) -> bool { + c.games_won >= 1 +} +fn on_a_roll(c: &AchievementContext) -> bool { + c.win_streak_current >= 3 +} +fn unstoppable(c: &AchievementContext) -> bool { + c.win_streak_current >= 10 +} +fn century(c: &AchievementContext) -> bool { + c.games_played >= 100 +} +fn veteran(c: &AchievementContext) -> bool { + c.games_played >= 500 +} +fn speed_demon(c: &AchievementContext) -> bool { + c.last_win_time_seconds < 180 +} +fn lightning(c: &AchievementContext) -> bool { + c.last_win_time_seconds < 90 +} +fn high_scorer(c: &AchievementContext) -> bool { + c.best_single_score >= 5_000 +} +fn point_machine(c: &AchievementContext) -> bool { + c.lifetime_score >= 50_000 +} +fn no_undo(c: &AchievementContext) -> bool { + !c.last_win_used_undo +} +fn draw_three_master(c: &AchievementContext) -> bool { + c.draw_three_wins >= 10 +} +fn night_owl(c: &AchievementContext) -> bool { + // "Play after midnight" — 00:00 through 05:59 local time. + matches!(c.wall_clock_hour, Some(h) if h < 6) +} +fn early_bird(c: &AchievementContext) -> bool { + // "Play before 6am" — same window as night_owl; both unlock together + // when someone wins in the small hours. Retained for progression variety. + matches!(c.wall_clock_hour, Some(h) if h < 6) +} +fn speed_and_skill(c: &AchievementContext) -> bool { + c.last_win_time_seconds < 90 && !c.last_win_used_undo +} + +/// All currently-evaluable achievements. Order is stable so persistence files +/// remain readable across versions (new achievements append). +pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ + AchievementDef { + id: "first_win", + name: "First Win", + description: "Win your first game", + secret: false, + condition: first_win, + }, + AchievementDef { + id: "on_a_roll", + name: "On a Roll", + description: "Win 3 games in a row", + secret: false, + condition: on_a_roll, + }, + AchievementDef { + id: "unstoppable", + name: "Unstoppable", + description: "Win 10 games in a row", + secret: false, + condition: unstoppable, + }, + AchievementDef { + id: "century", + name: "Century", + description: "Play 100 games", + secret: false, + condition: century, + }, + AchievementDef { + id: "veteran", + name: "Veteran", + description: "Play 500 games", + secret: false, + condition: veteran, + }, + AchievementDef { + id: "speed_demon", + name: "Speed Demon", + description: "Win in under 3 minutes", + secret: false, + condition: speed_demon, + }, + AchievementDef { + id: "lightning", + name: "Lightning", + description: "Win in under 90 seconds", + secret: false, + condition: lightning, + }, + AchievementDef { + id: "high_scorer", + name: "High Scorer", + description: "Score at least 5,000 in one game", + secret: false, + condition: high_scorer, + }, + AchievementDef { + id: "point_machine", + name: "Point Machine", + description: "Accumulate 50,000 lifetime points", + secret: false, + condition: point_machine, + }, + AchievementDef { + id: "no_undo", + name: "No Undo", + description: "Win a game without using undo", + secret: false, + condition: no_undo, + }, + AchievementDef { + id: "draw_three_master", + name: "Draw 3 Master", + description: "Win 10 games in Draw 3 mode", + secret: false, + condition: draw_three_master, + }, + AchievementDef { + id: "night_owl", + name: "Night Owl", + description: "Win a game after midnight", + secret: false, + condition: night_owl, + }, + AchievementDef { + id: "early_bird", + name: "Early Bird", + description: "Win a game before 6am", + secret: false, + condition: early_bird, + }, + AchievementDef { + id: "speed_and_skill", + name: "???", + description: "A secret achievement", + secret: true, + condition: speed_and_skill, + }, +]; + +/// Return every `AchievementDef` whose condition is satisfied by `ctx`. +pub fn check_achievements(ctx: &AchievementContext) -> Vec<&'static AchievementDef> { + ALL_ACHIEVEMENTS + .iter() + .filter(|d| d.is_unlocked_by(ctx)) + .collect() +} + +/// Look up an achievement definition by ID. +pub fn achievement_by_id(id: &str) -> Option<&'static AchievementDef> { + ALL_ACHIEVEMENTS.iter().find(|d| d.id == id) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx() -> AchievementContext { + AchievementContext { + games_played: 0, + games_won: 0, + win_streak_current: 0, + best_single_score: 0, + lifetime_score: 0, + draw_three_wins: 0, + last_win_score: 0, + last_win_time_seconds: u64::MAX, + last_win_used_undo: true, + wall_clock_hour: None, + } + } + + #[test] + fn all_ids_are_unique() { + let mut ids: Vec<&str> = ALL_ACHIEVEMENTS.iter().map(|d| d.id).collect(); + ids.sort(); + let len = ids.len(); + ids.dedup(); + assert_eq!(ids.len(), len, "duplicate achievement ID in ALL_ACHIEVEMENTS"); + } + + #[test] + fn no_achievements_unlocked_at_default() { + let c = ctx(); + assert!(check_achievements(&c).is_empty()); + } + + #[test] + fn first_win_unlocks_on_first_won_game() { + let mut c = ctx(); + c.games_won = 1; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"first_win")); + } + + #[test] + fn lightning_requires_under_90_seconds() { + let mut c = ctx(); + c.games_won = 1; + c.last_win_time_seconds = 89; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"lightning")); + assert!(ids.contains(&"speed_demon")); + + c.last_win_time_seconds = 90; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"lightning")); + assert!(ids.contains(&"speed_demon")); + } + + #[test] + fn no_undo_requires_clean_win() { + let mut c = ctx(); + c.games_won = 1; + c.last_win_used_undo = false; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"no_undo")); + + c.last_win_used_undo = true; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"no_undo")); + } + + #[test] + fn secret_speed_and_skill_requires_both_clean_and_fast() { + let mut c = ctx(); + c.games_won = 1; + c.last_win_time_seconds = 60; + c.last_win_used_undo = false; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"speed_and_skill")); + + c.last_win_used_undo = true; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"speed_and_skill")); + } + + #[test] + fn night_owl_requires_early_hours() { + let mut c = ctx(); + c.games_won = 1; + c.wall_clock_hour = Some(2); + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"night_owl")); + + c.wall_clock_hour = Some(12); + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"night_owl")); + } + + #[test] + fn achievement_by_id_finds_known_and_returns_none_for_unknown() { + assert_eq!(achievement_by_id("first_win").map(|d| d.name), Some("First Win")); + assert!(achievement_by_id("nonexistent").is_none()); + } +} diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 530df99..3bc7784 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -35,6 +35,9 @@ pub struct GameState { pub seed: u64, pub is_won: bool, pub is_auto_completable: bool, + /// Number of times `undo()` has been successfully invoked this game. + /// Used by achievement conditions like `no_undo`. + pub undo_count: u32, undo_stack: Vec, } @@ -64,6 +67,7 @@ impl GameState { seed, is_won: false, is_auto_completable: false, + undo_count: 0, undo_stack: Vec::new(), } } @@ -242,6 +246,7 @@ impl GameState { self.move_count = snapshot.move_count; self.is_won = false; self.is_auto_completable = false; + self.undo_count = self.undo_count.saturating_add(1); Ok(()) } diff --git a/solitaire_core/src/lib.rs b/solitaire_core/src/lib.rs index 2c600b3..89409e2 100644 --- a/solitaire_core/src/lib.rs +++ b/solitaire_core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod achievement; pub mod card; pub mod deck; pub mod error;