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 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-24 12:50:46 -07:00
parent b9957909b1
commit 82fa584cbb
3 changed files with 324 additions and 0 deletions
+318
View File
@@ -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 (023) at the time of win. `None` if unknown.
pub wall_clock_hour: Option<u32>,
}
/// 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());
}
}
+5
View File
@@ -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<StateSnapshot>,
}
@@ -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(())
}
+1
View File
@@ -1,3 +1,4 @@
pub mod achievement;
pub mod card;
pub mod deck;
pub mod error;