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