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:
@@ -1,7 +1,7 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_engine::{
|
use solitaire_engine::{
|
||||||
AchievementPlugin, AnimationPlugin, CardPlugin, DailyChallengePlugin, GamePlugin, InputPlugin,
|
AchievementPlugin, AnimationPlugin, CardPlugin, DailyChallengePlugin, GamePlugin, InputPlugin,
|
||||||
ProgressPlugin, StatsPlugin, TablePlugin,
|
ProgressPlugin, StatsPlugin, TablePlugin, WeeklyGoalsPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -25,5 +25,6 @@ fn main() {
|
|||||||
.add_plugins(ProgressPlugin::default())
|
.add_plugins(ProgressPlugin::default())
|
||||||
.add_plugins(AchievementPlugin::default())
|
.add_plugins(AchievementPlugin::default())
|
||||||
.add_plugins(DailyChallengePlugin)
|
.add_plugins(DailyChallengePlugin)
|
||||||
|
.add_plugins(WeeklyGoalsPlugin)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,3 +51,9 @@ pub use progress::{
|
|||||||
daily_seed_for, level_for_xp, load_progress_from, progress_file_path, save_progress_to,
|
daily_seed_for, level_for_xp, load_progress_from, progress_file_path, save_progress_to,
|
||||||
xp_for_win, PlayerProgress,
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ pub struct PlayerProgress {
|
|||||||
pub daily_challenge_last_completed: Option<NaiveDate>,
|
pub daily_challenge_last_completed: Option<NaiveDate>,
|
||||||
pub daily_challenge_streak: u32,
|
pub daily_challenge_streak: u32,
|
||||||
pub weekly_goal_progress: HashMap<String, 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_card_backs: Vec<usize>,
|
||||||
pub unlocked_backgrounds: Vec<usize>,
|
pub unlocked_backgrounds: Vec<usize>,
|
||||||
pub last_modified: DateTime<Utc>,
|
pub last_modified: DateTime<Utc>,
|
||||||
@@ -73,6 +78,7 @@ impl Default for PlayerProgress {
|
|||||||
daily_challenge_last_completed: None,
|
daily_challenge_last_completed: None,
|
||||||
daily_challenge_streak: 0,
|
daily_challenge_streak: 0,
|
||||||
weekly_goal_progress: HashMap::new(),
|
weekly_goal_progress: HashMap::new(),
|
||||||
|
weekly_goal_week_iso: None,
|
||||||
unlocked_card_backs: vec![0], // back #0 always available
|
unlocked_card_backs: vec![0], // back #0 always available
|
||||||
unlocked_backgrounds: vec![0], // background #0 always available
|
unlocked_backgrounds: vec![0], // background #0 always available
|
||||||
last_modified: DateTime::UNIX_EPOCH,
|
last_modified: DateTime::UNIX_EPOCH,
|
||||||
@@ -96,6 +102,32 @@ impl PlayerProgress {
|
|||||||
self.level > prev_level
|
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`.
|
/// Record a daily-challenge completion for `date`.
|
||||||
///
|
///
|
||||||
/// - First completion ever, or a gap of more than one day: streak resets to 1.
|
/// - 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);
|
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]
|
#[test]
|
||||||
fn same_day_completion_is_idempotent() {
|
fn same_day_completion_is_idempotent() {
|
||||||
let mut p = PlayerProgress::default();
|
let mut p = PlayerProgress::default();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,12 +12,14 @@ pub mod progress_plugin;
|
|||||||
pub mod resources;
|
pub mod resources;
|
||||||
pub mod stats_plugin;
|
pub mod stats_plugin;
|
||||||
pub mod table_plugin;
|
pub mod table_plugin;
|
||||||
|
pub mod weekly_goals_plugin;
|
||||||
|
|
||||||
pub use achievement_plugin::{AchievementPlugin, AchievementsResource};
|
pub use achievement_plugin::{AchievementPlugin, AchievementsResource};
|
||||||
pub use daily_challenge_plugin::{
|
pub use daily_challenge_plugin::{
|
||||||
DailyChallengeCompletedEvent, DailyChallengePlugin, DailyChallengeResource,
|
DailyChallengeCompletedEvent, DailyChallengePlugin, DailyChallengeResource,
|
||||||
};
|
};
|
||||||
pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate};
|
pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate};
|
||||||
|
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
||||||
pub use animation_plugin::{AnimationPlugin, CardAnim};
|
pub use animation_plugin::{AnimationPlugin, CardAnim};
|
||||||
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
||||||
pub use events::{
|
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