Files
Ferrous-Solitaire/solitaire_core/src/achievement.rs
T
funman300 ffc79447d4 fix+refactor+docs: P0–P3 todo list items
P0 fixes:
- Register WinSummaryPlugin, SelectionPlugin, CardAnimationPlugin in main.rs
  (all three were exported but never wired — features silently did nothing)
- game_state::draw(): increment move_count on waste→stock recycle, not just
  on normal draws; add move_count_increments_on_recycle regression test

P1 fixes:
- solitaire_server/Cargo.toml: remove duplicate dev-dependencies
  (solitaire_sync, uuid, chrono, jsonwebtoken were in both sections)

P2 — input_plugin refactor:
- Split 198-line handle_keyboard() into three focused systems under 110 lines each:
  handle_keyboard_core (U/N/Z/D/Space), handle_keyboard_hint (H), handle_keyboard_forfeit (G)
- Introduce KeyboardConfirmState resource to share countdown timers across systems
- Add three new unit tests: all_hints_suggests_draw_*, all_hints_is_empty_when_truly_stuck,
  new_game_confirm_window_is_positive

P2 — achievement predicate tests (solitaire_core):
- Add 10 direct unit tests for speed_demon, lightning, no_undo, high_scorer,
  on_a_roll, comeback predicates (previously only covered via check_achievements())
- 141 core tests now passing

P2 — server tests:
- solitaire_server/src/sync.rs: 4 unit tests for merge logic (no DB required)
- solitaire_server/src/leaderboard.rs: 2 unit tests for entry shape and sort order

P3 — documentation:
- Add struct-level ///  to 12 Plugin structs (ChallengePlugin, CursorPlugin,
  AnimationPlugin, HelpPlugin, PausePlugin, AudioPlugin, DailyChallengePlugin,
  HudPlugin, LeaderboardPlugin, OnboardingPlugin, TimeAttackPlugin, WeeklyGoalsPlugin)
- Add field-level /// to Card, Pile, Deck, GameState, AchievementContext, AchievementDef
- Add /// to WeeklyGoalKind, WeeklyGoalDef, WeeklyGoalContext, StatsExt::update_on_win

card_animation module (new files from previous session):
- chain.rs, diagnostics.rs, tuning.rs, updated interaction.rs/animation.rs/mod.rs/lib.rs
- Remove unused HOVER_SCALE_DEFAULT / DRAG_LIFT_SCALE_DEFAULT / HOVER_LERP_SPEED_DEFAULT constants
- Add handle_touch_stock_tap so touch users can draw from the stock pile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:02:52 +00:00

735 lines
25 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 {
/// Total number of games played (after this win has been recorded).
pub games_played: u32,
/// Total number of games won (after this win has been recorded).
pub games_won: u32,
/// Current consecutive win streak (after this win has been recorded).
pub win_streak_current: u32,
/// Highest single-game score ever achieved.
pub best_single_score: u32,
/// Cumulative score across all games ever played.
pub lifetime_score: u64,
/// Total wins completed in Draw 3 mode.
pub draw_three_wins: u32,
/// Current daily-challenge completion streak (consecutive days).
pub daily_challenge_streak: u32,
/// Score achieved in the just-won game.
pub last_win_score: i32,
/// Elapsed seconds for the just-won game.
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>,
/// Number of times waste was recycled back to stock during the won game.
pub last_win_recycle_count: u32,
/// `true` if the game was played in Zen mode.
pub last_win_is_zen: bool,
}
/// Reward granted when an achievement is first unlocked.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reward {
/// Unlocks a card-back design at the given index (0 is always unlocked).
CardBack(usize),
/// Unlocks a background design at the given index (0 is always unlocked).
Background(usize),
/// Awards bonus XP on top of the standard win XP.
BonusXp(u64),
/// A visual badge — no gameplay effect.
Badge,
}
/// A single achievement's static metadata + unlock condition.
#[derive(Debug, Clone, Copy)]
pub struct AchievementDef {
/// Unique string identifier for this achievement (e.g. `"first_win"`).
pub id: &'static str,
/// Human-readable display name shown in the achievements screen.
pub name: &'static str,
/// Flavour text describing how to unlock the achievement.
pub description: &'static str,
/// Hidden from the achievements screen until unlocked.
pub secret: bool,
/// Reward granted on first unlock. `None` for cosmetic-only recognition.
pub reward: Option<Reward>,
/// Predicate evaluated on every `GameWonEvent` and `StateChangedEvent`. Returns true when the achievement should unlock.
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 {
// Late-night session: 22:0002:59 local time.
matches!(c.wall_clock_hour, Some(h) if !(3..22).contains(&h))
}
fn early_bird(c: &AchievementContext) -> bool {
// Early-morning session: 05:0006:59 local time.
matches!(c.wall_clock_hour, Some(h) if (5..7).contains(&h))
}
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
}
fn perfectionist(c: &AchievementContext) -> bool {
!c.last_win_used_undo && c.last_win_score >= 5_000
}
fn comeback(c: &AchievementContext) -> bool {
c.last_win_recycle_count >= 3
}
fn zen_winner(c: &AchievementContext) -> bool {
c.last_win_is_zen
}
/// 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,
reward: None,
condition: first_win,
},
AchievementDef {
id: "on_a_roll",
name: "On a Roll",
description: "Win 3 games in a row",
secret: false,
reward: Some(Reward::CardBack(1)),
condition: on_a_roll,
},
AchievementDef {
id: "unstoppable",
name: "Unstoppable",
description: "Win 10 games in a row",
secret: false,
reward: Some(Reward::Background(1)),
condition: unstoppable,
},
AchievementDef {
id: "century",
name: "Century",
description: "Play 100 games",
secret: false,
reward: None,
condition: century,
},
AchievementDef {
id: "veteran",
name: "Veteran",
description: "Play 500 games",
secret: false,
reward: Some(Reward::Badge),
condition: veteran,
},
AchievementDef {
id: "speed_demon",
name: "Speed Demon",
description: "Win in under 3 minutes",
secret: false,
reward: None,
condition: speed_demon,
},
AchievementDef {
id: "lightning",
name: "Lightning",
description: "Win in under 90 seconds",
secret: false,
reward: Some(Reward::CardBack(2)),
condition: lightning,
},
AchievementDef {
id: "high_scorer",
name: "High Scorer",
description: "Score at least 5,000 in one game",
secret: false,
reward: None,
condition: high_scorer,
},
AchievementDef {
id: "point_machine",
name: "Point Machine",
description: "Accumulate 50,000 lifetime points",
secret: false,
reward: Some(Reward::Background(2)),
condition: point_machine,
},
AchievementDef {
id: "no_undo",
name: "No Undo",
description: "Win a game without using undo",
secret: false,
reward: Some(Reward::BonusXp(25)),
condition: no_undo,
},
AchievementDef {
id: "draw_three_master",
name: "Draw 3 Master",
description: "Win 10 games in Draw 3 mode",
secret: false,
reward: Some(Reward::CardBack(3)),
condition: draw_three_master,
},
AchievementDef {
id: "night_owl",
name: "Night Owl",
description: "Win a game between 10pm and 3am",
secret: false,
reward: None,
condition: night_owl,
},
AchievementDef {
id: "early_bird",
name: "Early Bird",
description: "Win a game between 5am and 7am",
secret: false,
reward: None,
condition: early_bird,
},
AchievementDef {
id: "speed_and_skill",
name: "???",
description: "A secret achievement",
secret: true,
reward: Some(Reward::CardBack(4)),
condition: speed_and_skill,
},
AchievementDef {
id: "daily_devotee",
name: "Daily Devotee",
description: "Complete the daily challenge 7 days in a row",
secret: false,
reward: Some(Reward::Background(3)),
condition: daily_devotee,
},
AchievementDef {
id: "perfectionist",
name: "Perfectionist",
description: "Win without undo and score at least 5,000",
secret: false,
reward: Some(Reward::Badge),
condition: perfectionist,
},
AchievementDef {
id: "comeback",
name: "???",
description: "A secret achievement",
secret: true,
reward: Some(Reward::Background(4)),
condition: comeback,
},
AchievementDef {
id: "zen_winner",
name: "???",
description: "A secret achievement",
secret: true,
reward: Some(Reward::Badge),
condition: zen_winner,
},
];
/// 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,
last_win_recycle_count: 0,
last_win_is_zen: false,
}
}
#[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_triggers_in_late_night_window() {
let mut c = ctx();
c.games_won = 1;
// Late night: 22:0002:59
for hour in [22u32, 23, 0, 1, 2] {
c.wall_clock_hour = Some(hour);
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"night_owl"), "expected night_owl at hour {hour}");
}
// Daytime hours must not trigger.
for hour in [3u32, 7, 12, 20, 21] {
c.wall_clock_hour = Some(hour);
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"night_owl"), "unexpected night_owl at hour {hour}");
}
}
#[test]
fn early_bird_triggers_in_morning_window() {
let mut c = ctx();
c.games_won = 1;
// Early morning: 05:0006:59
for hour in [5u32, 6] {
c.wall_clock_hour = Some(hour);
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"early_bird"), "expected early_bird at hour {hour}");
}
// Outside the window must not trigger.
for hour in [0u32, 3, 4, 7, 12, 23] {
c.wall_clock_hour = Some(hour);
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"early_bird"), "unexpected early_bird at hour {hour}");
}
}
#[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 perfectionist_requires_no_undo_and_high_score() {
let mut c = ctx();
c.last_win_used_undo = false;
c.last_win_score = 5_000;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"perfectionist"));
c.last_win_used_undo = true;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"perfectionist"));
c.last_win_used_undo = false;
c.last_win_score = 4_999;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"perfectionist"));
}
#[test]
fn comeback_requires_at_least_three_recycles() {
let mut c = ctx();
c.last_win_recycle_count = 2;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"comeback"));
c.last_win_recycle_count = 3;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"comeback"));
}
#[test]
fn zen_winner_requires_zen_mode() {
let mut c = ctx();
c.last_win_is_zen = false;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"zen_winner"));
c.last_win_is_zen = true;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"zen_winner"));
}
#[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());
}
// -----------------------------------------------------------------------
// Direct predicate tests via ctx_defaults()
// -----------------------------------------------------------------------
/// Baseline context representing a single clean one-minute win in Draw-One mode.
fn ctx_defaults() -> AchievementContext {
AchievementContext {
games_played: 1,
games_won: 1,
win_streak_current: 1,
best_single_score: 0,
lifetime_score: 0,
draw_three_wins: 0,
daily_challenge_streak: 0,
last_win_score: 0,
last_win_time_seconds: 600,
last_win_used_undo: false,
wall_clock_hour: Some(12),
last_win_recycle_count: 0,
last_win_is_zen: false,
}
}
#[test]
fn speed_demon_true_when_under_three_minutes() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 179;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"speed_demon"), "speed_demon should unlock at 179s");
}
#[test]
fn speed_demon_false_when_over_three_minutes() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 181;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"speed_demon"), "speed_demon must not unlock at 181s");
}
#[test]
fn lightning_true_when_under_90_seconds() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 89;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"lightning"), "lightning should unlock at 89s");
}
#[test]
fn lightning_false_at_exactly_90_seconds() {
let mut c = ctx_defaults();
c.last_win_time_seconds = 90;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"lightning"), "lightning must not unlock at exactly 90s");
}
#[test]
fn no_undo_true_when_zero_undos() {
let mut c = ctx_defaults();
c.last_win_used_undo = false;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"no_undo"), "no_undo should unlock when undo was not used");
}
#[test]
fn no_undo_false_when_undo_used() {
let mut c = ctx_defaults();
c.last_win_used_undo = true;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"no_undo"), "no_undo must not unlock when undo was used");
}
#[test]
fn high_scorer_true_when_score_5000_or_more() {
let mut c = ctx_defaults();
c.best_single_score = 5_000;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"high_scorer"), "high_scorer should unlock at best_single_score=5000");
}
#[test]
fn high_scorer_false_when_below_5000() {
let mut c = ctx_defaults();
c.best_single_score = 4_999;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"high_scorer"), "high_scorer must not unlock at best_single_score=4999");
}
#[test]
fn on_a_roll_true_at_streak_3() {
let mut c = ctx_defaults();
c.win_streak_current = 3;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock at streak=3");
}
#[test]
fn comeback_true_when_three_or_more_recycles() {
let mut c = ctx_defaults();
c.last_win_recycle_count = 3;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"comeback"), "comeback should unlock at last_win_recycle_count=3");
}
#[test]
fn on_a_roll_requires_streak_of_3() {
let mut c = ctx();
c.win_streak_current = 2;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"on_a_roll"));
c.win_streak_current = 3;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"on_a_roll"));
}
#[test]
fn unstoppable_requires_streak_of_10() {
let mut c = ctx();
c.win_streak_current = 9;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"unstoppable"));
assert!(ids.contains(&"on_a_roll"), "streak 9 must still satisfy on_a_roll");
c.win_streak_current = 10;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"unstoppable"));
assert!(ids.contains(&"on_a_roll"), "streak 10 must also satisfy on_a_roll");
}
#[test]
fn century_requires_100_games_played() {
let mut c = ctx();
c.games_played = 99;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"century"));
c.games_played = 100;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"century"));
}
#[test]
fn veteran_requires_500_games_played() {
let mut c = ctx();
c.games_played = 499;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"veteran"));
assert!(ids.contains(&"century"), "499 games must also satisfy century");
c.games_played = 500;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"veteran"));
assert!(ids.contains(&"century"), "500 games must also satisfy century");
}
#[test]
fn high_scorer_requires_best_single_score_of_5000() {
let mut c = ctx();
c.best_single_score = 4_999;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"high_scorer"));
c.best_single_score = 5_000;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"high_scorer"));
}
#[test]
fn point_machine_requires_50000_lifetime_score() {
let mut c = ctx();
c.lifetime_score = 49_999;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"point_machine"));
c.lifetime_score = 50_000;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"point_machine"));
}
#[test]
fn draw_three_master_requires_10_draw_three_wins() {
let mut c = ctx();
c.draw_three_wins = 9;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"draw_three_master"));
c.draw_three_wins = 10;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"draw_three_master"));
}
#[test]
fn speed_demon_boundary_at_180_seconds() {
let mut c = ctx();
c.games_won = 1;
c.last_win_time_seconds = 179;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"speed_demon"));
c.last_win_time_seconds = 180;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"speed_demon"));
}
#[test]
fn check_achievements_returns_multiple_when_conditions_met() {
// A context where first_win, on_a_roll, and no_undo all trigger at once.
let mut c = ctx();
c.games_won = 1;
c.win_streak_current = 3;
c.last_win_used_undo = false;
c.last_win_time_seconds = 999;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"first_win"), "first_win should unlock");
assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock");
assert!(ids.contains(&"no_undo"), "no_undo should unlock");
assert!(ids.len() >= 3, "at least 3 achievements must fire simultaneously");
}
#[test]
fn perfectionist_implies_no_undo_both_fire_together() {
// perfectionist requires !used_undo && score >= 5000, which is a strict
// superset of no_undo's condition. Both must appear in the result.
let mut c = ctx();
c.games_won = 1;
c.last_win_used_undo = false;
c.last_win_score = 5_000;
c.last_win_time_seconds = 999;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"perfectionist"), "perfectionist must unlock");
assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does");
}
#[test]
fn perfectionist_score_well_above_threshold_still_passes() {
let mut c = ctx();
c.games_won = 1;
c.last_win_used_undo = false;
c.last_win_score = 50_000;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"perfectionist"), "score far above threshold must pass");
}
}