Compare commits
18 Commits
bef7ab3c13
...
6b793aa2ab
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b793aa2ab | |||
| 0fdfbced6d | |||
| 363ddc9b75 | |||
| 0609d4eef3 | |||
| b730902d76 | |||
| 578938a9b2 | |||
| 622b35a3bf | |||
| 0cb8b32ec4 | |||
| ef043c14d4 | |||
| cfdb3b7547 | |||
| 5512a141b6 | |||
| 1f6994a084 | |||
| 4589c52368 | |||
| 82fa584cbb | |||
| b9957909b1 | |||
| 2ce11f8f4d | |||
| 5ced4c01ce | |||
| f8cce2433d |
+63
-22
@@ -1,7 +1,8 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
> Last updated: 2026-04-21
|
||||
> Last updated: 2026-04-24
|
||||
> Branch: `master` — pushed to https://git.aleshym.co/funman300/Rusty_Solitare.git
|
||||
> Test count: **196 passing** (77 core + 50 data + 69 engine), `cargo clippy --workspace -- -D warnings` clean
|
||||
|
||||
---
|
||||
|
||||
@@ -73,33 +74,73 @@ f84d7c5 fix(workspace): add derives/docs per code review, remove unused thiserro
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Bevy Rendering & Interaction ✅ COMPLETE
|
||||
|
||||
All sub-phases (3A–3F) done. Plugins: `GamePlugin`, `TablePlugin`, `CardPlugin`, `InputPlugin`, `AnimationPlugin`. Full game playable — drag/drop with rule validation, keyboard shortcuts (U/N/D/Esc), animated slides, win cascade. UI via `bevy::ui`, no egui.
|
||||
|
||||
### Phase 4 — Statistics Persistence ✅ COMPLETE
|
||||
|
||||
- `solitaire_data::StatsSnapshot` with `update_on_win` / `record_abandoned` / `win_rate`
|
||||
- Atomic file I/O via `save_stats_to` (`.tmp` → rename)
|
||||
- `StatsPlugin` in `solitaire_engine` — loads on startup, persists on `GameWonEvent` (win) and `NewGameRequestEvent` (abandoned if move_count>0 and not won)
|
||||
- Full-window overlay toggled with `S` — games played/won, win rate, streak, best score, fastest, avg
|
||||
- `StatsPlugin::default()` for production, `StatsPlugin::headless()` for tests (no disk I/O)
|
||||
|
||||
### Phase 5 — Achievements ✅ COMPLETE (14 of ~19)
|
||||
|
||||
- `solitaire_core::achievement` — `AchievementContext` + `AchievementDef` + `ALL_ACHIEVEMENTS` + `check_achievements`
|
||||
- `solitaire_core::GameState.undo_count` — tracks whether undo was used (for `no_undo` / `speed_and_skill`)
|
||||
- `solitaire_data::AchievementRecord` + atomic `achievements.json` persistence
|
||||
- `AchievementPlugin` — on `GameWonEvent`, build context from `StatsResource` + `GameState` + `chrono::Local` hour, evaluate all conditions, persist newly-unlocked records, emit `AchievementUnlockedEvent(id)`
|
||||
- `AnimationPlugin`'s toast resolves the event's ID to the achievement's name via `achievement_plugin::display_name_for`
|
||||
- New `StatsUpdate` system set lets `AchievementPlugin` order itself after stats are incremented
|
||||
- Deferred: `daily_devotee` (needs `PlayerProgress`), `comeback` (needs recycle counter), `zen_winner` (needs modes), `perfectionist` (needs max-score calc). Stubs can be added in later phases.
|
||||
|
||||
### Phase 6 (part 1) — XP, Levels, ProgressPlugin ✅ COMPLETE
|
||||
|
||||
- `solitaire_data::PlayerProgress` with `total_xp`, `level`, daily/weekly/unlock fields
|
||||
- `level_for_xp(xp)` and `xp_for_win(time, used_undo)` helpers (per ARCHITECTURE.md §13)
|
||||
- `add_xp(amount) -> prev_level` with `leveled_up_from(prev)` for level-up detection
|
||||
- Atomic `progress.json` persistence via `save_progress_to` / `load_progress_from`
|
||||
- `ProgressPlugin` — on `GameWonEvent`, awards XP (base 50 + speed bonus 10–50 + no-undo 25), persists, emits `LevelUpEvent`
|
||||
- `ProgressUpdate` system set for ordering downstream systems
|
||||
- `ProgressPlugin::default()` for production, `::headless()` for tests
|
||||
|
||||
### Phase 6 (part 2a) — Daily Challenge + Level-Up Toast ✅ COMPLETE
|
||||
|
||||
- `daily_seed_for(date)` deterministic per-date seed
|
||||
- `PlayerProgress::record_daily_completion(date)` with streak / reset / idempotency rules
|
||||
- `DailyChallengePlugin`: today's seed in a resource; pressing **C** starts a daily-seed new game; on winning a daily-seed game, awards **+100 XP**, updates streak, persists, fires `DailyChallengeCompletedEvent`
|
||||
- `LevelUpEvent` now spawns a toast through `AnimationPlugin`
|
||||
- `daily_devotee` achievement wired (streak ≥ 7); `AchievementContext` gains `daily_challenge_streak` and reads from `ProgressResource`
|
||||
|
||||
### Phase 6 (part 2b) — Weekly Goals ✅ COMPLETE
|
||||
|
||||
- `solitaire_data::weekly` — `WeeklyGoalKind`, `WeeklyGoalDef`, `WeeklyGoalContext`, `current_iso_week_key`, three starter goals (5 wins / 3 no-undo / 3 fast)
|
||||
- `PlayerProgress` — `weekly_goal_week_iso`, `roll_weekly_goals_if_new_week`, `record_weekly_progress`
|
||||
- `WeeklyGoalsPlugin` — on `GameWonEvent`, rolls week if needed, increments matching goals, awards `WEEKLY_GOAL_XP` (75) per completion, fires `WeeklyGoalCompletedEvent`
|
||||
|
||||
### Phase 6 (part 3) — Completion Toasts + Progression Panel ✅ COMPLETE
|
||||
|
||||
- `AnimationPlugin` now surfaces `DailyChallengeCompletedEvent` (shows streak) and `WeeklyGoalCompletedEvent` (shows goal description) as 3-second toasts.
|
||||
- Stats overlay (**S** key) appends a Progression section: level, total XP, daily streak, and a Weekly Goals list iterating `WEEKLY_GOALS` with `progress/target` for each.
|
||||
|
||||
## What Is Next
|
||||
|
||||
### Phase 3 — Bevy Rendering & Interaction (`solitaire_engine`)
|
||||
### Phase 6 (part 4) — Special Modes + Unlock UI
|
||||
|
||||
This is the next phase to implement. Key tasks:
|
||||
- Time Attack / Challenge / Zen modes (unlock at level 5). Add a `GameMode` enum in `solitaire_core::game_state`; `GameState` tracks its mode; Zen skips scoring, Challenge disables undo, Time Attack ends on timer.
|
||||
- Mode selector UI + keyboard shortcut (e.g. `Z` for Zen) + extend `NewGameRequestEvent` with an optional mode.
|
||||
- Card-back / background unlock UI for `unlocked_card_backs` / `unlocked_backgrounds`.
|
||||
- Elapsed-time tracking — currently `GameState.elapsed_seconds` stays at 0; wire a timer system.
|
||||
|
||||
- Add `GameStateResource`, `DragState`, `SyncStatusResource` Bevy resources
|
||||
- Add Bevy events: `MoveRequestEvent`, `DrawRequestEvent`, `UndoRequestEvent`, `NewGameRequestEvent`, `StateChangedEvent`, `GameWonEvent`
|
||||
- `CardPlugin` — spawn card entities with 2D sprites, drag-and-drop input
|
||||
- `TablePlugin` — pile markers, table background, layout calculation from window size
|
||||
- `AnimationPlugin` — card slide (lerp 0.15s), flip (scale X 0.2s), win cascade, toast
|
||||
- `GamePlugin` — wire `GameStateResource`, route input events to `solitaire_core::GameState`
|
||||
- Responsive layout: recalculate positions on `WindowResized`
|
||||
- Keyboard shortcuts: U=undo, N=new game, D=draw, Escape=pause
|
||||
|
||||
See the full spec in the master prompt (originally pasted by the user) or in `ARCHITECTURE.md` section 5.
|
||||
|
||||
### Phases 4–8 (in order after Phase 3)
|
||||
### Phases 7–8 (in order after Phase 6 part 4)
|
||||
|
||||
| Phase | Scope |
|
||||
|---|---|
|
||||
| Phase 4 | Statistics (`StatsSnapshot`, persist to `stats.json`, stats screen in egui) |
|
||||
| Phase 5 | Achievements (20+ achievements, `AchievementPlugin`, toast queue) |
|
||||
| Phase 6 | XP/levels, daily challenges, weekly goals, special modes |
|
||||
| Phase 7 | Audio (`bevy_kira_audio`), polish, hints, onboarding, pause menu |
|
||||
| Phase 7 | Audio (`kira`), polish, hints, onboarding, pause menu |
|
||||
| Phase 8A–C | Local storage + `SyncProvider` + self-hosted Axum server + client |
|
||||
| Phase 8D | GPGS stub fully wired into settings UI (already compiles, just UI) |
|
||||
| Phase 8D | GPGS stub fully wired into settings UI |
|
||||
|
||||
---
|
||||
|
||||
@@ -150,12 +191,12 @@ For Phase 3 onwards, write a new plan using the `superpowers:writing-plans` skil
|
||||
# Check everything compiles
|
||||
cargo check --workspace
|
||||
|
||||
# Run all tests (68 tests, all should pass)
|
||||
# Run all tests (196 tests, all should pass)
|
||||
cargo test --workspace
|
||||
|
||||
# Lint (must be zero warnings)
|
||||
cargo clippy --workspace -- -D warnings
|
||||
|
||||
# Run the game (blank window for now — rendering added in Phase 3)
|
||||
# Run the game
|
||||
cargo run -p solitaire_app --features bevy/dynamic_linking
|
||||
```
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use bevy::prelude::*;
|
||||
use solitaire_engine::{AnimationPlugin, CardPlugin, GamePlugin, InputPlugin, TablePlugin};
|
||||
use solitaire_engine::{
|
||||
AchievementPlugin, AnimationPlugin, CardPlugin, DailyChallengePlugin, GamePlugin, InputPlugin,
|
||||
ProgressPlugin, StatsPlugin, TablePlugin, WeeklyGoalsPlugin,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
App::new()
|
||||
@@ -18,5 +21,10 @@ fn main() {
|
||||
.add_plugins(CardPlugin)
|
||||
.add_plugins(InputPlugin)
|
||||
.add_plugins(AnimationPlugin)
|
||||
.add_plugins(StatsPlugin::default())
|
||||
.add_plugins(ProgressPlugin::default())
|
||||
.add_plugins(AchievementPlugin::default())
|
||||
.add_plugins(DailyChallengePlugin)
|
||||
.add_plugins(WeeklyGoalsPlugin)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
//! 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 (0–23) 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());
|
||||
}
|
||||
}
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod achievement;
|
||||
pub mod card;
|
||||
pub mod deck;
|
||||
pub mod error;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Persistence for per-player achievement unlock records.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const FILE_NAME: &str = "achievements.json";
|
||||
|
||||
/// One player's unlock state for a single achievement.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AchievementRecord {
|
||||
pub id: String,
|
||||
pub unlocked: bool,
|
||||
pub unlock_date: Option<DateTime<Utc>>,
|
||||
pub reward_granted: bool,
|
||||
}
|
||||
|
||||
impl AchievementRecord {
|
||||
/// Construct an initial record for an achievement that is not yet unlocked.
|
||||
pub fn locked(id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
unlocked: false,
|
||||
unlock_date: None,
|
||||
reward_granted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark this record unlocked at the given timestamp. No-op if already unlocked
|
||||
/// (preserves earliest `unlock_date`).
|
||||
pub fn unlock(&mut self, at: DateTime<Utc>) {
|
||||
if self.unlocked {
|
||||
return;
|
||||
}
|
||||
self.unlocked = true;
|
||||
self.unlock_date = Some(at);
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform-specific default path for `achievements.json`.
|
||||
pub fn achievements_file_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
|
||||
}
|
||||
|
||||
/// Load achievements from an explicit path. Returns `Vec::new()` if the file
|
||||
/// is missing or unreadable.
|
||||
pub fn load_achievements_from(path: &Path) -> Vec<AchievementRecord> {
|
||||
let Ok(data) = fs::read(path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
serde_json::from_slice(&data).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Save achievements to an explicit path using an atomic write.
|
||||
pub fn save_achievements_to(path: &Path, records: &[AchievementRecord]) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(records).map_err(io::Error::other)?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, json.as_bytes())?;
|
||||
fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
|
||||
fn tmp_path(name: &str) -> PathBuf {
|
||||
env::temp_dir().join(format!("solitaire_ach_test_{name}.json"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlock_sets_flag_and_date() {
|
||||
let mut r = AchievementRecord::locked("x");
|
||||
let at = Utc::now();
|
||||
r.unlock(at);
|
||||
assert!(r.unlocked);
|
||||
assert_eq!(r.unlock_date, Some(at));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlock_is_idempotent_on_date() {
|
||||
let mut r = AchievementRecord::locked("x");
|
||||
let first = Utc::now();
|
||||
r.unlock(first);
|
||||
let later = first + chrono::Duration::hours(1);
|
||||
r.unlock(later);
|
||||
assert_eq!(r.unlock_date, Some(first), "earliest date preserved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_save_and_load() {
|
||||
let path = tmp_path("round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let records = vec![
|
||||
AchievementRecord::locked("first_win"),
|
||||
{
|
||||
let mut r = AchievementRecord::locked("century");
|
||||
r.unlock(Utc::now());
|
||||
r
|
||||
},
|
||||
];
|
||||
save_achievements_to(&path, &records).expect("save");
|
||||
let loaded = load_achievements_from(&path);
|
||||
assert_eq!(loaded.len(), 2);
|
||||
assert_eq!(loaded[1].id, "century");
|
||||
assert!(loaded[1].unlocked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_missing_file_returns_empty() {
|
||||
let path = tmp_path("missing_abc");
|
||||
let _ = fs::remove_file(&path);
|
||||
assert!(load_achievements_from(&path).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_corrupt_file_returns_empty() {
|
||||
let path = tmp_path("corrupt");
|
||||
fs::write(&path, b"not json").expect("write");
|
||||
assert!(load_achievements_from(&path).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_cleans_up_tmp_file() {
|
||||
let path = tmp_path("atomic");
|
||||
save_achievements_to(&path, &[]).expect("save");
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
assert!(!tmp.exists());
|
||||
}
|
||||
}
|
||||
@@ -34,3 +34,26 @@ pub trait SyncProvider: Send + Sync {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub mod stats;
|
||||
pub use stats::StatsSnapshot;
|
||||
|
||||
pub mod storage;
|
||||
pub use storage::{load_stats, load_stats_from, save_stats, save_stats_to, stats_file_path};
|
||||
|
||||
pub mod achievements;
|
||||
pub use achievements::{
|
||||
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord,
|
||||
};
|
||||
|
||||
pub mod progress;
|
||||
pub use progress::{
|
||||
daily_seed_for, level_for_xp, load_progress_from, progress_file_path, save_progress_to,
|
||||
xp_for_win, PlayerProgress,
|
||||
};
|
||||
|
||||
pub mod weekly;
|
||||
pub use weekly::{
|
||||
current_iso_week_key, weekly_goal_by_id, WeeklyGoalContext, WeeklyGoalDef, WeeklyGoalKind,
|
||||
WEEKLY_GOALS, WEEKLY_GOAL_XP,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
//! Player progression — XP, level, unlocks, daily/weekly progress.
|
||||
//!
|
||||
//! Persisted to `progress.json` next to `stats.json` and `achievements.json`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const FILE_NAME: &str = "progress.json";
|
||||
|
||||
/// XP-to-level lookup. Matches ARCHITECTURE.md §13.
|
||||
///
|
||||
/// Levels 1–10: `level = floor(total_xp / 500)`
|
||||
/// Levels 11+: `level = 10 + floor((total_xp - 5_000) / 1_000)`
|
||||
pub fn level_for_xp(xp: u64) -> u32 {
|
||||
if xp < 5_000 {
|
||||
(xp / 500) as u32
|
||||
} else {
|
||||
10 + ((xp - 5_000) / 1_000) as u32
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic seed derived from a date, identical for all players globally.
|
||||
/// Used as the RNG seed for the daily-challenge deal.
|
||||
pub fn daily_seed_for(date: NaiveDate) -> u64 {
|
||||
let y = date.year() as u64;
|
||||
let m = date.month() as u64;
|
||||
let d = date.day() as u64;
|
||||
y * 10_000 + m * 100 + d
|
||||
}
|
||||
|
||||
/// XP awarded for winning a game.
|
||||
///
|
||||
/// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if
|
||||
/// the player did not use undo.
|
||||
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
||||
let base: u64 = 50;
|
||||
let speed_bonus: u64 = if time_seconds >= 120 {
|
||||
0
|
||||
} else {
|
||||
// Linearly scale 50 → 10 across 0..=120 seconds.
|
||||
// 0s → 50, 60s → 30, 120s → 10.
|
||||
let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
|
||||
scaled.max(10)
|
||||
};
|
||||
let no_undo_bonus: u64 = if used_undo { 0 } else { 25 };
|
||||
base + speed_bonus + no_undo_bonus
|
||||
}
|
||||
|
||||
/// Persisted player progression state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PlayerProgress {
|
||||
pub total_xp: u64,
|
||||
pub level: u32,
|
||||
pub daily_challenge_last_completed: Option<NaiveDate>,
|
||||
pub daily_challenge_streak: u32,
|
||||
pub weekly_goal_progress: HashMap<String, u32>,
|
||||
/// ISO week key (e.g. `"2026-W17"`) the current `weekly_goal_progress`
|
||||
/// counters belong to. When the engine sees a different week it clears
|
||||
/// progress and updates this field.
|
||||
#[serde(default)]
|
||||
pub weekly_goal_week_iso: Option<String>,
|
||||
pub unlocked_card_backs: Vec<usize>,
|
||||
pub unlocked_backgrounds: Vec<usize>,
|
||||
pub last_modified: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Default for PlayerProgress {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
total_xp: 0,
|
||||
level: 0,
|
||||
daily_challenge_last_completed: None,
|
||||
daily_challenge_streak: 0,
|
||||
weekly_goal_progress: HashMap::new(),
|
||||
weekly_goal_week_iso: None,
|
||||
unlocked_card_backs: vec![0], // back #0 always available
|
||||
unlocked_backgrounds: vec![0], // background #0 always available
|
||||
last_modified: DateTime::UNIX_EPOCH,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerProgress {
|
||||
/// Add XP and recompute level. Returns the previous level so callers can
|
||||
/// detect level-up events.
|
||||
pub fn add_xp(&mut self, amount: u64) -> u32 {
|
||||
let prev_level = self.level;
|
||||
self.total_xp = self.total_xp.saturating_add(amount);
|
||||
self.level = level_for_xp(self.total_xp);
|
||||
self.last_modified = Utc::now();
|
||||
prev_level
|
||||
}
|
||||
|
||||
/// `true` if a level-up just occurred (current level > `prev_level`).
|
||||
pub fn leveled_up_from(&self, prev_level: u32) -> bool {
|
||||
self.level > prev_level
|
||||
}
|
||||
|
||||
/// Reset weekly-goal progress when the ISO week has rolled over.
|
||||
/// No-op if the stored week key already matches `current`.
|
||||
pub fn roll_weekly_goals_if_new_week(&mut self, current: &str) -> bool {
|
||||
if self.weekly_goal_week_iso.as_deref() == Some(current) {
|
||||
return false;
|
||||
}
|
||||
self.weekly_goal_progress.clear();
|
||||
self.weekly_goal_week_iso = Some(current.to_string());
|
||||
self.last_modified = Utc::now();
|
||||
true
|
||||
}
|
||||
|
||||
/// Increment progress for `goal_id` by 1, capped at `target`.
|
||||
/// Returns `true` if this call brought the counter from below `target`
|
||||
/// to at-or-above `target` (i.e. just completed the goal).
|
||||
pub fn record_weekly_progress(&mut self, goal_id: &str, target: u32) -> bool {
|
||||
let entry = self.weekly_goal_progress.entry(goal_id.to_string()).or_insert(0);
|
||||
if *entry >= target {
|
||||
// Already complete — do not over-count.
|
||||
return false;
|
||||
}
|
||||
*entry = entry.saturating_add(1);
|
||||
self.last_modified = Utc::now();
|
||||
*entry >= target
|
||||
}
|
||||
|
||||
/// Record a daily-challenge completion for `date`.
|
||||
///
|
||||
/// - First completion ever, or a gap of more than one day: streak resets to 1.
|
||||
/// - Completion the day after the previous: streak increments.
|
||||
/// - Same day as the previous: no-op (idempotent — a player can't double-count).
|
||||
///
|
||||
/// Returns `true` if this call recorded a fresh completion (i.e. it wasn't
|
||||
/// the same-day no-op case).
|
||||
pub fn record_daily_completion(&mut self, date: NaiveDate) -> bool {
|
||||
match self.daily_challenge_last_completed {
|
||||
Some(last) if last == date => return false,
|
||||
Some(last) if last + Duration::days(1) == date => {
|
||||
self.daily_challenge_streak = self.daily_challenge_streak.saturating_add(1);
|
||||
}
|
||||
_ => {
|
||||
self.daily_challenge_streak = 1;
|
||||
}
|
||||
}
|
||||
self.daily_challenge_last_completed = Some(date);
|
||||
self.last_modified = Utc::now();
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform-specific default path for `progress.json`.
|
||||
pub fn progress_file_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
|
||||
}
|
||||
|
||||
/// Load progress from an explicit path. Returns `default()` if missing/corrupt.
|
||||
pub fn load_progress_from(path: &Path) -> PlayerProgress {
|
||||
let Ok(data) = fs::read(path) else {
|
||||
return PlayerProgress::default();
|
||||
};
|
||||
serde_json::from_slice(&data).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Save progress to an explicit path using an atomic write.
|
||||
pub fn save_progress_to(path: &Path, progress: &PlayerProgress) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(progress).map_err(io::Error::other)?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, json.as_bytes())?;
|
||||
fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
|
||||
fn tmp_path(name: &str) -> PathBuf {
|
||||
env::temp_dir().join(format!("solitaire_progress_test_{name}.json"))
|
||||
}
|
||||
|
||||
// --- Level formula ---
|
||||
|
||||
#[test]
|
||||
fn level_for_xp_at_breakpoints() {
|
||||
assert_eq!(level_for_xp(0), 0);
|
||||
assert_eq!(level_for_xp(499), 0);
|
||||
assert_eq!(level_for_xp(500), 1);
|
||||
assert_eq!(level_for_xp(4_999), 9);
|
||||
assert_eq!(level_for_xp(5_000), 10);
|
||||
assert_eq!(level_for_xp(5_999), 10);
|
||||
assert_eq!(level_for_xp(6_000), 11);
|
||||
assert_eq!(level_for_xp(15_000), 20);
|
||||
}
|
||||
|
||||
// --- XP-for-win formula ---
|
||||
|
||||
#[test]
|
||||
fn xp_for_slow_win_with_undo_is_just_base() {
|
||||
assert_eq!(xp_for_win(300, true), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xp_for_no_undo_win_adds_25() {
|
||||
assert_eq!(xp_for_win(300, false), 75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xp_for_instant_win_includes_max_speed_bonus() {
|
||||
// base 50 + speed 50 = 100 with undo, +25 without
|
||||
assert_eq!(xp_for_win(0, true), 100);
|
||||
assert_eq!(xp_for_win(0, false), 125);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xp_speed_bonus_scales_linearly_to_120s() {
|
||||
// At 60s: 50 - (60*40/120) = 50 - 20 = 30
|
||||
assert_eq!(xp_for_win(60, true), 50 + 30);
|
||||
// At 119s: 50 - (119*40/120) = 50 - 39 = 11, but floored at 10
|
||||
assert!(xp_for_win(119, true) >= 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xp_no_speed_bonus_at_or_above_120s() {
|
||||
assert_eq!(xp_for_win(120, true), 50);
|
||||
assert_eq!(xp_for_win(180, true), 50);
|
||||
}
|
||||
|
||||
// --- PlayerProgress.add_xp ---
|
||||
|
||||
#[test]
|
||||
fn add_xp_returns_previous_level_and_recomputes() {
|
||||
let mut p = PlayerProgress::default();
|
||||
let prev = p.add_xp(500);
|
||||
assert_eq!(prev, 0);
|
||||
assert_eq!(p.total_xp, 500);
|
||||
assert_eq!(p.level, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_up_detection_works() {
|
||||
let mut p = PlayerProgress::default();
|
||||
let prev = p.add_xp(450);
|
||||
assert!(!p.leveled_up_from(prev), "no level change at 450 xp");
|
||||
let prev = p.add_xp(60);
|
||||
assert!(p.leveled_up_from(prev), "0 → 1 at 510 xp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_xp_saturates_on_overflow() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.total_xp = u64::MAX - 5;
|
||||
p.add_xp(100);
|
||||
assert_eq!(p.total_xp, u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_unlocks_include_first_card_back_and_background() {
|
||||
let p = PlayerProgress::default();
|
||||
assert!(p.unlocked_card_backs.contains(&0));
|
||||
assert!(p.unlocked_backgrounds.contains(&0));
|
||||
}
|
||||
|
||||
// --- Persistence ---
|
||||
|
||||
#[test]
|
||||
fn round_trip_save_and_load() {
|
||||
let path = tmp_path("round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let mut p = PlayerProgress::default();
|
||||
p.add_xp(1234);
|
||||
p.unlocked_card_backs.push(2);
|
||||
save_progress_to(&path, &p).expect("save");
|
||||
let loaded = load_progress_from(&path);
|
||||
assert_eq!(loaded.total_xp, 1234);
|
||||
assert_eq!(loaded.level, p.level);
|
||||
assert!(loaded.unlocked_card_backs.contains(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_missing_file_returns_default() {
|
||||
let path = tmp_path("missing_xyz");
|
||||
let _ = fs::remove_file(&path);
|
||||
let p = load_progress_from(&path);
|
||||
assert_eq!(p, PlayerProgress::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_corrupt_file_returns_default() {
|
||||
let path = tmp_path("corrupt");
|
||||
fs::write(&path, b"garbage").expect("write");
|
||||
let p = load_progress_from(&path);
|
||||
assert_eq!(p, PlayerProgress::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_cleans_up_tmp_file() {
|
||||
let path = tmp_path("atomic");
|
||||
save_progress_to(&path, &PlayerProgress::default()).expect("save");
|
||||
assert!(!path.with_extension("json.tmp").exists());
|
||||
}
|
||||
|
||||
// --- Daily challenge ---
|
||||
|
||||
#[test]
|
||||
fn daily_seed_is_deterministic_per_date() {
|
||||
let d = NaiveDate::from_ymd_opt(2026, 4, 24).unwrap();
|
||||
assert_eq!(daily_seed_for(d), daily_seed_for(d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_seed_differs_across_dates() {
|
||||
let a = NaiveDate::from_ymd_opt(2026, 4, 24).unwrap();
|
||||
let b = NaiveDate::from_ymd_opt(2026, 4, 25).unwrap();
|
||||
assert_ne!(daily_seed_for(a), daily_seed_for(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_daily_completion_starts_streak_at_1() {
|
||||
let mut p = PlayerProgress::default();
|
||||
let d = NaiveDate::from_ymd_opt(2026, 4, 24).unwrap();
|
||||
let recorded = p.record_daily_completion(d);
|
||||
assert!(recorded);
|
||||
assert_eq!(p.daily_challenge_streak, 1);
|
||||
assert_eq!(p.daily_challenge_last_completed, Some(d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_days_increment_streak() {
|
||||
let mut p = PlayerProgress::default();
|
||||
let d1 = NaiveDate::from_ymd_opt(2026, 4, 24).unwrap();
|
||||
let d2 = d1 + Duration::days(1);
|
||||
let d3 = d2 + Duration::days(1);
|
||||
p.record_daily_completion(d1);
|
||||
p.record_daily_completion(d2);
|
||||
p.record_daily_completion(d3);
|
||||
assert_eq!(p.daily_challenge_streak, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_day_resets_streak_to_1() {
|
||||
let mut p = PlayerProgress::default();
|
||||
let d1 = NaiveDate::from_ymd_opt(2026, 4, 24).unwrap();
|
||||
let d3 = d1 + Duration::days(2); // skipped d2
|
||||
p.record_daily_completion(d1);
|
||||
p.record_daily_completion(d3);
|
||||
assert_eq!(p.daily_challenge_streak, 1);
|
||||
}
|
||||
|
||||
// --- Weekly goals ---
|
||||
|
||||
#[test]
|
||||
fn first_week_roll_initializes_key_and_returns_true() {
|
||||
let mut p = PlayerProgress::default();
|
||||
let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
|
||||
assert!(rolled);
|
||||
assert_eq!(p.weekly_goal_week_iso.as_deref(), Some("2026-W17"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_week_roll_is_noop() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.roll_weekly_goals_if_new_week("2026-W17");
|
||||
p.weekly_goal_progress.insert("g1".into(), 3);
|
||||
let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
|
||||
assert!(!rolled);
|
||||
assert_eq!(p.weekly_goal_progress.get("g1"), Some(&3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_week_roll_clears_progress_and_updates_key() {
|
||||
let mut p = PlayerProgress::default();
|
||||
p.roll_weekly_goals_if_new_week("2026-W17");
|
||||
p.weekly_goal_progress.insert("g1".into(), 3);
|
||||
let rolled = p.roll_weekly_goals_if_new_week("2026-W18");
|
||||
assert!(rolled);
|
||||
assert!(p.weekly_goal_progress.is_empty());
|
||||
assert_eq!(p.weekly_goal_week_iso.as_deref(), Some("2026-W18"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_weekly_progress_returns_true_only_on_completion_step() {
|
||||
let mut p = PlayerProgress::default();
|
||||
assert!(!p.record_weekly_progress("g1", 3));
|
||||
assert!(!p.record_weekly_progress("g1", 3));
|
||||
assert!(p.record_weekly_progress("g1", 3), "third tick completes");
|
||||
// Further ticks should not re-fire completion.
|
||||
assert!(!p.record_weekly_progress("g1", 3));
|
||||
assert_eq!(p.weekly_goal_progress.get("g1"), Some(&3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_day_completion_is_idempotent() {
|
||||
let mut p = PlayerProgress::default();
|
||||
let d = NaiveDate::from_ymd_opt(2026, 4, 24).unwrap();
|
||||
p.record_daily_completion(d);
|
||||
let recorded_again = p.record_daily_completion(d);
|
||||
assert!(!recorded_again, "same-day completion must report no-op");
|
||||
assert_eq!(p.daily_challenge_streak, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! Player statistics — persisted to `stats.json` between sessions.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
|
||||
/// Cumulative game statistics. Stored as `stats.json` in the platform data dir.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StatsSnapshot {
|
||||
pub games_played: u32,
|
||||
pub games_won: u32,
|
||||
pub games_lost: u32,
|
||||
pub win_streak_current: u32,
|
||||
pub win_streak_best: u32,
|
||||
/// Rolling average of win times in seconds.
|
||||
pub avg_time_seconds: u64,
|
||||
/// Fastest win time. `u64::MAX` means no wins yet.
|
||||
pub fastest_win_seconds: u64,
|
||||
/// Sum of all winning scores.
|
||||
pub lifetime_score: u64,
|
||||
pub best_single_score: u32,
|
||||
pub draw_one_wins: u32,
|
||||
pub draw_three_wins: u32,
|
||||
pub last_modified: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Default for StatsSnapshot {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
games_played: 0,
|
||||
games_won: 0,
|
||||
games_lost: 0,
|
||||
win_streak_current: 0,
|
||||
win_streak_best: 0,
|
||||
avg_time_seconds: 0,
|
||||
fastest_win_seconds: u64::MAX,
|
||||
lifetime_score: 0,
|
||||
best_single_score: 0,
|
||||
draw_one_wins: 0,
|
||||
draw_three_wins: 0,
|
||||
last_modified: DateTime::UNIX_EPOCH,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StatsSnapshot {
|
||||
/// Record a completed win. Updates all relevant counters and rolling averages.
|
||||
pub fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode) {
|
||||
let prev_wins = self.games_won;
|
||||
self.games_played += 1;
|
||||
self.games_won += 1;
|
||||
self.win_streak_current += 1;
|
||||
if self.win_streak_current > self.win_streak_best {
|
||||
self.win_streak_best = self.win_streak_current;
|
||||
}
|
||||
|
||||
let score_u32 = score.max(0) as u32;
|
||||
self.lifetime_score = self.lifetime_score.saturating_add(score_u32 as u64);
|
||||
if score_u32 > self.best_single_score {
|
||||
self.best_single_score = score_u32;
|
||||
}
|
||||
|
||||
if time_seconds < self.fastest_win_seconds {
|
||||
self.fastest_win_seconds = time_seconds;
|
||||
}
|
||||
|
||||
self.avg_time_seconds = if prev_wins == 0 {
|
||||
time_seconds
|
||||
} else {
|
||||
((self.avg_time_seconds as u128 * prev_wins as u128 + time_seconds as u128)
|
||||
/ self.games_won as u128) as u64
|
||||
};
|
||||
|
||||
match draw_mode {
|
||||
DrawMode::DrawOne => self.draw_one_wins += 1,
|
||||
DrawMode::DrawThree => self.draw_three_wins += 1,
|
||||
}
|
||||
|
||||
self.last_modified = Utc::now();
|
||||
}
|
||||
|
||||
/// Record an abandoned game (player started a new game without winning).
|
||||
pub fn record_abandoned(&mut self) {
|
||||
self.games_played += 1;
|
||||
self.games_lost += 1;
|
||||
self.win_streak_current = 0;
|
||||
self.last_modified = Utc::now();
|
||||
}
|
||||
|
||||
/// Win percentage as 0–100, or `None` if no games played.
|
||||
pub fn win_rate(&self) -> Option<f32> {
|
||||
if self.games_played == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(self.games_won as f32 / self.games_played as f32 * 100.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_stats_are_all_zero() {
|
||||
let s = StatsSnapshot::default();
|
||||
assert_eq!(s.games_played, 0);
|
||||
assert_eq!(s.games_won, 0);
|
||||
assert_eq!(s.win_streak_current, 0);
|
||||
assert_eq!(s.win_streak_best, 0);
|
||||
assert_eq!(s.lifetime_score, 0);
|
||||
assert_eq!(s.best_single_score, 0);
|
||||
assert_eq!(s.fastest_win_seconds, u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_win_sets_all_fields() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(1500, 120, &DrawMode::DrawOne);
|
||||
assert_eq!(s.games_played, 1);
|
||||
assert_eq!(s.games_won, 1);
|
||||
assert_eq!(s.win_streak_current, 1);
|
||||
assert_eq!(s.win_streak_best, 1);
|
||||
assert_eq!(s.lifetime_score, 1500);
|
||||
assert_eq!(s.best_single_score, 1500);
|
||||
assert_eq!(s.fastest_win_seconds, 120);
|
||||
assert_eq!(s.avg_time_seconds, 120);
|
||||
assert_eq!(s.draw_one_wins, 1);
|
||||
assert_eq!(s.draw_three_wins, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streak_tracks_across_wins() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
for _ in 0..3 {
|
||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||
}
|
||||
assert_eq!(s.win_streak_current, 3);
|
||||
assert_eq!(s.win_streak_best, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_abandoned_resets_streak_and_increments_played() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||
assert_eq!(s.win_streak_current, 2);
|
||||
s.record_abandoned();
|
||||
assert_eq!(s.games_played, 3);
|
||||
assert_eq!(s.games_lost, 1);
|
||||
assert_eq!(s.win_streak_current, 0);
|
||||
assert_eq!(s.win_streak_best, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fastest_win_takes_minimum() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(100, 300, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 120, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 500, &DrawMode::DrawOne);
|
||||
assert_eq!(s.fastest_win_seconds, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avg_time_is_correct_rolling_average() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(100, 100, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 200, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 300, &DrawMode::DrawOne);
|
||||
assert_eq!(s.avg_time_seconds, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_score_updates_only_on_higher_score() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(500, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(300, 60, &DrawMode::DrawOne);
|
||||
assert_eq!(s.best_single_score, 500);
|
||||
s.update_on_win(800, 60, &DrawMode::DrawOne);
|
||||
assert_eq!(s.best_single_score, 800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_score_treated_as_zero() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(-50, 60, &DrawMode::DrawOne);
|
||||
assert_eq!(s.best_single_score, 0);
|
||||
assert_eq!(s.lifetime_score, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_three_wins_tracked_separately() {
|
||||
let mut s = StatsSnapshot::default();
|
||||
s.update_on_win(100, 60, &DrawMode::DrawOne);
|
||||
s.update_on_win(100, 60, &DrawMode::DrawThree);
|
||||
assert_eq!(s.draw_one_wins, 1);
|
||||
assert_eq!(s.draw_three_wins, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Atomic file I/O for `StatsSnapshot` persistence.
|
||||
//!
|
||||
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power
|
||||
//! loss during a write never corrupts the saved data.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::stats::StatsSnapshot;
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const STATS_FILE_NAME: &str = "stats.json";
|
||||
|
||||
/// Returns the platform-specific path to `stats.json`, or `None` if
|
||||
/// `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
|
||||
pub fn stats_file_path() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(STATS_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Load stats from an explicit path. Returns `StatsSnapshot::default()` if
|
||||
/// the file is missing or cannot be deserialized (corrupt/truncated).
|
||||
pub fn load_stats_from(path: &Path) -> StatsSnapshot {
|
||||
let Ok(data) = fs::read(path) else {
|
||||
return StatsSnapshot::default();
|
||||
};
|
||||
serde_json::from_slice(&data).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Save stats to an explicit path using an atomic write (`.tmp` → rename).
|
||||
pub fn save_stats_to(path: &Path, stats: &StatsSnapshot) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(stats).map_err(io::Error::other)?;
|
||||
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, json.as_bytes())?;
|
||||
fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load stats from the platform default path. Returns default if the path
|
||||
/// is unavailable or the file is missing/corrupt.
|
||||
pub fn load_stats() -> StatsSnapshot {
|
||||
stats_file_path()
|
||||
.map(|p| load_stats_from(&p))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Save stats to the platform default path. Returns an error if the platform
|
||||
/// data dir is unavailable or the write fails.
|
||||
pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
|
||||
let path = stats_file_path().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable")
|
||||
})?;
|
||||
save_stats_to(&path, stats)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::stats::StatsSnapshot;
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
use std::env;
|
||||
|
||||
fn tmp_path(name: &str) -> PathBuf {
|
||||
env::temp_dir().join(format!("solitaire_test_{name}.json"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_save_and_load() {
|
||||
let path = tmp_path("round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let mut stats = StatsSnapshot::default();
|
||||
stats.update_on_win(1000, 180, &DrawMode::DrawOne);
|
||||
save_stats_to(&path, &stats).expect("save");
|
||||
|
||||
let loaded = load_stats_from(&path);
|
||||
assert_eq!(loaded.games_won, 1);
|
||||
assert_eq!(loaded.best_single_score, 1000);
|
||||
assert_eq!(loaded.fastest_win_seconds, 180);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_missing_file_returns_default() {
|
||||
let path = tmp_path("missing_file_abc123");
|
||||
let _ = fs::remove_file(&path);
|
||||
let stats = load_stats_from(&path);
|
||||
assert_eq!(stats, StatsSnapshot::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_is_atomic_no_half_written_file() {
|
||||
let path = tmp_path("atomic_write");
|
||||
let stats = StatsSnapshot::default();
|
||||
save_stats_to(&path, &stats).expect("save");
|
||||
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
assert!(!tmp.exists(), ".tmp file must be cleaned up after rename");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_corrupt_file_returns_default() {
|
||||
let path = tmp_path("corrupt");
|
||||
fs::write(&path, b"not valid json!!!").expect("write corrupt");
|
||||
let stats = load_stats_from(&path);
|
||||
assert_eq!(stats, StatsSnapshot::default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Weekly goal definitions and helpers.
|
||||
//!
|
||||
//! Goals reset every ISO week. Engine evaluates them on `GameWonEvent` and
|
||||
//! increments matching counters in `PlayerProgress::weekly_goal_progress`.
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
|
||||
/// XP awarded each time a weekly goal is just completed.
|
||||
pub const WEEKLY_GOAL_XP: u64 = 75;
|
||||
|
||||
/// What kind of game outcome counts as progress toward this goal.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WeeklyGoalKind {
|
||||
/// Any win counts.
|
||||
WinGame,
|
||||
/// A win without using `undo` counts.
|
||||
WinWithoutUndo,
|
||||
/// A win in strictly fewer than `seconds` seconds counts.
|
||||
WinUnder { seconds: u64 },
|
||||
}
|
||||
|
||||
/// Static metadata for a single weekly goal.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct WeeklyGoalDef {
|
||||
pub id: &'static str,
|
||||
pub description: &'static str,
|
||||
pub target: u32,
|
||||
pub kind: WeeklyGoalKind,
|
||||
}
|
||||
|
||||
/// Per-event facts a goal needs to decide whether it matched.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct WeeklyGoalContext {
|
||||
pub time_seconds: u64,
|
||||
pub used_undo: bool,
|
||||
}
|
||||
|
||||
impl WeeklyGoalDef {
|
||||
/// Returns `true` if this win event counts as one tick of progress
|
||||
/// toward this goal.
|
||||
pub fn matches(&self, ctx: &WeeklyGoalContext) -> bool {
|
||||
match self.kind {
|
||||
WeeklyGoalKind::WinGame => true,
|
||||
WeeklyGoalKind::WinWithoutUndo => !ctx.used_undo,
|
||||
WeeklyGoalKind::WinUnder { seconds } => ctx.time_seconds < seconds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All currently-active weekly goals.
|
||||
pub const WEEKLY_GOALS: &[WeeklyGoalDef] = &[
|
||||
WeeklyGoalDef {
|
||||
id: "weekly_5_wins",
|
||||
description: "Win 5 games this week",
|
||||
target: 5,
|
||||
kind: WeeklyGoalKind::WinGame,
|
||||
},
|
||||
WeeklyGoalDef {
|
||||
id: "weekly_3_no_undo",
|
||||
description: "Win 3 games without undo this week",
|
||||
target: 3,
|
||||
kind: WeeklyGoalKind::WinWithoutUndo,
|
||||
},
|
||||
WeeklyGoalDef {
|
||||
id: "weekly_3_fast",
|
||||
description: "Win 3 games in under 3 minutes this week",
|
||||
target: 3,
|
||||
kind: WeeklyGoalKind::WinUnder { seconds: 180 },
|
||||
},
|
||||
];
|
||||
|
||||
/// Stable identifier for the ISO week containing `date`, e.g. `"2026-W17"`.
|
||||
/// Same string for every player worldwide on the same calendar week.
|
||||
pub fn current_iso_week_key(date: NaiveDate) -> String {
|
||||
let iso = date.iso_week();
|
||||
format!("{}-W{:02}", iso.year(), iso.week())
|
||||
}
|
||||
|
||||
/// Look up a weekly-goal definition by id.
|
||||
pub fn weekly_goal_by_id(id: &str) -> Option<&'static WeeklyGoalDef> {
|
||||
WEEKLY_GOALS.iter().find(|g| g.id == id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ctx(time: u64, undo: bool) -> WeeklyGoalContext {
|
||||
WeeklyGoalContext {
|
||||
time_seconds: time,
|
||||
used_undo: undo,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_goal_ids_are_unique() {
|
||||
let mut ids: Vec<&str> = WEEKLY_GOALS.iter().map(|g| g.id).collect();
|
||||
ids.sort();
|
||||
let len = ids.len();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_game_always_matches() {
|
||||
let g = weekly_goal_by_id("weekly_5_wins").unwrap();
|
||||
assert!(g.matches(&ctx(60, false)));
|
||||
assert!(g.matches(&ctx(99999, true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_undo_only_matches_clean_wins() {
|
||||
let g = weekly_goal_by_id("weekly_3_no_undo").unwrap();
|
||||
assert!(g.matches(&ctx(120, false)));
|
||||
assert!(!g.matches(&ctx(120, true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_only_matches_under_3_minutes() {
|
||||
let g = weekly_goal_by_id("weekly_3_fast").unwrap();
|
||||
assert!(g.matches(&ctx(60, true)));
|
||||
assert!(g.matches(&ctx(179, true)));
|
||||
assert!(!g.matches(&ctx(180, true)));
|
||||
assert!(!g.matches(&ctx(300, false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_week_key_is_stable_within_a_week() {
|
||||
let monday = NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(); // 2026-W17 Mon
|
||||
let sunday = NaiveDate::from_ymd_opt(2026, 4, 26).unwrap(); // 2026-W17 Sun
|
||||
assert_eq!(current_iso_week_key(monday), current_iso_week_key(sunday));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_week_key_differs_across_weeks() {
|
||||
let w17 = NaiveDate::from_ymd_opt(2026, 4, 20).unwrap();
|
||||
let w18 = NaiveDate::from_ymd_opt(2026, 4, 27).unwrap();
|
||||
assert_ne!(current_iso_week_key(w17), current_iso_week_key(w18));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_week_key_format_includes_year_and_week() {
|
||||
let d = NaiveDate::from_ymd_opt(2026, 4, 20).unwrap();
|
||||
let key = current_iso_week_key(d);
|
||||
assert!(key.starts_with("2026-W"));
|
||||
assert_eq!(key.len(), 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Evaluates achievements on `GameWonEvent`, persists unlocks, and fires
|
||||
//! `AchievementUnlockedEvent` for each newly unlocked achievement.
|
||||
//!
|
||||
//! The persistence path is configurable via `AchievementPlugin::storage_path`.
|
||||
//! `AchievementPlugin::default()` uses the platform data dir;
|
||||
//! `AchievementPlugin::headless()` disables I/O entirely (for tests).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use chrono::{Local, Timelike, Utc};
|
||||
use solitaire_core::achievement::{
|
||||
achievement_by_id, check_achievements, AchievementContext, ALL_ACHIEVEMENTS,
|
||||
};
|
||||
use solitaire_data::{
|
||||
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord,
|
||||
};
|
||||
|
||||
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::{ProgressResource, ProgressUpdate};
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||
|
||||
/// All per-player achievement records (one per known achievement).
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct AchievementsResource(pub Vec<AchievementRecord>);
|
||||
|
||||
/// Persistence path for `AchievementsResource`. `None` disables I/O.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct AchievementsStoragePath(pub Option<PathBuf>);
|
||||
|
||||
pub struct AchievementPlugin {
|
||||
pub storage_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for AchievementPlugin {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
storage_path: achievements_file_path(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AchievementPlugin {
|
||||
/// Plugin configured with no persistence.
|
||||
pub fn headless() -> Self {
|
||||
Self { storage_path: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for AchievementPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let mut records = match &self.storage_path {
|
||||
Some(path) => load_achievements_from(path),
|
||||
None => Vec::new(),
|
||||
};
|
||||
// Ensure every known achievement has a record. Keeps file forward-compatible
|
||||
// when new achievements are added in future releases.
|
||||
for def in ALL_ACHIEVEMENTS {
|
||||
if !records.iter().any(|r| r.id == def.id) {
|
||||
records.push(AchievementRecord::locked(def.id));
|
||||
}
|
||||
}
|
||||
|
||||
app.insert_resource(AchievementsResource(records))
|
||||
.insert_resource(AchievementsStoragePath(self.storage_path.clone()))
|
||||
.add_event::<AchievementUnlockedEvent>()
|
||||
.add_event::<GameWonEvent>()
|
||||
// Run after GameMutation (so GameWonEvent is available), after
|
||||
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
||||
// (so daily_challenge_streak is up to date for daily_devotee).
|
||||
.add_systems(
|
||||
Update,
|
||||
evaluate_on_win
|
||||
.after(GameMutation)
|
||||
.after(StatsUpdate)
|
||||
.after(ProgressUpdate),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn evaluate_on_win(
|
||||
mut wins: EventReader<GameWonEvent>,
|
||||
mut unlocks: EventWriter<AchievementUnlockedEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
stats: Res<StatsResource>,
|
||||
progress: Res<ProgressResource>,
|
||||
path: Res<AchievementsStoragePath>,
|
||||
mut achievements: ResMut<AchievementsResource>,
|
||||
) {
|
||||
let Some(ev) = wins.read().last() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let ctx = AchievementContext {
|
||||
games_played: stats.0.games_played,
|
||||
games_won: stats.0.games_won,
|
||||
win_streak_current: stats.0.win_streak_current,
|
||||
best_single_score: stats.0.best_single_score,
|
||||
lifetime_score: stats.0.lifetime_score,
|
||||
draw_three_wins: stats.0.draw_three_wins,
|
||||
daily_challenge_streak: progress.0.daily_challenge_streak,
|
||||
last_win_score: ev.score,
|
||||
last_win_time_seconds: ev.time_seconds,
|
||||
last_win_used_undo: game.0.undo_count > 0,
|
||||
wall_clock_hour: Some(Local::now().hour()),
|
||||
};
|
||||
|
||||
let hits = check_achievements(&ctx);
|
||||
if hits.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let mut changed = false;
|
||||
for def in hits {
|
||||
let Some(record) = achievements.0.iter_mut().find(|r| r.id == def.id) else {
|
||||
continue;
|
||||
};
|
||||
if record.unlocked {
|
||||
continue;
|
||||
}
|
||||
record.unlock(now);
|
||||
changed = true;
|
||||
unlocks.send(AchievementUnlockedEvent(def.id.to_string()));
|
||||
}
|
||||
|
||||
if changed {
|
||||
if let Some(target) = &path.0 {
|
||||
if let Err(e) = save_achievements_to(target, &achievements.0) {
|
||||
warn!("failed to save achievements: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: resolve an achievement ID to its human-readable name.
|
||||
/// Used by the toast renderer in `animation_plugin`.
|
||||
pub fn display_name_for(id: &str) -> String {
|
||||
achievement_by_id(id)
|
||||
.map(|d| d.name.to_string())
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::stats_plugin::StatsPlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(StatsPlugin::headless())
|
||||
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
||||
.add_plugins(AchievementPlugin::headless());
|
||||
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
|
||||
// MinimalPlugins it isn't auto-registered.
|
||||
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_is_populated_with_all_known_ids() {
|
||||
let app = headless_app();
|
||||
let records = &app.world().resource::<AchievementsResource>().0;
|
||||
assert_eq!(records.len(), ALL_ACHIEVEMENTS.len());
|
||||
for def in ALL_ACHIEVEMENTS {
|
||||
assert!(records.iter().any(|r| r.id == def.id && !r.unlocked));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_unlocks_first_win_and_fires_event() {
|
||||
let mut app = headless_app();
|
||||
|
||||
// StatsPlugin runs update_stats_on_win first (after GameMutation); that
|
||||
// bumps games_won to 1 before evaluate_on_win reads StatsResource.
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 1000,
|
||||
time_seconds: 300,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let unlocked_first_win = app
|
||||
.world()
|
||||
.resource::<AchievementsResource>()
|
||||
.0
|
||||
.iter()
|
||||
.find(|r| r.id == "first_win")
|
||||
.map(|r| r.unlocked)
|
||||
.unwrap_or(false);
|
||||
assert!(unlocked_first_win);
|
||||
|
||||
// Verify the event was emitted.
|
||||
let events = app.world().resource::<Events<AchievementUnlockedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<String> = cursor.read(events).map(|e| e.0.clone()).collect();
|
||||
assert!(fired.contains(&"first_win".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_win_does_not_refire_already_unlocked_achievement() {
|
||||
let mut app = headless_app();
|
||||
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 1000,
|
||||
time_seconds: 300,
|
||||
});
|
||||
app.update();
|
||||
|
||||
// Clear events from first win.
|
||||
app.world_mut()
|
||||
.resource_mut::<Events<AchievementUnlockedEvent>>()
|
||||
.clear();
|
||||
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 1000,
|
||||
time_seconds: 300,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<AchievementUnlockedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<String> = cursor.read(events).map(|e| e.0.clone()).collect();
|
||||
assert!(
|
||||
!fired.contains(&"first_win".to_string()),
|
||||
"first_win must not re-fire on subsequent wins"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_name_resolves_known_and_unknown_ids() {
|
||||
assert_eq!(display_name_for("first_win"), "First Win");
|
||||
assert_eq!(display_name_for("bogus"), "bogus");
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,23 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::achievement_plugin::display_name_for;
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::daily_challenge_plugin::DailyChallengeCompletedEvent;
|
||||
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::progress_plugin::LevelUpEvent;
|
||||
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
|
||||
|
||||
/// Duration of a card slide (move) animation in seconds.
|
||||
pub const SLIDE_SECS: f32 = 0.15;
|
||||
|
||||
const WIN_TOAST_SECS: f32 = 4.0;
|
||||
const ACHIEVEMENT_TOAST_SECS: f32 = 3.0;
|
||||
const LEVELUP_TOAST_SECS: f32 = 3.0;
|
||||
const DAILY_TOAST_SECS: f32 = 3.0;
|
||||
const WEEKLY_TOAST_SECS: f32 = 3.0;
|
||||
const CASCADE_STAGGER: f32 = 0.05;
|
||||
const CASCADE_DURATION: f32 = 0.5;
|
||||
|
||||
@@ -49,12 +56,18 @@ impl Plugin for AnimationPlugin {
|
||||
// is idempotent in Bevy.
|
||||
app.add_event::<GameWonEvent>()
|
||||
.add_event::<AchievementUnlockedEvent>()
|
||||
.add_event::<LevelUpEvent>()
|
||||
.add_event::<DailyChallengeCompletedEvent>()
|
||||
.add_event::<WeeklyGoalCompletedEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
advance_card_anims,
|
||||
handle_win_cascade,
|
||||
handle_achievement_toast,
|
||||
handle_levelup_toast,
|
||||
handle_daily_toast,
|
||||
handle_weekly_toast,
|
||||
tick_toasts,
|
||||
)
|
||||
.after(GameMutation),
|
||||
@@ -126,12 +139,48 @@ fn handle_achievement_toast(
|
||||
for ev in events.read() {
|
||||
spawn_toast(
|
||||
&mut commands,
|
||||
format!("Achievement: {}", ev.0),
|
||||
format!("Achievement: {}", display_name_for(&ev.0)),
|
||||
ACHIEVEMENT_TOAST_SECS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_levelup_toast(mut commands: Commands, mut events: EventReader<LevelUpEvent>) {
|
||||
for ev in events.read() {
|
||||
spawn_toast(
|
||||
&mut commands,
|
||||
format!("Level Up! → {}", ev.new_level),
|
||||
LEVELUP_TOAST_SECS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_daily_toast(
|
||||
mut commands: Commands,
|
||||
mut events: EventReader<DailyChallengeCompletedEvent>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
spawn_toast(
|
||||
&mut commands,
|
||||
format!("Daily Challenge Complete! (Streak: {})", ev.streak),
|
||||
DAILY_TOAST_SECS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_weekly_toast(
|
||||
mut commands: Commands,
|
||||
mut events: EventReader<WeeklyGoalCompletedEvent>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
spawn_toast(
|
||||
&mut commands,
|
||||
format!("Weekly Goal: {}", ev.description),
|
||||
WEEKLY_TOAST_SECS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn tick_toasts(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Tracks the per-date daily challenge: a deterministic seed every player
|
||||
//! sees on a given calendar day, plus completion bookkeeping.
|
||||
//!
|
||||
//! When the player wins a game whose seed matches today's daily seed and
|
||||
//! today's date hasn't been completed yet, this plugin:
|
||||
//! - calls `PlayerProgress::record_daily_completion`
|
||||
//! - awards a fixed XP bonus (`DAILY_BONUS_XP`)
|
||||
//! - persists progress
|
||||
//! - emits `DailyChallengeCompletedEvent`
|
||||
//!
|
||||
//! Pressing **C** fires a `NewGameRequestEvent` with today's daily seed so
|
||||
//! the player can start a fresh attempt.
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use chrono::{Local, NaiveDate};
|
||||
use solitaire_data::{daily_seed_for, save_progress_to};
|
||||
|
||||
use crate::events::{GameWonEvent, NewGameRequestEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||
use crate::resources::GameStateResource;
|
||||
|
||||
/// Bonus XP awarded for completing today's daily challenge.
|
||||
pub const DAILY_BONUS_XP: u64 = 100;
|
||||
|
||||
/// The active daily challenge — date + RNG seed for that date's deal.
|
||||
#[derive(Resource, Debug, Clone, Copy)]
|
||||
pub struct DailyChallengeResource {
|
||||
pub date: NaiveDate,
|
||||
pub seed: u64,
|
||||
}
|
||||
|
||||
impl DailyChallengeResource {
|
||||
pub fn for_today() -> Self {
|
||||
let date = Local::now().date_naive();
|
||||
Self {
|
||||
date,
|
||||
seed: daily_seed_for(date),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fired when the player has just completed today's daily challenge.
|
||||
#[derive(Event, Debug, Clone, Copy)]
|
||||
pub struct DailyChallengeCompletedEvent {
|
||||
pub date: NaiveDate,
|
||||
pub streak: u32,
|
||||
}
|
||||
|
||||
pub struct DailyChallengePlugin;
|
||||
|
||||
impl Plugin for DailyChallengePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.insert_resource(DailyChallengeResource::for_today())
|
||||
.add_event::<DailyChallengeCompletedEvent>()
|
||||
.add_event::<GameWonEvent>()
|
||||
.add_event::<NewGameRequestEvent>()
|
||||
// record/award after the base ProgressUpdate so we don't fight
|
||||
// ProgressPlugin's add_xp on the same frame.
|
||||
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
|
||||
.add_systems(Update, handle_start_daily_request.before(GameMutation));
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_daily_completion(
|
||||
mut wins: EventReader<GameWonEvent>,
|
||||
daily: Res<DailyChallengeResource>,
|
||||
game: Res<GameStateResource>,
|
||||
mut progress: ResMut<ProgressResource>,
|
||||
path: Res<ProgressStoragePath>,
|
||||
mut completed: EventWriter<DailyChallengeCompletedEvent>,
|
||||
) {
|
||||
for _ in wins.read() {
|
||||
if game.0.seed != daily.seed {
|
||||
continue;
|
||||
}
|
||||
if !progress.0.record_daily_completion(daily.date) {
|
||||
// Already counted today — no-op.
|
||||
continue;
|
||||
}
|
||||
progress.0.add_xp(DAILY_BONUS_XP);
|
||||
if let Some(target) = &path.0 {
|
||||
if let Err(e) = save_progress_to(target, &progress.0) {
|
||||
warn!("failed to save progress after daily completion: {e}");
|
||||
}
|
||||
}
|
||||
completed.send(DailyChallengeCompletedEvent {
|
||||
date: daily.date,
|
||||
streak: progress.0.daily_challenge_streak,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_start_daily_request(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
daily: Res<DailyChallengeResource>,
|
||||
mut new_game: EventWriter<NewGameRequestEvent>,
|
||||
) {
|
||||
if keys.just_pressed(KeyCode::KeyC) {
|
||||
new_game.send(NewGameRequestEvent {
|
||||
seed: Some(daily.seed),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::progress_plugin::ProgressPlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(ProgressPlugin::headless())
|
||||
.add_plugins(DailyChallengePlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_uses_today() {
|
||||
let app = headless_app();
|
||||
let r = app.world().resource::<DailyChallengeResource>();
|
||||
assert_eq!(r.date, Local::now().date_naive());
|
||||
assert_eq!(r.seed, daily_seed_for(r.date));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winning_with_daily_seed_completes_and_fires_event() {
|
||||
let mut app = headless_app();
|
||||
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
||||
|
||||
// Replace the GameState with one whose seed matches the daily seed.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(daily_seed, DrawMode::DrawOne);
|
||||
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 200,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let progress = &app.world().resource::<ProgressResource>().0;
|
||||
assert_eq!(progress.daily_challenge_streak, 1);
|
||||
// +100 from the daily bonus
|
||||
assert!(progress.total_xp >= DAILY_BONUS_XP);
|
||||
|
||||
let events = app.world().resource::<Events<DailyChallengeCompletedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||
assert_eq!(fired.len(), 1);
|
||||
assert_eq!(fired[0].streak, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winning_with_unrelated_seed_does_not_complete_daily() {
|
||||
let mut app = headless_app();
|
||||
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
||||
// Use a deliberately different seed.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(daily_seed.wrapping_add(7777), DrawMode::DrawOne);
|
||||
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 200,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let progress = &app.world().resource::<ProgressResource>().0;
|
||||
assert_eq!(progress.daily_challenge_streak, 0);
|
||||
|
||||
let events = app.world().resource::<Events<DailyChallengeCompletedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert!(cursor.read(events).next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_win_same_day_is_idempotent() {
|
||||
let mut app = headless_app();
|
||||
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||
GameState::new(daily_seed, DrawMode::DrawOne);
|
||||
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 200,
|
||||
});
|
||||
app.update();
|
||||
// Re-send win.
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 200,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let progress = &app.world().resource::<ProgressResource>().0;
|
||||
assert_eq!(progress.daily_challenge_streak, 1, "streak does not double-count");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_c_fires_new_game_with_daily_seed() {
|
||||
let mut app = headless_app();
|
||||
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyC);
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<NewGameRequestEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||
assert_eq!(fired.len(), 1);
|
||||
assert_eq!(fired[0].seed, Some(daily_seed));
|
||||
}
|
||||
}
|
||||
@@ -48,10 +48,46 @@ impl Plugin for GamePlugin {
|
||||
)
|
||||
.chain()
|
||||
.in_set(GameMutation),
|
||||
);
|
||||
)
|
||||
.add_systems(Update, tick_elapsed_time);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure, testable helper. Updates `elapsed_seconds` and drains the
|
||||
/// fractional accumulator into whole-second ticks. No-op when `is_won`.
|
||||
pub fn advance_elapsed(
|
||||
elapsed_seconds: &mut u64,
|
||||
accumulator: &mut f32,
|
||||
delta_secs: f32,
|
||||
is_won: bool,
|
||||
) {
|
||||
if is_won {
|
||||
return;
|
||||
}
|
||||
*accumulator += delta_secs;
|
||||
while *accumulator >= 1.0 {
|
||||
*elapsed_seconds = elapsed_seconds.saturating_add(1);
|
||||
*accumulator -= 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Increment `GameState.elapsed_seconds` once per real-world second while
|
||||
/// the game is in progress (not won). Stops counting on win so the final
|
||||
/// time reflects how long the player took to solve the deal.
|
||||
fn tick_elapsed_time(
|
||||
time: Res<Time>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
mut accumulator: Local<f32>,
|
||||
) {
|
||||
let is_won = game.0.is_won;
|
||||
advance_elapsed(
|
||||
&mut game.0.elapsed_seconds,
|
||||
&mut accumulator,
|
||||
time.delta_secs(),
|
||||
is_won,
|
||||
);
|
||||
}
|
||||
|
||||
fn seed_from_system_time() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -232,6 +268,37 @@ mod tests {
|
||||
assert_ne!(before, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_elapsed_drains_accumulator_into_whole_seconds() {
|
||||
let mut elapsed = 0;
|
||||
let mut acc = 0.0;
|
||||
advance_elapsed(&mut elapsed, &mut acc, 2.5, false);
|
||||
assert_eq!(elapsed, 2);
|
||||
// Remaining 0.5 should still be in the accumulator.
|
||||
advance_elapsed(&mut elapsed, &mut acc, 0.5, false);
|
||||
assert_eq!(elapsed, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_elapsed_is_noop_when_won() {
|
||||
let mut elapsed = 100;
|
||||
let mut acc = 0.0;
|
||||
advance_elapsed(&mut elapsed, &mut acc, 5.0, true);
|
||||
assert_eq!(elapsed, 100);
|
||||
assert_eq!(acc, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_elapsed_handles_subsecond_deltas_without_skipping() {
|
||||
let mut elapsed = 0;
|
||||
let mut acc = 0.0;
|
||||
// 16ms × 60 frames/sec ≈ 1 second; should produce 1 tick.
|
||||
for _ in 0..60 {
|
||||
advance_elapsed(&mut elapsed, &mut acc, 1.0 / 60.0, false);
|
||||
}
|
||||
assert!(elapsed == 1, "expected 1 second, got {elapsed}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_move_does_not_fire_state_changed() {
|
||||
let mut app = test_app(42);
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
//! Bevy integration layer for Solitaire Quest.
|
||||
|
||||
pub mod achievement_plugin;
|
||||
pub mod animation_plugin;
|
||||
pub mod card_plugin;
|
||||
pub mod daily_challenge_plugin;
|
||||
pub mod events;
|
||||
pub mod game_plugin;
|
||||
pub mod input_plugin;
|
||||
pub mod layout;
|
||||
pub mod progress_plugin;
|
||||
pub mod resources;
|
||||
pub mod stats_plugin;
|
||||
pub mod table_plugin;
|
||||
pub mod weekly_goals_plugin;
|
||||
|
||||
pub use achievement_plugin::{AchievementPlugin, AchievementsResource};
|
||||
pub use daily_challenge_plugin::{
|
||||
DailyChallengeCompletedEvent, DailyChallengePlugin, DailyChallengeResource,
|
||||
};
|
||||
pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate};
|
||||
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
||||
pub use animation_plugin::{AnimationPlugin, CardAnim};
|
||||
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
||||
pub use events::{
|
||||
@@ -19,4 +30,5 @@ pub use game_plugin::{GameMutation, GamePlugin};
|
||||
pub use input_plugin::InputPlugin;
|
||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
||||
pub use resources::{DragState, GameStateResource, SyncStatus, SyncStatusResource};
|
||||
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
||||
pub use table_plugin::{PileMarker, TableBackground, TablePlugin};
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Awards XP on `GameWonEvent`, persists `PlayerProgress`, and emits a
|
||||
//! `LevelUpEvent` when a win pushes the player to a new level.
|
||||
//!
|
||||
//! Configurable storage path:
|
||||
//! - `ProgressPlugin::default()` uses the platform data dir
|
||||
//! - `ProgressPlugin::headless()` disables I/O for tests
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_data::{
|
||||
load_progress_from, progress_file_path, save_progress_to, xp_for_win, PlayerProgress,
|
||||
};
|
||||
|
||||
use crate::events::GameWonEvent;
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::resources::GameStateResource;
|
||||
|
||||
/// Bevy resource wrapping the current `PlayerProgress`.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct ProgressResource(pub PlayerProgress);
|
||||
|
||||
/// Persistence path for `ProgressResource`. `None` disables I/O.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct ProgressStoragePath(pub Option<PathBuf>);
|
||||
|
||||
/// Fired when a win pushes the player to a new level.
|
||||
#[derive(Event, Debug, Clone, Copy)]
|
||||
pub struct LevelUpEvent {
|
||||
pub previous_level: u32,
|
||||
pub new_level: u32,
|
||||
pub total_xp: u64,
|
||||
}
|
||||
|
||||
/// System set for the progress-mutating systems. Downstream plugins that
|
||||
/// read `ProgressResource` after a win should run `.after(ProgressUpdate)`.
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ProgressUpdate;
|
||||
|
||||
pub struct ProgressPlugin {
|
||||
pub storage_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for ProgressPlugin {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
storage_path: progress_file_path(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProgressPlugin {
|
||||
/// Plugin configured with no persistence — for tests and headless apps.
|
||||
pub fn headless() -> Self {
|
||||
Self { storage_path: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for ProgressPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let loaded = match &self.storage_path {
|
||||
Some(path) => load_progress_from(path),
|
||||
None => PlayerProgress::default(),
|
||||
};
|
||||
app.insert_resource(ProgressResource(loaded))
|
||||
.insert_resource(ProgressStoragePath(self.storage_path.clone()))
|
||||
.add_event::<LevelUpEvent>()
|
||||
.add_event::<GameWonEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
award_xp_on_win
|
||||
.after(GameMutation)
|
||||
.in_set(ProgressUpdate),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn award_xp_on_win(
|
||||
mut wins: EventReader<GameWonEvent>,
|
||||
mut levelups: EventWriter<LevelUpEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
path: Res<ProgressStoragePath>,
|
||||
mut progress: ResMut<ProgressResource>,
|
||||
) {
|
||||
for ev in wins.read() {
|
||||
let used_undo = game.0.undo_count > 0;
|
||||
let amount = xp_for_win(ev.time_seconds, used_undo);
|
||||
let prev_level = progress.0.add_xp(amount);
|
||||
if progress.0.leveled_up_from(prev_level) {
|
||||
levelups.send(LevelUpEvent {
|
||||
previous_level: prev_level,
|
||||
new_level: progress.0.level,
|
||||
total_xp: progress.0.total_xp,
|
||||
});
|
||||
}
|
||||
if let Some(target) = &path.0 {
|
||||
if let Err(e) = save_progress_to(target, &progress.0) {
|
||||
warn!("failed to save progress: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(ProgressPlugin::headless());
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_resource_starts_at_default() {
|
||||
let app = headless_app();
|
||||
let p = &app.world().resource::<ProgressResource>().0;
|
||||
assert_eq!(p, &PlayerProgress::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_awards_base_xp() {
|
||||
let mut app = headless_app();
|
||||
// Game starts with undo_count = 0, so the no-undo bonus applies.
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 300, // no speed bonus
|
||||
});
|
||||
app.update();
|
||||
|
||||
let xp = app.world().resource::<ProgressResource>().0.total_xp;
|
||||
// base 50 + no_undo 25 = 75
|
||||
assert_eq!(xp, 75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_after_undo_grants_no_undo_bonus_off() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.undo_count = 1;
|
||||
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 300,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let xp = app.world().resource::<ProgressResource>().0.total_xp;
|
||||
// base 50 only, since undo was used
|
||||
assert_eq!(xp, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_win_includes_speed_bonus() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 0,
|
||||
});
|
||||
app.update();
|
||||
|
||||
// base 50 + speed 50 + no_undo 25 = 125
|
||||
let xp = app.world().resource::<ProgressResource>().0.total_xp;
|
||||
assert_eq!(xp, 125);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_500_xp_fires_levelup_event() {
|
||||
let mut app = headless_app();
|
||||
// Pre-load 480 XP so a 75-XP win pushes us over the 500 boundary.
|
||||
app.world_mut().resource_mut::<ProgressResource>().0.total_xp = 480;
|
||||
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 300,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<LevelUpEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||
assert_eq!(fired.len(), 1, "exactly one level-up");
|
||||
assert_eq!(fired[0].previous_level, 0);
|
||||
assert_eq!(fired[0].new_level, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_without_level_change_does_not_fire_levelup() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 300,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let events = app.world().resource::<Events<LevelUpEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
assert!(cursor.read(events).next().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
//! Loads, updates, and persists `StatsSnapshot` in response to game events,
|
||||
//! and provides a toggleable full-window stats overlay (press `S`).
|
||||
//!
|
||||
//! The persistence path is configurable via `StatsPlugin::storage_path`.
|
||||
//! In production, `StatsPlugin::default()` loads/saves from the platform
|
||||
//! data dir. In tests, use `StatsPlugin::headless()` to disable all file
|
||||
//! I/O so the user's real stats file is neither read nor overwritten.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_data::{
|
||||
load_stats_from, save_stats_to, stats_file_path, PlayerProgress, StatsSnapshot, WEEKLY_GOALS,
|
||||
};
|
||||
|
||||
use crate::events::{GameWonEvent, NewGameRequestEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::GameStateResource;
|
||||
|
||||
/// Bevy resource wrapping the current stats.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct StatsResource(pub StatsSnapshot);
|
||||
|
||||
/// Persistence path for `StatsResource`. `None` disables I/O.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct StatsStoragePath(pub Option<PathBuf>);
|
||||
|
||||
/// System set for the stats-mutating systems. Downstream plugins that read
|
||||
/// `StatsResource` after a win/abandon should run `.after(StatsUpdate)`.
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct StatsUpdate;
|
||||
|
||||
/// Marker component on the stats overlay root node.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StatsScreen;
|
||||
|
||||
/// Registers stats resources, update systems, and the UI toggle.
|
||||
pub struct StatsPlugin {
|
||||
/// Where to persist stats. `None` disables all file I/O (for tests).
|
||||
pub storage_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for StatsPlugin {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
storage_path: stats_file_path(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StatsPlugin {
|
||||
/// Plugin configured with no persistence. Use in tests and headless apps
|
||||
/// where touching `~/.local/share/solitaire_quest/stats.json` would be
|
||||
/// incorrect.
|
||||
pub fn headless() -> Self {
|
||||
Self { storage_path: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for StatsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let loaded = match &self.storage_path {
|
||||
Some(path) => load_stats_from(path),
|
||||
None => StatsSnapshot::default(),
|
||||
};
|
||||
app.insert_resource(StatsResource(loaded))
|
||||
.insert_resource(StatsStoragePath(self.storage_path.clone()))
|
||||
.add_event::<GameWonEvent>()
|
||||
.add_event::<NewGameRequestEvent>()
|
||||
// record_abandoned must read `move_count` BEFORE handle_new_game
|
||||
// clobbers it with a fresh game.
|
||||
.add_systems(
|
||||
Update,
|
||||
update_stats_on_new_game
|
||||
.before(GameMutation)
|
||||
.in_set(StatsUpdate),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
update_stats_on_win.after(GameMutation).in_set(StatsUpdate),
|
||||
)
|
||||
.add_systems(Update, toggle_stats_screen.after(GameMutation));
|
||||
}
|
||||
}
|
||||
|
||||
fn persist(path: &StatsStoragePath, stats: &StatsSnapshot, context: &str) {
|
||||
let Some(target) = &path.0 else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = save_stats_to(target, stats) {
|
||||
warn!("failed to save stats after {context}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn update_stats_on_win(
|
||||
mut events: EventReader<GameWonEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
mut stats: ResMut<StatsResource>,
|
||||
path: Res<StatsStoragePath>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
stats
|
||||
.0
|
||||
.update_on_win(ev.score, ev.time_seconds, &game.0.draw_mode);
|
||||
persist(&path, &stats.0, "win");
|
||||
}
|
||||
}
|
||||
|
||||
fn update_stats_on_new_game(
|
||||
mut events: EventReader<NewGameRequestEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
mut stats: ResMut<StatsResource>,
|
||||
path: Res<StatsStoragePath>,
|
||||
) {
|
||||
for _ in events.read() {
|
||||
if game.0.move_count > 0 && !game.0.is_won {
|
||||
stats.0.record_abandoned();
|
||||
persist(&path, &stats.0, "abandoned game");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_stats_screen(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
stats: Res<StatsResource>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
screens: Query<Entity, With<StatsScreen>>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::KeyS) {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.get_single() {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
} else {
|
||||
spawn_stats_screen(&mut commands, &stats.0, progress.as_deref().map(|p| &p.0));
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_stats_screen(
|
||||
commands: &mut Commands,
|
||||
stats: &StatsSnapshot,
|
||||
progress: Option<&PlayerProgress>,
|
||||
) {
|
||||
let win_rate = stats
|
||||
.win_rate()
|
||||
.map_or("N/A".to_string(), |r| format!("{r:.1}%"));
|
||||
let fastest = if stats.fastest_win_seconds == u64::MAX {
|
||||
"N/A".to_string()
|
||||
} else {
|
||||
format_duration(stats.fastest_win_seconds)
|
||||
};
|
||||
let avg = if stats.games_won == 0 {
|
||||
"N/A".to_string()
|
||||
} else {
|
||||
format_duration(stats.avg_time_seconds)
|
||||
};
|
||||
|
||||
let mut lines: Vec<String> = vec![
|
||||
"=== Statistics ===".to_string(),
|
||||
format!("Games Played: {}", stats.games_played),
|
||||
format!("Games Won: {}", stats.games_won),
|
||||
format!("Win Rate: {win_rate}"),
|
||||
format!(
|
||||
"Win Streak: {} (Best: {})",
|
||||
stats.win_streak_current, stats.win_streak_best
|
||||
),
|
||||
format!("Best Score: {}", stats.best_single_score),
|
||||
format!("Fastest Win: {fastest}"),
|
||||
format!("Avg Win Time: {avg}"),
|
||||
];
|
||||
|
||||
if let Some(p) = progress {
|
||||
lines.push(String::new());
|
||||
lines.push("=== Progression ===".to_string());
|
||||
lines.push(format!("Level: {}", p.level));
|
||||
lines.push(format!("Total XP: {}", p.total_xp));
|
||||
lines.push(format!(
|
||||
"Daily Streak: {}",
|
||||
p.daily_challenge_streak
|
||||
));
|
||||
lines.push(String::new());
|
||||
lines.push("-- Weekly Goals --".to_string());
|
||||
for goal in WEEKLY_GOALS {
|
||||
let progress_value = p
|
||||
.weekly_goal_progress
|
||||
.get(goal.id)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
lines.push(format!(
|
||||
" {}: {}/{}",
|
||||
goal.description, progress_value, goal.target
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
lines.push("Press S to close".to_string());
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
StatsScreen,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(0.0),
|
||||
top: Val::Percent(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Column,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
row_gap: Val::Px(6.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.88)),
|
||||
ZIndex(200),
|
||||
))
|
||||
.with_children(|b| {
|
||||
for line in lines {
|
||||
b.spawn((
|
||||
Text::new(line),
|
||||
TextFont {
|
||||
font_size: 24.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(0.95, 0.95, 0.90)),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn format_duration(secs: u64) -> String {
|
||||
let m = secs / 60;
|
||||
let s = secs % 60;
|
||||
format!("{m}m {s:02}s")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(StatsPlugin::headless());
|
||||
// MinimalPlugins doesn't register keyboard input — add it so the
|
||||
// toggle system can read ButtonInput<KeyCode> in tests.
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
// ProgressResource is an optional dependency for the stats screen;
|
||||
// include it so toggle tests exercise the progression panel.
|
||||
app.add_plugins(crate::progress_plugin::ProgressPlugin::headless());
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_resource_exists_after_startup() {
|
||||
let app = headless_app();
|
||||
assert!(app.world().get_resource::<StatsResource>().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_plugin_starts_with_default_stats() {
|
||||
let app = headless_app();
|
||||
let stats = &app.world().resource::<StatsResource>().0;
|
||||
assert_eq!(stats, &StatsSnapshot::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_event_increments_games_won() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 1000,
|
||||
time_seconds: 120,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let stats = &app.world().resource::<StatsResource>().0;
|
||||
assert_eq!(stats.games_won, 1);
|
||||
assert_eq!(stats.games_played, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_game_after_moves_records_abandoned() {
|
||||
let mut app = headless_app();
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::resources::GameStateResource>()
|
||||
.0
|
||||
.move_count = 3;
|
||||
|
||||
app.world_mut()
|
||||
.send_event(NewGameRequestEvent { seed: Some(999) });
|
||||
app.update();
|
||||
|
||||
let stats = &app.world().resource::<StatsResource>().0;
|
||||
assert_eq!(stats.games_played, 1);
|
||||
assert_eq!(stats.games_lost, 1);
|
||||
assert_eq!(stats.win_streak_current, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_game_without_moves_does_not_record_abandoned() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.send_event(NewGameRequestEvent { seed: Some(42) });
|
||||
app.update();
|
||||
|
||||
let stats = &app.world().resource::<StatsResource>().0;
|
||||
assert_eq!(stats.games_played, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_s_spawns_stats_screen() {
|
||||
let mut app = headless_app();
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&StatsScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyS);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&StatsScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_s_twice_closes_stats_screen() {
|
||||
let mut app = headless_app();
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyS);
|
||||
app.update();
|
||||
|
||||
// Release + clear + press: `press()` is a no-op if the key is already
|
||||
// in `pressed`, and MinimalPlugins doesn't include bevy_input's
|
||||
// per-frame updater to drain `just_pressed`, so we cycle manually.
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::KeyS);
|
||||
input.clear();
|
||||
input.press(KeyCode::KeyS);
|
||||
}
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&StatsScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Tracks per-ISO-week goal progress: rolls the counter set when the week
|
||||
//! changes, increments matching goals on `GameWonEvent`, awards
|
||||
//! `WEEKLY_GOAL_XP` when a goal completes, and persists.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use chrono::Local;
|
||||
use solitaire_data::{
|
||||
current_iso_week_key, save_progress_to, weekly_goal_by_id, WeeklyGoalContext, WEEKLY_GOALS,
|
||||
WEEKLY_GOAL_XP,
|
||||
};
|
||||
|
||||
use crate::events::GameWonEvent;
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||
use crate::resources::GameStateResource;
|
||||
|
||||
/// Fired when the player has just completed a weekly goal.
|
||||
#[derive(Event, Debug, Clone)]
|
||||
pub struct WeeklyGoalCompletedEvent {
|
||||
pub goal_id: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
pub struct WeeklyGoalsPlugin;
|
||||
|
||||
impl Plugin for WeeklyGoalsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_event::<WeeklyGoalCompletedEvent>()
|
||||
.add_event::<GameWonEvent>()
|
||||
// Run after GameMutation (so GameWonEvent is available) and
|
||||
// ProgressUpdate (so we don't fight ProgressPlugin's add_xp).
|
||||
.add_systems(
|
||||
Update,
|
||||
evaluate_weekly_goals
|
||||
.after(GameMutation)
|
||||
.after(ProgressUpdate),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_weekly_goals(
|
||||
mut wins: EventReader<GameWonEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
mut progress: ResMut<ProgressResource>,
|
||||
path: Res<ProgressStoragePath>,
|
||||
mut completions: EventWriter<WeeklyGoalCompletedEvent>,
|
||||
) {
|
||||
let mut events: Vec<&GameWonEvent> = wins.read().collect();
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Roll the week first so progress for old weeks doesn't carry over.
|
||||
let week_key = current_iso_week_key(Local::now().date_naive());
|
||||
progress.0.roll_weekly_goals_if_new_week(&week_key);
|
||||
|
||||
let mut any_change = false;
|
||||
let mut bonus_xp: u64 = 0;
|
||||
|
||||
// Drain in order so earlier wins roll up before later ones are evaluated
|
||||
// (only matters for backlogged events; usually 1 per frame).
|
||||
for ev in events.drain(..) {
|
||||
let ctx = WeeklyGoalContext {
|
||||
time_seconds: ev.time_seconds,
|
||||
used_undo: game.0.undo_count > 0,
|
||||
};
|
||||
for def in WEEKLY_GOALS {
|
||||
if !def.matches(&ctx) {
|
||||
continue;
|
||||
}
|
||||
let just_completed = progress.0.record_weekly_progress(def.id, def.target);
|
||||
any_change = true;
|
||||
if just_completed {
|
||||
bonus_xp = bonus_xp.saturating_add(WEEKLY_GOAL_XP);
|
||||
completions.send(WeeklyGoalCompletedEvent {
|
||||
goal_id: def.id.to_string(),
|
||||
description: def.description.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bonus_xp > 0 {
|
||||
progress.0.add_xp(bonus_xp);
|
||||
}
|
||||
|
||||
if any_change {
|
||||
if let Some(target) = &path.0 {
|
||||
if let Err(e) = save_progress_to(target, &progress.0) {
|
||||
warn!("failed to save progress after weekly goal update: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a goal id to its description (used for toasts).
|
||||
pub fn weekly_goal_description(id: &str) -> String {
|
||||
weekly_goal_by_id(id)
|
||||
.map(|g| g.description.to_string())
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::progress_plugin::ProgressPlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(ProgressPlugin::headless())
|
||||
.add_plugins(WeeklyGoalsPlugin);
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_win_increments_win_game_goal() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 200,
|
||||
});
|
||||
app.update();
|
||||
let p = &app.world().resource::<ProgressResource>().0;
|
||||
assert_eq!(p.weekly_goal_progress.get("weekly_5_wins"), Some(&1));
|
||||
// No-undo + slow win → no_undo goal also ticked, fast goal NOT ticked.
|
||||
assert_eq!(p.weekly_goal_progress.get("weekly_3_no_undo"), Some(&1));
|
||||
assert!(p.weekly_goal_progress.get("weekly_3_fast").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_win_ticks_fast_goal_too() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 60,
|
||||
});
|
||||
app.update();
|
||||
let p = &app.world().resource::<ProgressResource>().0;
|
||||
assert_eq!(p.weekly_goal_progress.get("weekly_3_fast"), Some(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_after_undo_does_not_tick_no_undo_goal() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.undo_count = 1;
|
||||
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 200,
|
||||
});
|
||||
app.update();
|
||||
let p = &app.world().resource::<ProgressResource>().0;
|
||||
assert_eq!(p.weekly_goal_progress.get("weekly_5_wins"), Some(&1));
|
||||
assert!(p.weekly_goal_progress.get("weekly_3_no_undo").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completing_a_goal_fires_event_and_awards_bonus() {
|
||||
let mut app = headless_app();
|
||||
// Pre-set the weekly_3_fast goal to 2/3 so the next fast win completes it.
|
||||
app.world_mut()
|
||||
.resource_mut::<ProgressResource>()
|
||||
.0
|
||||
.weekly_goal_progress
|
||||
.insert("weekly_3_fast".to_string(), 2);
|
||||
// Match the current ISO week key so roll_weekly_goals doesn't clear it.
|
||||
let key = current_iso_week_key(Local::now().date_naive());
|
||||
app.world_mut()
|
||||
.resource_mut::<ProgressResource>()
|
||||
.0
|
||||
.weekly_goal_week_iso = Some(key);
|
||||
|
||||
let xp_before = app.world().resource::<ProgressResource>().0.total_xp;
|
||||
|
||||
app.world_mut().send_event(GameWonEvent {
|
||||
score: 500,
|
||||
time_seconds: 60,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let p = &app.world().resource::<ProgressResource>().0;
|
||||
assert_eq!(p.weekly_goal_progress.get("weekly_3_fast"), Some(&3));
|
||||
// Delta = base win XP (from ProgressPlugin in the headless app) +
|
||||
// WEEKLY_GOAL_XP for completing the goal. Verify the goal bonus is
|
||||
// included by checking `delta - base_win_xp == WEEKLY_GOAL_XP`.
|
||||
let base_win_xp = solitaire_data::xp_for_win(60, false);
|
||||
assert_eq!(p.total_xp - xp_before, base_win_xp + WEEKLY_GOAL_XP);
|
||||
|
||||
let events = app.world().resource::<Events<WeeklyGoalCompletedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(events).cloned().collect();
|
||||
assert!(fired.iter().any(|e| e.goal_id == "weekly_3_fast"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_goal_description_resolves_known_and_unknown() {
|
||||
assert_eq!(
|
||||
weekly_goal_description("weekly_5_wins"),
|
||||
"Win 5 games this week"
|
||||
);
|
||||
assert_eq!(weekly_goal_description("nope"), "nope");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user