From b730902d7639304dafdeca59261d94978b5706c3 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 24 Apr 2026 19:25:18 -0700 Subject: [PATCH] feat(engine): add weekly goals with ISO-week rollover and +75 XP bonus Phase 6 part 2b: - solitaire_data::weekly defines WeeklyGoalKind, WeeklyGoalDef, WeeklyGoalContext, current_iso_week_key, and three starter goals (5 wins, 3 no-undo wins, 3 fast wins). - PlayerProgress gains weekly_goal_week_iso, roll_weekly_goals_if_new_week, and record_weekly_progress (returns true exactly once per goal completion). - WeeklyGoalsPlugin evaluates GameWonEvent against WEEKLY_GOALS, rolls the week if needed, increments matching counters, awards WEEKLY_GOAL_XP for newly-completed goals, persists progress, and fires WeeklyGoalCompletedEvent. Co-Authored-By: Claude Opus 4.7 --- solitaire_app/src/main.rs | 3 +- solitaire_data/src/lib.rs | 6 + solitaire_data/src/progress.rs | 74 +++++++ solitaire_data/src/weekly.rs | 148 ++++++++++++++ solitaire_engine/src/lib.rs | 2 + solitaire_engine/src/weekly_goals_plugin.rs | 211 ++++++++++++++++++++ 6 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 solitaire_data/src/weekly.rs create mode 100644 solitaire_engine/src/weekly_goals_plugin.rs diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 23b1563..63d9395 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, DailyChallengePlugin, GamePlugin, InputPlugin, - ProgressPlugin, StatsPlugin, TablePlugin, + ProgressPlugin, StatsPlugin, TablePlugin, WeeklyGoalsPlugin, }; fn main() { @@ -25,5 +25,6 @@ fn main() { .add_plugins(ProgressPlugin::default()) .add_plugins(AchievementPlugin::default()) .add_plugins(DailyChallengePlugin) + .add_plugins(WeeklyGoalsPlugin) .run(); } diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index 7956f8d..4f1628e 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -51,3 +51,9 @@ pub use progress::{ daily_seed_for, level_for_xp, load_progress_from, progress_file_path, save_progress_to, xp_for_win, PlayerProgress, }; + +pub mod weekly; +pub use weekly::{ + current_iso_week_key, weekly_goal_by_id, WeeklyGoalContext, WeeklyGoalDef, WeeklyGoalKind, + WEEKLY_GOALS, WEEKLY_GOAL_XP, +}; diff --git a/solitaire_data/src/progress.rs b/solitaire_data/src/progress.rs index 26c1713..34a229e 100644 --- a/solitaire_data/src/progress.rs +++ b/solitaire_data/src/progress.rs @@ -60,6 +60,11 @@ pub struct PlayerProgress { pub daily_challenge_last_completed: Option, pub daily_challenge_streak: u32, pub weekly_goal_progress: HashMap, + /// ISO week key (e.g. `"2026-W17"`) the current `weekly_goal_progress` + /// counters belong to. When the engine sees a different week it clears + /// progress and updates this field. + #[serde(default)] + pub weekly_goal_week_iso: Option, pub unlocked_card_backs: Vec, pub unlocked_backgrounds: Vec, pub last_modified: DateTime, @@ -73,6 +78,7 @@ impl Default for PlayerProgress { daily_challenge_last_completed: None, daily_challenge_streak: 0, weekly_goal_progress: HashMap::new(), + weekly_goal_week_iso: None, unlocked_card_backs: vec![0], // back #0 always available unlocked_backgrounds: vec![0], // background #0 always available last_modified: DateTime::UNIX_EPOCH, @@ -96,6 +102,32 @@ impl PlayerProgress { self.level > prev_level } + /// Reset weekly-goal progress when the ISO week has rolled over. + /// No-op if the stored week key already matches `current`. + pub fn roll_weekly_goals_if_new_week(&mut self, current: &str) -> bool { + if self.weekly_goal_week_iso.as_deref() == Some(current) { + return false; + } + self.weekly_goal_progress.clear(); + self.weekly_goal_week_iso = Some(current.to_string()); + self.last_modified = Utc::now(); + true + } + + /// Increment progress for `goal_id` by 1, capped at `target`. + /// Returns `true` if this call brought the counter from below `target` + /// to at-or-above `target` (i.e. just completed the goal). + pub fn record_weekly_progress(&mut self, goal_id: &str, target: u32) -> bool { + let entry = self.weekly_goal_progress.entry(goal_id.to_string()).or_insert(0); + if *entry >= target { + // Already complete — do not over-count. + return false; + } + *entry = entry.saturating_add(1); + self.last_modified = Utc::now(); + *entry >= target + } + /// Record a daily-challenge completion for `date`. /// /// - First completion ever, or a gap of more than one day: streak resets to 1. @@ -323,6 +355,48 @@ mod tests { assert_eq!(p.daily_challenge_streak, 1); } + // --- Weekly goals --- + + #[test] + fn first_week_roll_initializes_key_and_returns_true() { + let mut p = PlayerProgress::default(); + let rolled = p.roll_weekly_goals_if_new_week("2026-W17"); + assert!(rolled); + assert_eq!(p.weekly_goal_week_iso.as_deref(), Some("2026-W17")); + } + + #[test] + fn same_week_roll_is_noop() { + let mut p = PlayerProgress::default(); + p.roll_weekly_goals_if_new_week("2026-W17"); + p.weekly_goal_progress.insert("g1".into(), 3); + let rolled = p.roll_weekly_goals_if_new_week("2026-W17"); + assert!(!rolled); + assert_eq!(p.weekly_goal_progress.get("g1"), Some(&3)); + } + + #[test] + fn new_week_roll_clears_progress_and_updates_key() { + let mut p = PlayerProgress::default(); + p.roll_weekly_goals_if_new_week("2026-W17"); + p.weekly_goal_progress.insert("g1".into(), 3); + let rolled = p.roll_weekly_goals_if_new_week("2026-W18"); + assert!(rolled); + assert!(p.weekly_goal_progress.is_empty()); + assert_eq!(p.weekly_goal_week_iso.as_deref(), Some("2026-W18")); + } + + #[test] + fn record_weekly_progress_returns_true_only_on_completion_step() { + let mut p = PlayerProgress::default(); + assert!(!p.record_weekly_progress("g1", 3)); + assert!(!p.record_weekly_progress("g1", 3)); + assert!(p.record_weekly_progress("g1", 3), "third tick completes"); + // Further ticks should not re-fire completion. + assert!(!p.record_weekly_progress("g1", 3)); + assert_eq!(p.weekly_goal_progress.get("g1"), Some(&3)); + } + #[test] fn same_day_completion_is_idempotent() { let mut p = PlayerProgress::default(); diff --git a/solitaire_data/src/weekly.rs b/solitaire_data/src/weekly.rs new file mode 100644 index 0000000..30aec0b --- /dev/null +++ b/solitaire_data/src/weekly.rs @@ -0,0 +1,148 @@ +//! Weekly goal definitions and helpers. +//! +//! Goals reset every ISO week. Engine evaluates them on `GameWonEvent` and +//! increments matching counters in `PlayerProgress::weekly_goal_progress`. + +use chrono::{Datelike, NaiveDate}; + +/// XP awarded each time a weekly goal is just completed. +pub const WEEKLY_GOAL_XP: u64 = 75; + +/// What kind of game outcome counts as progress toward this goal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WeeklyGoalKind { + /// Any win counts. + WinGame, + /// A win without using `undo` counts. + WinWithoutUndo, + /// A win in strictly fewer than `seconds` seconds counts. + WinUnder { seconds: u64 }, +} + +/// Static metadata for a single weekly goal. +#[derive(Debug, Clone, Copy)] +pub struct WeeklyGoalDef { + pub id: &'static str, + pub description: &'static str, + pub target: u32, + pub kind: WeeklyGoalKind, +} + +/// Per-event facts a goal needs to decide whether it matched. +#[derive(Debug, Clone, Copy)] +pub struct WeeklyGoalContext { + pub time_seconds: u64, + pub used_undo: bool, +} + +impl WeeklyGoalDef { + /// Returns `true` if this win event counts as one tick of progress + /// toward this goal. + pub fn matches(&self, ctx: &WeeklyGoalContext) -> bool { + match self.kind { + WeeklyGoalKind::WinGame => true, + WeeklyGoalKind::WinWithoutUndo => !ctx.used_undo, + WeeklyGoalKind::WinUnder { seconds } => ctx.time_seconds < seconds, + } + } +} + +/// All currently-active weekly goals. +pub const WEEKLY_GOALS: &[WeeklyGoalDef] = &[ + WeeklyGoalDef { + id: "weekly_5_wins", + description: "Win 5 games this week", + target: 5, + kind: WeeklyGoalKind::WinGame, + }, + WeeklyGoalDef { + id: "weekly_3_no_undo", + description: "Win 3 games without undo this week", + target: 3, + kind: WeeklyGoalKind::WinWithoutUndo, + }, + WeeklyGoalDef { + id: "weekly_3_fast", + description: "Win 3 games in under 3 minutes this week", + target: 3, + kind: WeeklyGoalKind::WinUnder { seconds: 180 }, + }, +]; + +/// Stable identifier for the ISO week containing `date`, e.g. `"2026-W17"`. +/// Same string for every player worldwide on the same calendar week. +pub fn current_iso_week_key(date: NaiveDate) -> String { + let iso = date.iso_week(); + format!("{}-W{:02}", iso.year(), iso.week()) +} + +/// Look up a weekly-goal definition by id. +pub fn weekly_goal_by_id(id: &str) -> Option<&'static WeeklyGoalDef> { + WEEKLY_GOALS.iter().find(|g| g.id == id) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx(time: u64, undo: bool) -> WeeklyGoalContext { + WeeklyGoalContext { + time_seconds: time, + used_undo: undo, + } + } + + #[test] + fn all_goal_ids_are_unique() { + let mut ids: Vec<&str> = WEEKLY_GOALS.iter().map(|g| g.id).collect(); + ids.sort(); + let len = ids.len(); + ids.dedup(); + assert_eq!(ids.len(), len); + } + + #[test] + fn win_game_always_matches() { + let g = weekly_goal_by_id("weekly_5_wins").unwrap(); + assert!(g.matches(&ctx(60, false))); + assert!(g.matches(&ctx(99999, true))); + } + + #[test] + fn no_undo_only_matches_clean_wins() { + let g = weekly_goal_by_id("weekly_3_no_undo").unwrap(); + assert!(g.matches(&ctx(120, false))); + assert!(!g.matches(&ctx(120, true))); + } + + #[test] + fn fast_only_matches_under_3_minutes() { + let g = weekly_goal_by_id("weekly_3_fast").unwrap(); + assert!(g.matches(&ctx(60, true))); + assert!(g.matches(&ctx(179, true))); + assert!(!g.matches(&ctx(180, true))); + assert!(!g.matches(&ctx(300, false))); + } + + #[test] + fn iso_week_key_is_stable_within_a_week() { + let monday = NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(); // 2026-W17 Mon + let sunday = NaiveDate::from_ymd_opt(2026, 4, 26).unwrap(); // 2026-W17 Sun + assert_eq!(current_iso_week_key(monday), current_iso_week_key(sunday)); + } + + #[test] + fn iso_week_key_differs_across_weeks() { + let w17 = NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(); + let w18 = NaiveDate::from_ymd_opt(2026, 4, 27).unwrap(); + assert_ne!(current_iso_week_key(w17), current_iso_week_key(w18)); + } + + #[test] + fn iso_week_key_format_includes_year_and_week() { + let d = NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(); + let key = current_iso_week_key(d); + assert!(key.starts_with("2026-W")); + assert_eq!(key.len(), 8); + } +} diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 2df91dd..7a5ad85 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -12,12 +12,14 @@ pub mod progress_plugin; pub mod resources; pub mod stats_plugin; pub mod table_plugin; +pub mod weekly_goals_plugin; pub use achievement_plugin::{AchievementPlugin, AchievementsResource}; pub use daily_challenge_plugin::{ DailyChallengeCompletedEvent, DailyChallengePlugin, DailyChallengeResource, }; pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate}; +pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin}; pub use animation_plugin::{AnimationPlugin, CardAnim}; pub use card_plugin::{CardEntity, CardLabel, CardPlugin}; pub use events::{ diff --git a/solitaire_engine/src/weekly_goals_plugin.rs b/solitaire_engine/src/weekly_goals_plugin.rs new file mode 100644 index 0000000..142ff69 --- /dev/null +++ b/solitaire_engine/src/weekly_goals_plugin.rs @@ -0,0 +1,211 @@ +//! 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"); + } +}