From ef043c14d4aaec5268b207a762f66c98e633e46f Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 24 Apr 2026 19:11:22 -0700 Subject: [PATCH] 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 --- solitaire_app/src/main.rs | 5 +- solitaire_engine/src/lib.rs | 2 + solitaire_engine/src/progress_plugin.rs | 208 ++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 solitaire_engine/src/progress_plugin.rs diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 4cb22f5..9eb9bd3 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -1,7 +1,7 @@ use bevy::prelude::*; use solitaire_engine::{ - AchievementPlugin, AnimationPlugin, CardPlugin, GamePlugin, InputPlugin, StatsPlugin, - TablePlugin, + AchievementPlugin, AnimationPlugin, CardPlugin, GamePlugin, InputPlugin, ProgressPlugin, + StatsPlugin, TablePlugin, }; fn main() { @@ -23,5 +23,6 @@ fn main() { .add_plugins(AnimationPlugin) .add_plugins(StatsPlugin::default()) .add_plugins(AchievementPlugin::default()) + .add_plugins(ProgressPlugin::default()) .run(); } diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 1fe8685..0daada5 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -7,11 +7,13 @@ 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 use achievement_plugin::{AchievementPlugin, AchievementsResource}; +pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate}; pub use animation_plugin::{AnimationPlugin, CardAnim}; pub use card_plugin::{CardEntity, CardLabel, CardPlugin}; pub use events::{ diff --git a/solitaire_engine/src/progress_plugin.rs b/solitaire_engine/src/progress_plugin.rs new file mode 100644 index 0000000..f1beade --- /dev/null +++ b/solitaire_engine/src/progress_plugin.rs @@ -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); + +/// 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, +} + +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::() + .add_event::() + .add_systems( + Update, + award_xp_on_win + .after(GameMutation) + .in_set(ProgressUpdate), + ); + } +} + +fn award_xp_on_win( + mut wins: EventReader, + mut levelups: EventWriter, + game: Res, + path: Res, + mut progress: ResMut, +) { + 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::().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::().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::() + .0 + .undo_count = 1; + + app.world_mut().send_event(GameWonEvent { + score: 500, + time_seconds: 300, + }); + app.update(); + + let xp = app.world().resource::().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::().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::().0.total_xp = 480; + + app.world_mut().send_event(GameWonEvent { + score: 500, + time_seconds: 300, + }); + app.update(); + + let events = app.world().resource::>(); + 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::>(); + let mut cursor = events.get_cursor(); + assert!(cursor.read(events).next().is_none()); + } +}