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>
This commit is contained in:
funman300
2026-05-05 01:05:54 +00:00
parent ba527de351
commit 1a1047664b
4 changed files with 491 additions and 4 deletions
+140 -2
View File
@@ -3,10 +3,10 @@
//! All functions are free of I/O and side effects — safe to call from any
//! context including unit tests and the Bevy main thread.
use chrono::Utc;
use chrono::{NaiveDate, Utc};
use crate::{AchievementRecord, ConflictReport, PlayerProgress, StatsSnapshot, SyncPayload};
use crate::progress::level_for_xp;
use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP};
/// Merge two [`SyncPayload`]s into a single authoritative result.
///
@@ -240,6 +240,22 @@ fn merge_progress(
// Challenge index: take the higher (further ahead in challenge progression).
let challenge_index = local.challenge_index.max(remote.challenge_index);
// Daily-challenge history: union the two ordered lists into a sorted,
// deduplicated, capped Vec so completions made on either device survive.
let daily_challenge_history = union_naive_dates(
&local.daily_challenge_history,
&remote.daily_challenge_history,
);
// Longest streak ever: simple max — never regresses.
let daily_challenge_longest_streak = local
.daily_challenge_longest_streak
.max(remote.daily_challenge_longest_streak)
// Also defend against an old payload whose `longest_streak` was
// never written but whose current `daily_challenge_streak` exceeds
// the recorded longest — keep them coherent post-merge.
.max(daily_challenge_streak);
PlayerProgress {
total_xp,
level: level_for_xp(total_xp),
@@ -250,6 +266,8 @@ fn merge_progress(
unlocked_card_backs,
unlocked_backgrounds,
challenge_index,
daily_challenge_history,
daily_challenge_longest_streak,
last_modified: Utc::now(),
}
}
@@ -261,6 +279,20 @@ fn union_usize_vecs(a: &[usize], b: &[usize]) -> Vec<usize> {
set.into_iter().collect()
}
/// Returns the sorted union of two `NaiveDate` slices with duplicates
/// removed and the result capped at [`DAILY_CHALLENGE_HISTORY_CAP`]
/// entries (oldest dates trimmed first).
fn union_naive_dates(a: &[NaiveDate], b: &[NaiveDate]) -> Vec<NaiveDate> {
use std::collections::BTreeSet;
let set: BTreeSet<NaiveDate> = a.iter().chain(b.iter()).copied().collect();
let mut v: Vec<NaiveDate> = set.into_iter().collect();
if v.len() > DAILY_CHALLENGE_HISTORY_CAP {
let excess = v.len() - DAILY_CHALLENGE_HISTORY_CAP;
v.drain(0..excess);
}
v
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -753,4 +785,110 @@ mod tests {
let (merged, _) = merge(&local, &remote);
assert_eq!(merged.stats.fastest_win_seconds, 300);
}
// -----------------------------------------------------------------------
// Daily-challenge history + longest-streak merge
// -----------------------------------------------------------------------
fn nd(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
#[test]
fn merge_unions_daily_challenge_history() {
// Local and remote have disjoint completion dates; the merged
// history must contain all of them, sorted ascending, with no
// duplicates and within the cap.
let mut local = default_payload();
local.progress.daily_challenge_history =
vec![nd(2026, 4, 20), nd(2026, 4, 22), nd(2026, 4, 24)];
let mut remote = default_payload();
remote.progress.daily_challenge_history =
vec![nd(2026, 4, 21), nd(2026, 4, 22), nd(2026, 4, 25)];
let (merged, _) = merge(&local, &remote);
assert_eq!(
merged.progress.daily_challenge_history,
vec![
nd(2026, 4, 20),
nd(2026, 4, 21),
nd(2026, 4, 22),
nd(2026, 4, 24),
nd(2026, 4, 25),
],
"history union must be sorted, deduplicated, and contain every date from either side"
);
assert!(
merged.progress.daily_challenge_history.len() <= DAILY_CHALLENGE_HISTORY_CAP,
"merged history must respect the 365-entry cap"
);
}
#[test]
fn merge_caps_daily_challenge_history_at_max() {
// Construct a local history that already has CAP entries and a
// remote history that adds 50 fresher entries — the merge must
// drop the oldest 50 so the cap is preserved.
let start = nd(2024, 1, 1);
let local_dates: Vec<NaiveDate> = (0..DAILY_CHALLENGE_HISTORY_CAP as i64)
.map(|i| start + chrono::Duration::days(i))
.collect();
let remote_dates: Vec<NaiveDate> = (DAILY_CHALLENGE_HISTORY_CAP as i64
..DAILY_CHALLENGE_HISTORY_CAP as i64 + 50)
.map(|i| start + chrono::Duration::days(i))
.collect();
let mut local = default_payload();
local.progress.daily_challenge_history = local_dates.clone();
let mut remote = default_payload();
remote.progress.daily_challenge_history = remote_dates.clone();
let (merged, _) = merge(&local, &remote);
assert_eq!(
merged.progress.daily_challenge_history.len(),
DAILY_CHALLENGE_HISTORY_CAP,
"merged history must be capped at DAILY_CHALLENGE_HISTORY_CAP"
);
// The oldest 50 entries should have been evicted; oldest retained
// is therefore start + 50 days.
assert_eq!(
merged.progress.daily_challenge_history.first().copied(),
Some(start + chrono::Duration::days(50))
);
// Most recent retained is the last remote date.
assert_eq!(
merged.progress.daily_challenge_history.last().copied(),
remote_dates.last().copied()
);
}
#[test]
fn merge_takes_max_longest_streak() {
let mut local = default_payload();
local.progress.daily_challenge_longest_streak = 4;
let mut remote = default_payload();
remote.progress.daily_challenge_longest_streak = 9;
let (merged, _) = merge(&local, &remote);
assert_eq!(
merged.progress.daily_challenge_longest_streak, 9,
"longest streak must be the max across both sides"
);
}
#[test]
fn merge_longest_streak_never_below_current_streak() {
// If a payload's `daily_challenge_longest_streak` was never written
// (legacy file) but its `daily_challenge_streak` is non-zero, the
// merged longest must reflect at least the current streak so the
// two values stay coherent.
let mut local = default_payload();
local.progress.daily_challenge_streak = 7;
local.progress.daily_challenge_longest_streak = 0; // legacy
let remote = default_payload();
let (merged, _) = merge(&local, &remote);
assert!(
merged.progress.daily_challenge_longest_streak >= 7,
"longest streak must be at least as large as the merged current streak"
);
}
}
+117
View File
@@ -18,6 +18,13 @@ pub fn level_for_xp(xp: u64) -> 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
@@ -45,6 +52,14 @@ pub struct PlayerProgress {
/// 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>,
}
@@ -61,6 +76,8 @@ impl Default for PlayerProgress {
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,
}
}
@@ -114,6 +131,12 @@ impl PlayerProgress {
/// - 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 {
@@ -126,6 +149,19 @@ impl PlayerProgress {
}
}
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
}
@@ -320,4 +356,85 @@ mod tests {
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))
);
}
}