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:
@@ -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::{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user