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 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-24 19:25:18 -07:00
parent 578938a9b2
commit b730902d76
6 changed files with 443 additions and 1 deletions
+6
View File
@@ -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,
};
+74
View File
@@ -60,6 +60,11 @@ pub struct PlayerProgress {
pub daily_challenge_last_completed: Option<NaiveDate>,
pub daily_challenge_streak: u32,
pub weekly_goal_progress: HashMap<String, u32>,
/// 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<String>,
pub unlocked_card_backs: Vec<usize>,
pub unlocked_backgrounds: Vec<usize>,
pub last_modified: DateTime<Utc>,
@@ -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();
+148
View File
@@ -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);
}
}