diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 5d9f295..4cb22f5 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -1,6 +1,7 @@ use bevy::prelude::*; use solitaire_engine::{ - AnimationPlugin, CardPlugin, GamePlugin, InputPlugin, StatsPlugin, TablePlugin, + AchievementPlugin, AnimationPlugin, CardPlugin, GamePlugin, InputPlugin, StatsPlugin, + TablePlugin, }; fn main() { @@ -21,5 +22,6 @@ fn main() { .add_plugins(InputPlugin) .add_plugins(AnimationPlugin) .add_plugins(StatsPlugin::default()) + .add_plugins(AchievementPlugin::default()) .run(); } diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs new file mode 100644 index 0000000..722ff31 --- /dev/null +++ b/solitaire_engine/src/achievement_plugin.rs @@ -0,0 +1,234 @@ +//! 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::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); + +/// Persistence path for `AchievementsResource`. `None` disables I/O. +#[derive(Resource, Debug, Clone)] +pub struct AchievementsStoragePath(pub Option); + +pub struct AchievementPlugin { + pub storage_path: Option, +} + +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::() + .add_event::() + // Run after GameMutation (so GameWonEvent is available) and after + // StatsUpdate (so StatsResource already reflects this win). + .add_systems( + Update, + evaluate_on_win.after(GameMutation).after(StatsUpdate), + ); + } +} + +fn evaluate_on_win( + mut wins: EventReader, + mut unlocks: EventWriter, + game: Res, + stats: Res, + path: Res, + mut achievements: ResMut, +) { + 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, + 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(AchievementPlugin::headless()); + // StatsPlugin's UI toggle system reads ButtonInput; under + // MinimalPlugins it isn't auto-registered. + app.init_resource::>(); + app.update(); + app + } + + #[test] + fn resource_is_populated_with_all_known_ids() { + let app = headless_app(); + let records = &app.world().resource::().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::() + .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::>(); + let mut cursor = events.get_cursor(); + let fired: Vec = 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::>() + .clear(); + + app.world_mut().send_event(GameWonEvent { + score: 1000, + time_seconds: 300, + }); + app.update(); + + let events = app.world().resource::>(); + let mut cursor = events.get_cursor(); + let fired: Vec = 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"); + } +} diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index a7c699b..a0ff7c5 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -5,6 +5,7 @@ use bevy::prelude::*; +use crate::achievement_plugin::display_name_for; use crate::card_plugin::CardEntity; use crate::events::{AchievementUnlockedEvent, GameWonEvent}; use crate::game_plugin::GameMutation; @@ -126,7 +127,7 @@ 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, ); } diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 6282f9f..1fe8685 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -1,5 +1,6 @@ //! Bevy integration layer for Solitaire Quest. +pub mod achievement_plugin; pub mod animation_plugin; pub mod card_plugin; pub mod events; @@ -10,6 +11,7 @@ pub mod resources; pub mod stats_plugin; pub mod table_plugin; +pub use achievement_plugin::{AchievementPlugin, AchievementsResource}; pub use animation_plugin::{AnimationPlugin, CardAnim}; pub use card_plugin::{CardEntity, CardLabel, CardPlugin}; pub use events::{ @@ -20,5 +22,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}; +pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate}; pub use table_plugin::{PileMarker, TableBackground, TablePlugin}; diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index ad088e2..b288d3e 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -24,6 +24,11 @@ pub struct StatsResource(pub StatsSnapshot); #[derive(Resource, Debug, Clone)] pub struct StatsStoragePath(pub Option); +/// 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; @@ -63,11 +68,17 @@ impl Plugin for StatsPlugin { .add_event::() // 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)) .add_systems( Update, - (update_stats_on_win, toggle_stats_screen).after(GameMutation), - ); + 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)); } }