From 172f32a4d8a4fb23975baa81c6a73155f689a0b9 Mon Sep 17 00:00:00 2001 From: demenik Date: Thu, 11 Dec 2025 17:26:19 +0100 Subject: [PATCH 1/4] feat: Add achievement tracking and persistence (#42) --- Cargo.lock | 26 +++++++++-- Cargo.toml | 2 + src/features/achievement/components.rs | 65 ++++++++++++++++++++++++++ src/features/achievement/mod.rs | 32 +++++++++++++ src/features/mod.rs | 2 + src/features/phase/components.rs | 1 + src/features/phase/mod.rs | 6 ++- src/features/savegame/mod.rs | 8 ++++ src/main.rs | 1 + 9 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 src/features/achievement/components.rs create mode 100644 src/features/achievement/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 0ac9b38..1e88b66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -210,7 +210,7 @@ dependencies = [ "flate2", "itertools 0.13.0", "nom", - "strum", + "strum 0.26.3", "thiserror 1.0.69", ] @@ -3940,6 +3940,8 @@ dependencies = [ "directories", "serde", "serde_json", + "strum 0.27.2", + "strum_macros 0.27.2", "tungstenite", "url", "uuid", @@ -4565,9 +4567,15 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", ] +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + [[package]] name = "strum_macros" version = "0.26.4" @@ -4581,6 +4589,18 @@ dependencies = [ "syn", ] +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "svg_fmt" version = "0.4.5" @@ -4714,7 +4734,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index bdee263..2dedd2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ serde_json = "1.0" directories = "6.0" tungstenite = { version = "0.28.0", features = ["native-tls"] } url = "2.5.7" +strum = "0.27.2" +strum_macros = "0.27.2" [dev-dependencies] uuid = "1.18.1" diff --git a/src/features/achievement/components.rs b/src/features/achievement/components.rs new file mode 100644 index 0000000..788daf8 --- /dev/null +++ b/src/features/achievement/components.rs @@ -0,0 +1,65 @@ +use crate::{features::phase::components::SessionTracker, prelude::*}; +use std::collections::HashMap; +use strum_macros::EnumIter; + +/// Represents an unlockable achievement. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, EnumIter)] +pub enum AchievementId { + // Berry achievements + FirstSteps, + MasterFarmer, + BerryTycoon, + // Focus achievements, + GettingStarted, + FocusMaster, + ZenMaster, + // Withered achievements + Negligent, + CompostKing, +} + +impl AchievementId { + /// Label to be displayed ingame + pub fn label(&self) -> String { + match self { + AchievementId::FirstSteps => "Erste Schritte: Verdiene eine Beere.", + AchievementId::MasterFarmer => "Meisterbauer: Verdiene 100 Beeren.", + AchievementId::BerryTycoon => "Beeren-Tycoon: Verdiene 1.000 Beeren.", + AchievementId::GettingStarted => "Aller Anfang: Schließe deine erste Fokus-Phase ab.", + AchievementId::FocusMaster => "Fokus-Meister: Schließe 10 Fokus-Phasen ab.", + AchievementId::ZenMaster => "Zen-Meister: Schließe 50 Fokus-Phasen ab.", + AchievementId::Negligent => "Nachlässig: Lasse eine Pflanze verdorren.", + AchievementId::CompostKing => "Kompost-König: Lasse 10 Pflanzen verdorren.", + } + .into() + } + + /// Checks if an achievement's conditions are met + pub fn conditions_met(&self, tracker: &SessionTracker) -> bool { + match self { + AchievementId::FirstSteps => tracker.total_berries_earned >= 1, + AchievementId::MasterFarmer => tracker.total_berries_earned >= 100, + AchievementId::BerryTycoon => tracker.total_berries_earned >= 1000, + AchievementId::GettingStarted => tracker.completed_focus_phases >= 1, + AchievementId::FocusMaster => tracker.completed_focus_phases >= 10, + AchievementId::ZenMaster => tracker.completed_focus_phases >= 50, + AchievementId::Negligent => tracker.total_plants_withered >= 1, + AchievementId::CompostKing => tracker.total_plants_withered >= 10, + } + } +} + +#[derive(Resource, Default, Debug, Serialize, Deserialize, Clone)] +pub struct AchievementProgress { + pub unlocked: HashMap, +} + +impl AchievementProgress { + pub fn is_unlocked(&self, id: &AchievementId) -> bool { + *self.unlocked.get(id).unwrap_or(&false) + } + + pub fn unlock(&mut self, id: AchievementId) { + self.unlocked.insert(id, true); + } +} diff --git a/src/features/achievement/mod.rs b/src/features/achievement/mod.rs new file mode 100644 index 0000000..eaff214 --- /dev/null +++ b/src/features/achievement/mod.rs @@ -0,0 +1,32 @@ +use crate::features::notification::components::Notifications; +use crate::features::phase::components::SessionTracker; +use crate::prelude::*; +use components::{AchievementId, AchievementProgress}; +use strum::IntoEnumIterator; + +pub mod components; + +pub struct AchievementPlugin; + +impl Plugin for AchievementPlugin { + fn build(&self, app: &mut App) { + app.init_resource::(); + app.add_systems( + Update, + check_achievements.run_if(in_state(AppState::GameScreen)), + ); + } +} + +fn check_achievements( + tracker: Res, + mut progress: ResMut, + mut notifications: ResMut, +) { + for achievement in AchievementId::iter() { + if !progress.is_unlocked(&achievement) && achievement.conditions_met(&tracker) { + progress.unlock(achievement.clone()); + notifications.info(Some("Erfolg freigeschaltet!"), achievement.label()); + } + } +} diff --git a/src/features/mod.rs b/src/features/mod.rs index 6b6c2b8..a47c7ad 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -1,3 +1,4 @@ +pub mod achievement; pub mod config; pub mod core; pub mod game_screen; @@ -14,6 +15,7 @@ pub mod start_screen; pub mod ui; pub mod wonderevent; +pub use achievement::AchievementPlugin; pub use core::CorePlugin; pub use game_screen::GameScreenPlugin; pub use grid::GridPlugin; diff --git a/src/features/phase/components.rs b/src/features/phase/components.rs index 7dc430d..e41947d 100644 --- a/src/features/phase/components.rs +++ b/src/features/phase/components.rs @@ -67,4 +67,5 @@ impl Default for TimerSettings { pub struct SessionTracker { pub completed_focus_phases: u32, pub total_berries_earned: u32, + pub total_plants_withered: u32, } diff --git a/src/features/phase/mod.rs b/src/features/phase/mod.rs index baf6397..0bb49e2 100644 --- a/src/features/phase/mod.rs +++ b/src/features/phase/mod.rs @@ -213,7 +213,7 @@ pub fn next_phase( pub fn handle_continue( mut messages: MessageReader, mut phase_res: ResMut, - session_tracker: Res, + mut session_tracker: ResMut, settings: Res, mut tile_query: Query<&mut TileState>, game_config: Res, @@ -256,6 +256,10 @@ pub fn handle_continue( } } + if new_withered && !*withered { + session_tracker.total_plants_withered += 1; + } + *state = TileState::Occupied { seed: seed.clone(), watered: false, diff --git a/src/features/savegame/mod.rs b/src/features/savegame/mod.rs index ca6441b..4584e23 100644 --- a/src/features/savegame/mod.rs +++ b/src/features/savegame/mod.rs @@ -1,3 +1,4 @@ +use crate::features::achievement::components::AchievementProgress; use crate::features::phase::components::{SessionTracker, TimerSettings}; use crate::features::savegame::ui::load_popup_handler; use crate::prelude::*; @@ -34,6 +35,7 @@ struct SaveData { tiles: Vec>, current_phase: CurrentPhase, session_tracker: SessionTracker, + achievement_progress: AchievementProgress, timer_settings: TimerSettings, pom_position: GridPosition, inventory: Vec, @@ -47,6 +49,7 @@ fn dump_savegame( tile_query: Query<&TileState>, phase: Res, tracker: Res, + achievement_progress: Res, settings: Res, pom_query: Query<&GridPosition, With>, inventory: Res, @@ -85,6 +88,7 @@ fn dump_savegame( tiles: tile_states, current_phase: phase.clone(), session_tracker: tracker.clone(), + achievement_progress: achievement_progress.clone(), timer_settings: settings.clone(), pom_position: *pom_pos, inventory: item_stacks, @@ -118,6 +122,7 @@ fn load_savegame( mut tile_query: Query<&mut TileState>, mut phase: ResMut, mut tracker: ResMut, + mut achievement_progress: ResMut, mut settings: ResMut, mut pom_query: Query<(&mut GridPosition, &mut Transform), With>, mut inventory: ResMut, @@ -134,6 +139,7 @@ fn load_savegame( Ok(save_data) => { *phase = save_data.current_phase; *tracker = save_data.session_tracker; + *achievement_progress = save_data.achievement_progress; *settings = save_data.timer_settings; if let Ok((mut pom_pos, mut pom_transform)) = pom_query.single_mut() { @@ -185,11 +191,13 @@ fn reset_savegame( mut tile_query: Query<&mut TileState>, mut phase: ResMut, mut tracker: ResMut, + mut achievement_progress: ResMut, mut settings: ResMut, mut pom_query: Query<(&mut GridPosition, &mut Transform), With>, mut inventory: ResMut, ) { *tracker = SessionTracker::default(); + *achievement_progress = AchievementProgress::default(); *settings = TimerSettings::default(); *phase = CurrentPhase(Phase::Focus { duration: settings.focus_duration as f32, diff --git a/src/main.rs b/src/main.rs index 01c3643..af08cac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,7 @@ fn main() { features::ShopPlugin, features::WonderEventPlugin, features::NotificationPlugin, + features::AchievementPlugin, )) .insert_resource(config) .add_systems(Startup, overwrite_default_font) From efc69f6be0cec47c4547f819800a17d86430d7dd Mon Sep 17 00:00:00 2001 From: demenik Date: Thu, 11 Dec 2025 17:35:00 +0100 Subject: [PATCH 2/4] test: Fix session tests for new session tracker field --- tests/session.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/session.rs b/tests/session.rs index b17efec..323c779 100644 --- a/tests/session.rs +++ b/tests/session.rs @@ -15,6 +15,7 @@ fn test_session_tracker_focus_to_short_break() { let session_tracker = SessionTracker { completed_focus_phases: 1, total_berries_earned: 0, + total_plants_withered: 0, }; next_phase(&mut current_phase, &session_tracker, &timer_settings); @@ -45,6 +46,7 @@ fn test_session_tracker_focus_to_long_break() { let session_tracker = SessionTracker { completed_focus_phases: timer_settings.long_break_interval, total_berries_earned: 0, + total_plants_withered: 0, }; next_phase(&mut current_phase, &session_tracker, &timer_settings); @@ -73,6 +75,7 @@ fn test_session_tracker_break_to_focus() { let session_tracker = SessionTracker { completed_focus_phases: 1, total_berries_earned: 0, + total_plants_withered: 0, }; // Arbitrary value, should not change let timer_settings = TimerSettings::default(); @@ -99,6 +102,7 @@ fn test_session_tracker_not_finished_phase_no_change() { let session_tracker = SessionTracker { completed_focus_phases: 0, total_berries_earned: 0, + total_plants_withered: 0, }; let timer_settings = TimerSettings::default(); From aae0420528d48f9e722cc2eef401b47311a665d5 Mon Sep 17 00:00:00 2001 From: demenik Date: Thu, 11 Dec 2025 17:35:41 +0100 Subject: [PATCH 3/4] refactor: Remove empty trailing line --- tests/session.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/session.rs b/tests/session.rs index 323c779..e613bef 100644 --- a/tests/session.rs +++ b/tests/session.rs @@ -120,4 +120,3 @@ fn test_session_tracker_not_finished_phase_no_change() { "Session tracker should not change if phase not Finished" ); } - From 63a23ae9a8cc6f009d63574ed756a21e68e530a2 Mon Sep 17 00:00:00 2001 From: demenik Date: Thu, 11 Dec 2025 17:42:55 +0100 Subject: [PATCH 4/4] test: Add achievement unlock logic test --- tests/achievement.rs | 75 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/achievement.rs diff --git a/tests/achievement.rs b/tests/achievement.rs new file mode 100644 index 0000000..8f9001c --- /dev/null +++ b/tests/achievement.rs @@ -0,0 +1,75 @@ +use pomomon_garden::features::achievement::AchievementPlugin; +use pomomon_garden::features::achievement::components::{AchievementId, AchievementProgress}; +use pomomon_garden::features::notification::components::Notifications; +use pomomon_garden::features::phase::components::SessionTracker; +use pomomon_garden::prelude::*; +use strum::IntoEnumIterator; + +#[test] +fn test_achievement_unlock_logic() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + app.add_plugins(AchievementPlugin); + app.insert_resource(Notifications::default()); // Needs to be present because check_achievements uses it + + // Helper to run systems and get progress + let mut run_check = |completed_focus: u32, total_berries: u32, total_withered: u32| { + app.insert_resource(SessionTracker { + completed_focus_phases: completed_focus, + total_berries_earned: total_berries, + total_plants_withered: total_withered, + }); + app.update(); // Runs check_achievements + app.world().resource::().clone() + }; + + // --- Initial State --- + let progress = run_check(0, 0, 0); + for id in AchievementId::iter() { + assert!( + !progress.is_unlocked(&id), + "Achievement {:?} should not be unlocked initially", + id + ); + } + + // --- First Steps (1 berry) --- + let progress = run_check(0, 1, 0); + assert!(progress.is_unlocked(&AchievementId::FirstSteps)); + + // --- Getting Started (1 focus phase) --- + let progress = run_check(1, 1, 0); + assert!(progress.is_unlocked(&AchievementId::GettingStarted)); + + // --- Negligent (1 withered plant) --- + let progress = run_check(1, 1, 1); + assert!(progress.is_unlocked(&AchievementId::Negligent)); + + // --- Focus Master (10 focus phases) --- + let progress = run_check(10, 1, 1); + assert!(progress.is_unlocked(&AchievementId::FocusMaster)); + + // --- Master Farmer (100 berries) --- + let progress = run_check(10, 100, 1); + assert!(progress.is_unlocked(&AchievementId::MasterFarmer)); + + // --- Zen Master (50 focus phases) --- + let progress = run_check(50, 100, 1); + assert!(progress.is_unlocked(&AchievementId::ZenMaster)); + + // --- Compost King (10 withered plants) --- + let progress = run_check(50, 100, 10); + assert!(progress.is_unlocked(&AchievementId::CompostKing)); + + // --- Berry Tycoon (1000 berries) --- + let progress = run_check(50, 1000, 10); + assert!(progress.is_unlocked(&AchievementId::BerryTycoon)); + + // --- Test idempotency: already unlocked should stay unlocked --- + let initial_progress = progress.clone(); + let progress = run_check(50, 1000, 10); + assert_eq!(progress.unlocked.len(), initial_progress.unlocked.len()); // Same number unlocked + for id in AchievementId::iter() { + assert_eq!(progress.is_unlocked(&id), initial_progress.is_unlocked(&id)); + } +}