Files
Ferrous-Solitaire/solitaire_core/src/achievement.rs
T
funman300 622b35a3bf feat(engine): add daily challenge, level-up toast, and daily_devotee achievement
Phase 6 part 2 (partial):
- daily_seed_for(date) and PlayerProgress::record_daily_completion in
  solitaire_data, with streak logic that increments on consecutive days,
  resets on a skipped day, and is idempotent on same-day re-completions.
- DailyChallengePlugin tracks today's seed, awards +100 XP and updates
  the streak when the player wins a game whose seed matches. Pressing C
  starts a new game with the daily seed.
- LevelUpEvent toast in AnimationPlugin announces level changes.
- AchievementContext gains daily_challenge_streak; daily_devotee
  achievement unlocks at streak >= 7. AchievementPlugin reads
  ProgressResource and runs after ProgressUpdate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:17:59 -07:00

346 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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,
// Progression.
/// Current daily-challenge completion streak (consecutive days).
pub daily_challenge_streak: 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
}
fn daily_devotee(c: &AchievementContext) -> bool {
c.daily_challenge_streak >= 7
}
/// 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,
},
AchievementDef {
id: "daily_devotee",
name: "Daily Devotee",
description: "Complete the daily challenge 7 days in a row",
secret: false,
condition: daily_devotee,
},
];
/// 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,
daily_challenge_streak: 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 daily_devotee_requires_7_day_streak() {
let mut c = ctx();
c.daily_challenge_streak = 6;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"daily_devotee"));
c.daily_challenge_streak = 7;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"daily_devotee"));
}
#[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());
}
}