diff --git a/solitaire_data/src/weekly.rs b/solitaire_data/src/weekly.rs index 30aec0b..3ee1f36 100644 --- a/solitaire_data/src/weekly.rs +++ b/solitaire_data/src/weekly.rs @@ -4,6 +4,7 @@ //! increments matching counters in `PlayerProgress::weekly_goal_progress`. use chrono::{Datelike, NaiveDate}; +use solitaire_core::game_state::DrawMode; /// XP awarded each time a weekly goal is just completed. pub const WEEKLY_GOAL_XP: u64 = 75; @@ -17,6 +18,8 @@ pub enum WeeklyGoalKind { WinWithoutUndo, /// A win in strictly fewer than `seconds` seconds counts. WinUnder { seconds: u64 }, + /// A win in Draw-3 mode counts. + WinDrawThree, } /// Static metadata for a single weekly goal. @@ -29,10 +32,11 @@ pub struct WeeklyGoalDef { } /// Per-event facts a goal needs to decide whether it matched. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub struct WeeklyGoalContext { pub time_seconds: u64, pub used_undo: bool, + pub draw_mode: DrawMode, } impl WeeklyGoalDef { @@ -43,6 +47,7 @@ impl WeeklyGoalDef { WeeklyGoalKind::WinGame => true, WeeklyGoalKind::WinWithoutUndo => !ctx.used_undo, WeeklyGoalKind::WinUnder { seconds } => ctx.time_seconds < seconds, + WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawMode::DrawThree, } } } @@ -67,6 +72,18 @@ pub const WEEKLY_GOALS: &[WeeklyGoalDef] = &[ target: 3, kind: WeeklyGoalKind::WinUnder { seconds: 180 }, }, + WeeklyGoalDef { + id: "weekly_1_under_five", + description: "Win 1 game in under 5 minutes this week", + target: 1, + kind: WeeklyGoalKind::WinUnder { seconds: 300 }, + }, + WeeklyGoalDef { + id: "weekly_draw_three", + description: "Win 1 Draw-3 game this week", + target: 1, + kind: WeeklyGoalKind::WinDrawThree, + }, ]; /// Stable identifier for the ISO week containing `date`, e.g. `"2026-W17"`. @@ -89,6 +106,15 @@ mod tests { WeeklyGoalContext { time_seconds: time, used_undo: undo, + draw_mode: DrawMode::DrawOne, + } + } + + fn ctx_d3(time: u64) -> WeeklyGoalContext { + WeeklyGoalContext { + time_seconds: time, + used_undo: false, + draw_mode: DrawMode::DrawThree, } } @@ -145,4 +171,20 @@ mod tests { assert!(key.starts_with("2026-W")); assert_eq!(key.len(), 8); } + + #[test] + fn under_five_matches_wins_below_300_seconds() { + let g = weekly_goal_by_id("weekly_1_under_five").unwrap(); + assert!(g.matches(&ctx(0, false))); + assert!(g.matches(&ctx(299, true))); + assert!(!g.matches(&ctx(300, false))); + assert!(!g.matches(&ctx(999, false))); + } + + #[test] + fn draw_three_goal_matches_only_draw_three_wins() { + let g = weekly_goal_by_id("weekly_draw_three").unwrap(); + assert!(g.matches(&ctx_d3(600))); + assert!(!g.matches(&ctx(600, false))); + } } diff --git a/solitaire_engine/src/weekly_goals_plugin.rs b/solitaire_engine/src/weekly_goals_plugin.rs index 2cc8a78..e90e4c9 100644 --- a/solitaire_engine/src/weekly_goals_plugin.rs +++ b/solitaire_engine/src/weekly_goals_plugin.rs @@ -80,6 +80,7 @@ fn evaluate_weekly_goals( let ctx = WeeklyGoalContext { time_seconds: ev.time_seconds, used_undo: game.0.undo_count > 0, + draw_mode: game.0.draw_mode.clone(), }; for def in WEEKLY_GOALS { if !def.matches(&ctx) { @@ -191,11 +192,16 @@ mod tests { 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); + // Also pre-complete weekly_1_under_five (target=1) and weekly_5_wins / + // weekly_3_no_undo at target so a 60-second win only completes weekly_3_fast, + // keeping the XP delta predictable. + { + let mut p = app.world_mut().resource_mut::(); + p.0.weekly_goal_progress.insert("weekly_3_fast".to_string(), 2); + p.0.weekly_goal_progress.insert("weekly_1_under_five".to_string(), 1); + p.0.weekly_goal_progress.insert("weekly_5_wins".to_string(), 5); + p.0.weekly_goal_progress.insert("weekly_3_no_undo".to_string(), 3); + } // 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()