Files
Ferrous-Solitaire/solitaire_sync/src/progress.rs
T
funman300 1a1047664b feat(engine): 14-day daily-challenge calendar in the Profile modal
The daily challenge already updated streak counters, but past
completions were invisible — the player had no in-game surface to
see streak length or the actual day-by-day record. Adds a 14-dot
horizontal calendar above the Profile modal's achievements section
with a "Current streak: N · Longest: M" caption.

Each dot represents a day in the trailing 14-day window ending
today. Today's dot gets a 2-px Balatro-yellow ring; completed days
fill STATE_SUCCESS; missed days fill BG_ELEVATED. Geometry: 14 ×
12 px + 13 × 6 px gap ≈ 246 px — fits comfortably inside the
modal's 360 px min_width even on the 800 px window minimum.

PlayerProgress gains two #[serde(default)] fields:
- daily_challenge_history: Vec<NaiveDate> capped at 365 entries
  (one year of history; older entries pushed off when the cap is
  hit). Sorted ascending, deduped on insert so same-day re-runs
  don't bloat the list.
- daily_challenge_longest_streak: u32, updated whenever streak
  exceeds the previous max.

Legacy progress.json files load to empty/0 via #[serde(default)].

solitaire_sync::merge unions histories from local + remote (sorted,
capped) and takes max(longest_streak), with a clamp to ensure
longest is never below the merged current streak — guards against
legacy payloads where longest=0 but current is mid-streak.

13 new tests across solitaire_sync (record_daily history append,
chronological order, dedupe, cap, longest update, merge union,
merge cap, max longest, clamp), solitaire_data (history append,
longest update, legacy deserialise), and solitaire_engine
(modal renders 14 dots, today marker on rightmost only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:05:54 +00:00

441 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Shared `PlayerProgress` definition — used by both the game client and the
//! sync server.
use std::collections::HashMap;
use chrono::{DateTime, Duration, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
/// XP-to-level calculation per ARCHITECTURE.md §13.
///
/// - Levels 110: `level = floor(total_xp / 500)`
/// - Levels 11+: `level = 10 + floor((total_xp - 5_000) / 1_000)`
pub fn level_for_xp(xp: u64) -> u32 {
if xp < 5_000 {
(xp / 500) as u32
} else {
10 + ((xp - 5_000) / 1_000) as u32
}
}
/// Maximum number of dates retained in [`PlayerProgress::daily_challenge_history`].
///
/// Bounds the per-player file size across years of play. ~365 entries is
/// roughly a year of daily completions, far more than the 14-day window the
/// in-game calendar surfaces.
pub const DAILY_CHALLENGE_HISTORY_CAP: usize = 365;
/// Persisted player progression state.
///
/// Mutation helpers such as `add_xp`, `record_daily_completion`, etc. are
/// defined as inherent methods directly on this type.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerProgress {
/// Total XP accumulated across all games.
pub total_xp: u64,
/// Current player level, recomputed from `total_xp`.
pub level: u32,
/// Date of the last completed daily challenge, if any.
pub daily_challenge_last_completed: Option<NaiveDate>,
/// Current daily-challenge streak length.
pub daily_challenge_streak: u32,
/// Per-goal progress counters for the current ISO week.
pub weekly_goal_progress: HashMap<String, u32>,
/// ISO week key (e.g. `"2026-W17"`) the `weekly_goal_progress` counters
/// belong to. Cleared when a new week begins.
#[serde(default)]
pub weekly_goal_week_iso: Option<String>,
/// Indices of card-back designs the player has unlocked (index 0 is always unlocked).
pub unlocked_card_backs: Vec<usize>,
/// Indices of background designs the player has unlocked (index 0 is always unlocked).
pub unlocked_backgrounds: Vec<usize>,
/// Index of the next Challenge-mode seed to serve to this player.
#[serde(default)]
pub challenge_index: u32,
/// All dates the player has completed the daily challenge, in
/// chronological ascending order. Bounded to the most recent 365
/// entries so file size stays bounded across years of play.
#[serde(default)]
pub daily_challenge_history: Vec<NaiveDate>,
/// Longest daily-challenge streak ever achieved on this profile.
#[serde(default)]
pub daily_challenge_longest_streak: u32,
/// Wall-clock time of the last modification (used for conflict detection).
pub last_modified: DateTime<Utc>,
}
impl Default for PlayerProgress {
fn default() -> Self {
Self {
total_xp: 0,
level: 0,
daily_challenge_last_completed: None,
daily_challenge_streak: 0,
weekly_goal_progress: HashMap::new(),
weekly_goal_week_iso: None,
unlocked_card_backs: vec![0],
unlocked_backgrounds: vec![0],
challenge_index: 0,
daily_challenge_history: Vec::new(),
daily_challenge_longest_streak: 0,
last_modified: DateTime::UNIX_EPOCH,
}
}
}
impl PlayerProgress {
/// Add XP and recompute level. Returns the previous level so callers can
/// detect level-up events.
pub fn add_xp(&mut self, amount: u64) -> u32 {
let prev_level = self.level;
self.total_xp = self.total_xp.saturating_add(amount);
self.level = level_for_xp(self.total_xp);
self.last_modified = Utc::now();
prev_level
}
/// `true` if a level-up just occurred (current level > `prev_level`).
pub fn leveled_up_from(&self, prev_level: u32) -> bool {
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 {
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.
/// - Completion the day after the previous: streak increments.
/// - Same day as the previous: no-op (idempotent).
///
/// On every fresh completion, `date` is appended to
/// `daily_challenge_history` (kept sorted ascending and capped at
/// [`DAILY_CHALLENGE_HISTORY_CAP`] entries) and
/// `daily_challenge_longest_streak` is bumped if the current streak
/// exceeds it.
///
/// Returns `true` if this call recorded a fresh completion.
pub fn record_daily_completion(&mut self, date: NaiveDate) -> bool {
match self.daily_challenge_last_completed {
Some(last) if last == date => return false,
Some(last) if last + Duration::days(1) == date => {
self.daily_challenge_streak = self.daily_challenge_streak.saturating_add(1);
}
_ => {
self.daily_challenge_streak = 1;
}
}
self.daily_challenge_last_completed = Some(date);
// Append to history (defensive against duplicates and out-of-order
// dates so a hand-edited or merged file can't corrupt the order).
if !self.daily_challenge_history.contains(&date) {
self.daily_challenge_history.push(date);
self.daily_challenge_history.sort();
if self.daily_challenge_history.len() > DAILY_CHALLENGE_HISTORY_CAP {
let excess = self.daily_challenge_history.len() - DAILY_CHALLENGE_HISTORY_CAP;
self.daily_challenge_history.drain(0..excess);
}
}
if self.daily_challenge_streak > self.daily_challenge_longest_streak {
self.daily_challenge_longest_streak = self.daily_challenge_streak;
}
self.last_modified = Utc::now();
true
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
// -----------------------------------------------------------------------
// level_for_xp
// -----------------------------------------------------------------------
#[test]
fn level_zero_at_zero_xp() {
assert_eq!(level_for_xp(0), 0);
}
#[test]
fn level_one_at_500_xp() {
assert_eq!(level_for_xp(500), 1);
}
#[test]
fn level_nine_at_4500_xp() {
assert_eq!(level_for_xp(4_500), 9);
}
#[test]
fn level_ten_at_5000_xp() {
assert_eq!(level_for_xp(5_000), 10);
}
#[test]
fn level_eleven_at_6000_xp() {
assert_eq!(level_for_xp(6_000), 11);
}
#[test]
fn level_scales_correctly_above_ten() {
// Level 10 + floor((7000 - 5000) / 1000) = 10 + 2 = 12
assert_eq!(level_for_xp(7_000), 12);
}
// -----------------------------------------------------------------------
// add_xp
// -----------------------------------------------------------------------
#[test]
fn add_xp_increases_total_xp() {
let mut p = PlayerProgress::default();
p.add_xp(300);
assert_eq!(p.total_xp, 300);
}
#[test]
fn add_xp_returns_previous_level() {
let mut p = PlayerProgress::default();
p.add_xp(400); // still level 0
let prev = p.add_xp(200); // crosses into level 1
assert_eq!(prev, 0, "returned level should be the pre-call level");
assert_eq!(p.level, 1);
}
#[test]
fn add_xp_saturates_on_overflow() {
let mut p = PlayerProgress { total_xp: u64::MAX, ..Default::default() };
p.add_xp(1);
assert_eq!(p.total_xp, u64::MAX);
}
// -----------------------------------------------------------------------
// leveled_up_from
// -----------------------------------------------------------------------
#[test]
fn leveled_up_from_returns_true_when_level_increased() {
let mut p = PlayerProgress::default();
p.add_xp(600); // reaches level 1
assert!(p.leveled_up_from(0));
}
#[test]
fn leveled_up_from_returns_false_when_same_level() {
let p = PlayerProgress::default();
assert!(!p.leveled_up_from(0));
}
// -----------------------------------------------------------------------
// roll_weekly_goals_if_new_week
// -----------------------------------------------------------------------
#[test]
fn roll_weekly_goals_clears_progress_for_new_week() {
let mut p = PlayerProgress { weekly_goal_week_iso: Some("2026-W16".to_string()), ..Default::default() };
p.weekly_goal_progress.insert("weekly_5_wins".to_string(), 3);
let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
assert!(rolled);
assert!(p.weekly_goal_progress.is_empty());
assert_eq!(p.weekly_goal_week_iso, Some("2026-W17".to_string()));
}
#[test]
fn roll_weekly_goals_is_noop_for_same_week() {
let mut p = PlayerProgress { weekly_goal_week_iso: Some("2026-W17".to_string()), ..Default::default() };
p.weekly_goal_progress.insert("weekly_5_wins".to_string(), 2);
let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
assert!(!rolled);
assert_eq!(p.weekly_goal_progress.get("weekly_5_wins"), Some(&2));
}
// -----------------------------------------------------------------------
// record_weekly_progress
// -----------------------------------------------------------------------
#[test]
fn record_weekly_progress_increments_counter() {
let mut p = PlayerProgress::default();
p.roll_weekly_goals_if_new_week("2026-W17");
let done = p.record_weekly_progress("weekly_5_wins", 5);
assert!(!done, "1/5 should not be done");
assert_eq!(p.weekly_goal_progress.get("weekly_5_wins"), Some(&1));
}
#[test]
fn record_weekly_progress_returns_true_on_completion() {
let mut p = PlayerProgress::default();
p.roll_weekly_goals_if_new_week("2026-W17");
for _ in 0..4 {
p.record_weekly_progress("weekly_5_wins", 5);
}
let done = p.record_weekly_progress("weekly_5_wins", 5);
assert!(done, "5th increment should complete the goal");
}
#[test]
fn record_weekly_progress_does_not_exceed_target() {
let mut p = PlayerProgress::default();
p.roll_weekly_goals_if_new_week("2026-W17");
for _ in 0..10 {
p.record_weekly_progress("weekly_5_wins", 5);
}
// Counter must be capped at target — never go above.
assert_eq!(p.weekly_goal_progress.get("weekly_5_wins"), Some(&5));
}
// -----------------------------------------------------------------------
// record_daily_completion
// -----------------------------------------------------------------------
#[test]
fn record_daily_completion_starts_streak_at_one() {
let mut p = PlayerProgress::default();
let recorded = p.record_daily_completion(date(2026, 4, 20));
assert!(recorded);
assert_eq!(p.daily_challenge_streak, 1);
assert_eq!(p.daily_challenge_last_completed, Some(date(2026, 4, 20)));
}
#[test]
fn record_daily_completion_same_day_is_noop() {
let mut p = PlayerProgress::default();
p.record_daily_completion(date(2026, 4, 20));
let recorded = p.record_daily_completion(date(2026, 4, 20));
assert!(!recorded);
assert_eq!(p.daily_challenge_streak, 1, "streak must not double-count same day");
}
#[test]
fn record_daily_completion_consecutive_days_extend_streak() {
let mut p = PlayerProgress::default();
p.record_daily_completion(date(2026, 4, 20));
p.record_daily_completion(date(2026, 4, 21));
assert_eq!(p.daily_challenge_streak, 2);
}
#[test]
fn record_daily_completion_gap_resets_streak_to_one() {
let mut p = PlayerProgress::default();
p.record_daily_completion(date(2026, 4, 20));
p.record_daily_completion(date(2026, 4, 22)); // skip the 21st
assert_eq!(p.daily_challenge_streak, 1, "gap must reset streak");
}
// -----------------------------------------------------------------------
// record_daily_completion — history + longest-streak side effects
// -----------------------------------------------------------------------
#[test]
fn record_daily_completion_appends_to_history_in_chronological_order() {
let mut p = PlayerProgress::default();
assert!(p.daily_challenge_history.is_empty());
p.record_daily_completion(date(2026, 4, 20));
p.record_daily_completion(date(2026, 4, 21));
p.record_daily_completion(date(2026, 4, 22));
assert_eq!(
p.daily_challenge_history,
vec![
date(2026, 4, 20),
date(2026, 4, 21),
date(2026, 4, 22),
],
"history should hold all three completions in ascending order"
);
}
#[test]
fn record_daily_completion_same_day_does_not_duplicate_history() {
let mut p = PlayerProgress::default();
p.record_daily_completion(date(2026, 4, 20));
p.record_daily_completion(date(2026, 4, 20));
assert_eq!(
p.daily_challenge_history,
vec![date(2026, 4, 20)],
"same-day completion is a no-op and must not duplicate history"
);
}
#[test]
fn record_daily_completion_updates_longest_streak() {
let mut p = PlayerProgress::default();
// Three-day streak: longest jumps from 0 → 3.
p.record_daily_completion(date(2026, 4, 20));
p.record_daily_completion(date(2026, 4, 21));
p.record_daily_completion(date(2026, 4, 22));
assert_eq!(p.daily_challenge_streak, 3);
assert_eq!(p.daily_challenge_longest_streak, 3);
// Gap resets the current streak — longest must NOT regress.
p.record_daily_completion(date(2026, 4, 25));
assert_eq!(p.daily_challenge_streak, 1);
assert_eq!(
p.daily_challenge_longest_streak, 3,
"longest_streak must never regress after a gap"
);
// Two-day streak — still below longest, so longest stays at 3.
p.record_daily_completion(date(2026, 4, 26));
assert_eq!(p.daily_challenge_streak, 2);
assert_eq!(p.daily_challenge_longest_streak, 3);
}
#[test]
fn daily_challenge_history_is_capped_at_max() {
// Push DAILY_CHALLENGE_HISTORY_CAP + 5 consecutive days; the
// earliest five must be evicted and the most recent CAP retained.
let mut p = PlayerProgress::default();
let start = date(2024, 1, 1);
let total = DAILY_CHALLENGE_HISTORY_CAP + 5;
for offset in 0..total {
p.record_daily_completion(start + Duration::days(offset as i64));
}
assert_eq!(p.daily_challenge_history.len(), DAILY_CHALLENGE_HISTORY_CAP);
// Oldest retained is `start + 5` (we dropped the first 5).
assert_eq!(
p.daily_challenge_history.first().copied(),
Some(start + Duration::days(5))
);
// Newest retained is the last date pushed.
assert_eq!(
p.daily_challenge_history.last().copied(),
Some(start + Duration::days(total as i64 - 1))
);
}
}