Merge branch '42-achievement-data-persistence' into 'dev'

Add achievement tracking and persistence

See merge request softwaregrundprojekt/2025-2026/einzelprojekt/tutorium-moritz/bernroider-dominik/bernroider-dominik!44
This commit is contained in:
Dominik Bernroider
2025-12-11 16:46:22 +00:00
11 changed files with 218 additions and 5 deletions

26
Cargo.lock generated
View File

@@ -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]]

View File

@@ -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"

View File

@@ -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<AchievementId, bool>,
}
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);
}
}

View File

@@ -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::<AchievementProgress>();
app.add_systems(
Update,
check_achievements.run_if(in_state(AppState::GameScreen)),
);
}
}
fn check_achievements(
tracker: Res<SessionTracker>,
mut progress: ResMut<AchievementProgress>,
mut notifications: ResMut<Notifications>,
) {
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());
}
}
}

View File

@@ -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;

View File

@@ -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,
}

View File

@@ -213,7 +213,7 @@ pub fn next_phase(
pub fn handle_continue(
mut messages: MessageReader<NextPhaseMessage>,
mut phase_res: ResMut<CurrentPhase>,
session_tracker: Res<SessionTracker>,
mut session_tracker: ResMut<SessionTracker>,
settings: Res<TimerSettings>,
mut tile_query: Query<&mut TileState>,
game_config: Res<GameConfig>,
@@ -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,

View File

@@ -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<Vec<TileState>>,
current_phase: CurrentPhase,
session_tracker: SessionTracker,
achievement_progress: AchievementProgress,
timer_settings: TimerSettings,
pom_position: GridPosition,
inventory: Vec<ItemStack>,
@@ -47,6 +49,7 @@ fn dump_savegame(
tile_query: Query<&TileState>,
phase: Res<CurrentPhase>,
tracker: Res<SessionTracker>,
achievement_progress: Res<AchievementProgress>,
settings: Res<TimerSettings>,
pom_query: Query<&GridPosition, With<Pom>>,
inventory: Res<Inventory>,
@@ -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<CurrentPhase>,
mut tracker: ResMut<SessionTracker>,
mut achievement_progress: ResMut<AchievementProgress>,
mut settings: ResMut<TimerSettings>,
mut pom_query: Query<(&mut GridPosition, &mut Transform), With<Pom>>,
mut inventory: ResMut<Inventory>,
@@ -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<CurrentPhase>,
mut tracker: ResMut<SessionTracker>,
mut achievement_progress: ResMut<AchievementProgress>,
mut settings: ResMut<TimerSettings>,
mut pom_query: Query<(&mut GridPosition, &mut Transform), With<Pom>>,
mut inventory: ResMut<Inventory>,
) {
*tracker = SessionTracker::default();
*achievement_progress = AchievementProgress::default();
*settings = TimerSettings::default();
*phase = CurrentPhase(Phase::Focus {
duration: settings.focus_duration as f32,

View File

@@ -36,6 +36,7 @@ fn main() {
features::ShopPlugin,
features::WonderEventPlugin,
features::NotificationPlugin,
features::AchievementPlugin,
))
.insert_resource(config)
.add_systems(Startup, overwrite_default_font)

75
tests/achievement.rs Normal file
View File

@@ -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::<AchievementProgress>().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));
}
}

View File

@@ -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();
@@ -116,4 +120,3 @@ fn test_session_tracker_not_finished_phase_no_change() {
"Session tracker should not change if phase not Finished"
);
}