Files
Ferrous-Solitaire/solitaire_data/src/weekly.rs
T
funman300 ffc79447d4 fix+refactor+docs: P0–P3 todo list items
P0 fixes:
- Register WinSummaryPlugin, SelectionPlugin, CardAnimationPlugin in main.rs
  (all three were exported but never wired — features silently did nothing)
- game_state::draw(): increment move_count on waste→stock recycle, not just
  on normal draws; add move_count_increments_on_recycle regression test

P1 fixes:
- solitaire_server/Cargo.toml: remove duplicate dev-dependencies
  (solitaire_sync, uuid, chrono, jsonwebtoken were in both sections)

P2 — input_plugin refactor:
- Split 198-line handle_keyboard() into three focused systems under 110 lines each:
  handle_keyboard_core (U/N/Z/D/Space), handle_keyboard_hint (H), handle_keyboard_forfeit (G)
- Introduce KeyboardConfirmState resource to share countdown timers across systems
- Add three new unit tests: all_hints_suggests_draw_*, all_hints_is_empty_when_truly_stuck,
  new_game_confirm_window_is_positive

P2 — achievement predicate tests (solitaire_core):
- Add 10 direct unit tests for speed_demon, lightning, no_undo, high_scorer,
  on_a_roll, comeback predicates (previously only covered via check_achievements())
- 141 core tests now passing

P2 — server tests:
- solitaire_server/src/sync.rs: 4 unit tests for merge logic (no DB required)
- solitaire_server/src/leaderboard.rs: 2 unit tests for entry shape and sort order

P3 — documentation:
- Add struct-level ///  to 12 Plugin structs (ChallengePlugin, CursorPlugin,
  AnimationPlugin, HelpPlugin, PausePlugin, AudioPlugin, DailyChallengePlugin,
  HudPlugin, LeaderboardPlugin, OnboardingPlugin, TimeAttackPlugin, WeeklyGoalsPlugin)
- Add field-level /// to Card, Pile, Deck, GameState, AchievementContext, AchievementDef
- Add /// to WeeklyGoalKind, WeeklyGoalDef, WeeklyGoalContext, StatsExt::update_on_win

card_animation module (new files from previous session):
- chain.rs, diagnostics.rs, tuning.rs, updated interaction.rs/animation.rs/mod.rs/lib.rs
- Remove unused HOVER_SCALE_DEFAULT / DRAG_LIFT_SCALE_DEFAULT / HOVER_LERP_SPEED_DEFAULT constants
- Add handle_touch_stock_tap so touch users can draw from the stock pile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 22:02:52 +00:00

191 lines
5.9 KiB
Rust

//! 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};
use solitaire_core::game_state::DrawMode;
/// XP awarded each time a weekly goal is just completed.
pub const WEEKLY_GOAL_XP: u64 = 75;
/// Discriminant for the type of weekly goal the player is working toward.
#[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 },
/// A win in Draw-3 mode counts.
WinDrawThree,
}
/// Static definition of a weekly goal — the goal type, target value, and display strings.
#[derive(Debug, Clone, Copy)]
pub struct WeeklyGoalDef {
pub id: &'static str,
pub description: &'static str,
pub target: u32,
pub kind: WeeklyGoalKind,
}
/// Runtime snapshot of game metrics used to evaluate weekly goal progress.
#[derive(Debug, Clone)]
pub struct WeeklyGoalContext {
pub time_seconds: u64,
pub used_undo: bool,
pub draw_mode: DrawMode,
}
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,
WeeklyGoalKind::WinDrawThree => ctx.draw_mode == DrawMode::DrawThree,
}
}
}
/// 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 },
},
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"`.
/// 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,
draw_mode: DrawMode::DrawOne,
}
}
fn ctx_d3(time: u64) -> WeeklyGoalContext {
WeeklyGoalContext {
time_seconds: time,
used_undo: false,
draw_mode: DrawMode::DrawThree,
}
}
#[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);
}
#[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)));
}
}