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