//! 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::() .add_event::() // 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, game: Res, mut progress: ResMut, path: Res, mut completions: EventWriter, ) { 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::().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::().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::() .0 .undo_count = 1; app.world_mut().send_event(GameWonEvent { score: 500, time_seconds: 200, }); app.update(); let p = &app.world().resource::().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::() .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::() .0 .weekly_goal_week_iso = Some(key); let xp_before = app.world().resource::().0.total_xp; app.world_mut().send_event(GameWonEvent { score: 500, time_seconds: 60, }); app.update(); let p = &app.world().resource::().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::>(); 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"); } }