Compare commits

...

18 Commits

Author SHA1 Message Date
funman300 6b793aa2ab modified: solitaire_engine/src/game_plugin.rs 2026-04-24 20:12:10 -07:00
funman300 0fdfbced6d docs: mark Phase 6 part 3 (completion toasts + progression panel) complete
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:28:26 -07:00
funman300 363ddc9b75 feat(engine): surface daily/weekly completions as toasts + progression panel
Phase 6 part 3 (partial):
- AnimationPlugin now shows a 3-second toast on DailyChallengeCompletedEvent
  and WeeklyGoalCompletedEvent.
- Stats overlay (S key) appends a Progression section with level, total XP,
  daily streak, and a live Weekly Goals list pulling from WEEKLY_GOALS.

Special modes (Time Attack / Challenge / Zen) and unlock UI deferred.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:28:13 -07:00
funman300 0609d4eef3 docs: mark Phase 6 part 2b (weekly goals) complete in session handoff
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:25:31 -07:00
funman300 b730902d76 feat(engine): add weekly goals with ISO-week rollover and +75 XP bonus
Phase 6 part 2b:
- solitaire_data::weekly defines WeeklyGoalKind, WeeklyGoalDef,
  WeeklyGoalContext, current_iso_week_key, and three starter goals
  (5 wins, 3 no-undo wins, 3 fast wins).
- PlayerProgress gains weekly_goal_week_iso, roll_weekly_goals_if_new_week,
  and record_weekly_progress (returns true exactly once per goal completion).
- WeeklyGoalsPlugin evaluates GameWonEvent against WEEKLY_GOALS, rolls the
  week if needed, increments matching counters, awards WEEKLY_GOAL_XP for
  newly-completed goals, persists progress, and fires
  WeeklyGoalCompletedEvent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:25:18 -07:00
funman300 578938a9b2 docs: mark Phase 6 part 2a (daily challenge + level-up toast) complete
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:18:04 -07:00
funman300 622b35a3bf feat(engine): add daily challenge, level-up toast, and daily_devotee achievement
Phase 6 part 2 (partial):
- daily_seed_for(date) and PlayerProgress::record_daily_completion in
  solitaire_data, with streak logic that increments on consecutive days,
  resets on a skipped day, and is idempotent on same-day re-completions.
- DailyChallengePlugin tracks today's seed, awards +100 XP and updates
  the streak when the player wins a game whose seed matches. Pressing C
  starts a new game with the daily seed.
- LevelUpEvent toast in AnimationPlugin announces level changes.
- AchievementContext gains daily_challenge_streak; daily_devotee
  achievement unlocks at streak >= 7. AchievementPlugin reads
  ProgressResource and runs after ProgressUpdate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:17:59 -07:00
funman300 0cb8b32ec4 docs: mark Phase 6 part 1 (XP/levels) complete in session handoff
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:11:32 -07:00
funman300 ef043c14d4 feat(engine): add ProgressPlugin awarding XP on wins with level-up events
On GameWonEvent, computes xp_for_win(time, used_undo) from
solitaire_data, calls PlayerProgress::add_xp, and emits LevelUpEvent
when the level changes. Persists atomically through the configurable
storage path; ProgressPlugin::headless() disables I/O for tests.

Introduces ProgressUpdate system set so future systems can run after
progress mutations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:11:22 -07:00
funman300 cfdb3b7547 feat(data): add PlayerProgress with XP/level helpers and atomic persistence
level_for_xp implements the two-segment level formula from
ARCHITECTURE.md §13. xp_for_win = base 50 + linearly-scaled speed bonus
(10..=50 for sub-2-minute wins) + 25 if no undo was used. PlayerProgress
exposes add_xp returning the previous level so callers can detect
level-up events.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:10:28 -07:00
funman300 5512a141b6 docs: mark Phase 5 complete in session handoff
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 12:54:40 -07:00
funman300 1f6994a084 feat(engine): add AchievementPlugin with persistent unlock tracking
On GameWonEvent, build an AchievementContext from StatsResource + GameState
+ wall-clock hour, evaluate ALL_ACHIEVEMENTS, flip newly-satisfied records
to unlocked, persist atomically, and emit AchievementUnlockedEvent for
each new unlock. AnimationPlugin's toast resolves the event's ID to the
achievement's display name via achievement_plugin::display_name_for.

Introduces StatsUpdate system set so AchievementPlugin can reliably run
after StatsResource reflects the win. AchievementPlugin::headless() used
in tests to avoid touching ~/.local/share/solitaire_quest/achievements.json.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 12:53:31 -07:00
funman300 4589c52368 feat(data): add AchievementRecord and atomic achievements.json persistence
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 12:51:15 -07:00
funman300 82fa584cbb feat(core): add achievement module with 14 unlock conditions
Introduces AchievementContext (stats + last-win snapshot), AchievementDef,
ALL_ACHIEVEMENTS, and check_achievements. Adds undo_count to GameState
so the no_undo and speed_and_skill conditions are evaluable.

Skipped achievements that depend on features not yet built:
daily_devotee (progress), comeback (recycle counter), zen_winner (modes),
perfectionist (max-score calc). They land in later phases.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 12:50:46 -07:00
funman300 b9957909b1 docs: mark Phase 3 and Phase 4 complete in session handoff
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 12:44:18 -07:00
funman300 2ce11f8f4d feat(engine): add StatsPlugin with persistent stats and toggleable overlay
StatsPlugin loads stats on startup, persists them on every GameWonEvent
and abandoned NewGameRequestEvent (>=1 move, not won), and provides a
full-window overlay toggled with `S` showing games played/won, win rate,
streak, best score, fastest win, and average win time.

The storage path is configurable via StatsPlugin::storage_path: the
default ctor uses dirs::data_dir(); StatsPlugin::headless() disables
I/O entirely so tests don't read or overwrite the user's real
stats.json. record_abandoned runs before GameMutation so it reads
move_count before handle_new_game clobbers it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 12:43:49 -07:00
funman300 5ced4c01ce feat(data): add atomic stats persistence (load_stats_from, save_stats_to)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 12:37:57 -07:00
funman300 f8cce2433d feat(data): add StatsSnapshot with update_on_win and record_abandoned
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 12:37:21 -07:00
19 changed files with 2840 additions and 25 deletions
+63 -22
View File
@@ -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 (3A3F) 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 1050 + 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 48 (in order after Phase 3)
### Phases 78 (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 8AC | 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
```
+9 -1
View File
@@ -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();
}
+345
View File
@@ -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 (023) at the time of win. `None` if unknown.
pub wall_clock_hour: Option<u32>,
}
/// A single achievement's static metadata + unlock condition.
#[derive(Debug, Clone, Copy)]
pub struct AchievementDef {
pub id: &'static str,
pub name: &'static str,
pub description: &'static str,
/// Hidden from the achievements screen until unlocked.
pub secret: bool,
pub condition: fn(&AchievementContext) -> bool,
}
impl AchievementDef {
pub fn is_unlocked_by(&self, ctx: &AchievementContext) -> bool {
(self.condition)(ctx)
}
}
// ---------------------------------------------------------------------------
// Condition predicates
// ---------------------------------------------------------------------------
fn first_win(c: &AchievementContext) -> bool {
c.games_won >= 1
}
fn on_a_roll(c: &AchievementContext) -> bool {
c.win_streak_current >= 3
}
fn unstoppable(c: &AchievementContext) -> bool {
c.win_streak_current >= 10
}
fn century(c: &AchievementContext) -> bool {
c.games_played >= 100
}
fn veteran(c: &AchievementContext) -> bool {
c.games_played >= 500
}
fn speed_demon(c: &AchievementContext) -> bool {
c.last_win_time_seconds < 180
}
fn lightning(c: &AchievementContext) -> bool {
c.last_win_time_seconds < 90
}
fn high_scorer(c: &AchievementContext) -> bool {
c.best_single_score >= 5_000
}
fn point_machine(c: &AchievementContext) -> bool {
c.lifetime_score >= 50_000
}
fn no_undo(c: &AchievementContext) -> bool {
!c.last_win_used_undo
}
fn draw_three_master(c: &AchievementContext) -> bool {
c.draw_three_wins >= 10
}
fn night_owl(c: &AchievementContext) -> bool {
// "Play after midnight" — 00:00 through 05:59 local time.
matches!(c.wall_clock_hour, Some(h) if h < 6)
}
fn early_bird(c: &AchievementContext) -> bool {
// "Play before 6am" — same window as night_owl; both unlock together
// when someone wins in the small hours. Retained for progression variety.
matches!(c.wall_clock_hour, Some(h) if h < 6)
}
fn speed_and_skill(c: &AchievementContext) -> bool {
c.last_win_time_seconds < 90 && !c.last_win_used_undo
}
fn daily_devotee(c: &AchievementContext) -> bool {
c.daily_challenge_streak >= 7
}
/// All currently-evaluable achievements. Order is stable so persistence files
/// remain readable across versions (new achievements append).
pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
AchievementDef {
id: "first_win",
name: "First Win",
description: "Win your first game",
secret: false,
condition: first_win,
},
AchievementDef {
id: "on_a_roll",
name: "On a Roll",
description: "Win 3 games in a row",
secret: false,
condition: on_a_roll,
},
AchievementDef {
id: "unstoppable",
name: "Unstoppable",
description: "Win 10 games in a row",
secret: false,
condition: unstoppable,
},
AchievementDef {
id: "century",
name: "Century",
description: "Play 100 games",
secret: false,
condition: century,
},
AchievementDef {
id: "veteran",
name: "Veteran",
description: "Play 500 games",
secret: false,
condition: veteran,
},
AchievementDef {
id: "speed_demon",
name: "Speed Demon",
description: "Win in under 3 minutes",
secret: false,
condition: speed_demon,
},
AchievementDef {
id: "lightning",
name: "Lightning",
description: "Win in under 90 seconds",
secret: false,
condition: lightning,
},
AchievementDef {
id: "high_scorer",
name: "High Scorer",
description: "Score at least 5,000 in one game",
secret: false,
condition: high_scorer,
},
AchievementDef {
id: "point_machine",
name: "Point Machine",
description: "Accumulate 50,000 lifetime points",
secret: false,
condition: point_machine,
},
AchievementDef {
id: "no_undo",
name: "No Undo",
description: "Win a game without using undo",
secret: false,
condition: no_undo,
},
AchievementDef {
id: "draw_three_master",
name: "Draw 3 Master",
description: "Win 10 games in Draw 3 mode",
secret: false,
condition: draw_three_master,
},
AchievementDef {
id: "night_owl",
name: "Night Owl",
description: "Win a game after midnight",
secret: false,
condition: night_owl,
},
AchievementDef {
id: "early_bird",
name: "Early Bird",
description: "Win a game before 6am",
secret: false,
condition: early_bird,
},
AchievementDef {
id: "speed_and_skill",
name: "???",
description: "A secret achievement",
secret: true,
condition: speed_and_skill,
},
AchievementDef {
id: "daily_devotee",
name: "Daily Devotee",
description: "Complete the daily challenge 7 days in a row",
secret: false,
condition: daily_devotee,
},
];
/// Return every `AchievementDef` whose condition is satisfied by `ctx`.
pub fn check_achievements(ctx: &AchievementContext) -> Vec<&'static AchievementDef> {
ALL_ACHIEVEMENTS
.iter()
.filter(|d| d.is_unlocked_by(ctx))
.collect()
}
/// Look up an achievement definition by ID.
pub fn achievement_by_id(id: &str) -> Option<&'static AchievementDef> {
ALL_ACHIEVEMENTS.iter().find(|d| d.id == id)
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> AchievementContext {
AchievementContext {
games_played: 0,
games_won: 0,
win_streak_current: 0,
best_single_score: 0,
lifetime_score: 0,
draw_three_wins: 0,
daily_challenge_streak: 0,
last_win_score: 0,
last_win_time_seconds: u64::MAX,
last_win_used_undo: true,
wall_clock_hour: None,
}
}
#[test]
fn all_ids_are_unique() {
let mut ids: Vec<&str> = ALL_ACHIEVEMENTS.iter().map(|d| d.id).collect();
ids.sort();
let len = ids.len();
ids.dedup();
assert_eq!(ids.len(), len, "duplicate achievement ID in ALL_ACHIEVEMENTS");
}
#[test]
fn no_achievements_unlocked_at_default() {
let c = ctx();
assert!(check_achievements(&c).is_empty());
}
#[test]
fn first_win_unlocks_on_first_won_game() {
let mut c = ctx();
c.games_won = 1;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"first_win"));
}
#[test]
fn lightning_requires_under_90_seconds() {
let mut c = ctx();
c.games_won = 1;
c.last_win_time_seconds = 89;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"lightning"));
assert!(ids.contains(&"speed_demon"));
c.last_win_time_seconds = 90;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"lightning"));
assert!(ids.contains(&"speed_demon"));
}
#[test]
fn no_undo_requires_clean_win() {
let mut c = ctx();
c.games_won = 1;
c.last_win_used_undo = false;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"no_undo"));
c.last_win_used_undo = true;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"no_undo"));
}
#[test]
fn secret_speed_and_skill_requires_both_clean_and_fast() {
let mut c = ctx();
c.games_won = 1;
c.last_win_time_seconds = 60;
c.last_win_used_undo = false;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"speed_and_skill"));
c.last_win_used_undo = true;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"speed_and_skill"));
}
#[test]
fn night_owl_requires_early_hours() {
let mut c = ctx();
c.games_won = 1;
c.wall_clock_hour = Some(2);
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"night_owl"));
c.wall_clock_hour = Some(12);
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"night_owl"));
}
#[test]
fn daily_devotee_requires_7_day_streak() {
let mut c = ctx();
c.daily_challenge_streak = 6;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(!ids.contains(&"daily_devotee"));
c.daily_challenge_streak = 7;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(ids.contains(&"daily_devotee"));
}
#[test]
fn achievement_by_id_finds_known_and_returns_none_for_unknown() {
assert_eq!(achievement_by_id("first_win").map(|d| d.name), Some("First Win"));
assert!(achievement_by_id("nonexistent").is_none());
}
}
+5
View File
@@ -35,6 +35,9 @@ pub struct GameState {
pub seed: u64,
pub is_won: bool,
pub is_auto_completable: bool,
/// Number of times `undo()` has been successfully invoked this game.
/// Used by achievement conditions like `no_undo`.
pub undo_count: u32,
undo_stack: Vec<StateSnapshot>,
}
@@ -64,6 +67,7 @@ impl GameState {
seed,
is_won: false,
is_auto_completable: false,
undo_count: 0,
undo_stack: Vec::new(),
}
}
@@ -242,6 +246,7 @@ impl GameState {
self.move_count = snapshot.move_count;
self.is_won = false;
self.is_auto_completable = false;
self.undo_count = self.undo_count.saturating_add(1);
Ok(())
}
+1
View File
@@ -1,3 +1,4 @@
pub mod achievement;
pub mod card;
pub mod deck;
pub mod error;
+139
View File
@@ -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());
}
}
+23
View File
@@ -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,
};
+409
View File
@@ -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 110: `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);
}
}
+199
View File
@@ -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 0100, 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);
}
}
+112
View File
@@ -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());
}
}
+148
View File
@@ -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);
}
}
+243
View File
@@ -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");
}
}
+50 -1
View File
@@ -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));
}
}
+68 -1
View File
@@ -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);
+12
View File
@@ -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};
+208
View File
@@ -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());
}
}
+372
View File
@@ -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
);
}
}
+211
View File
@@ -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");
}
}