feat(engine): add daily challenge, level-up toast, and daily_devotee achievement
Phase 6 part 2 (partial): - daily_seed_for(date) and PlayerProgress::record_daily_completion in solitaire_data, with streak logic that increments on consecutive days, resets on a skipped day, and is idempotent on same-day re-completions. - DailyChallengePlugin tracks today's seed, awards +100 XP and updates the streak when the player wins a game whose seed matches. Pressing C starts a new game with the daily seed. - LevelUpEvent toast in AnimationPlugin announces level changes. - AchievementContext gains daily_challenge_streak; daily_devotee achievement unlocks at streak >= 7. AchievementPlugin reads ProgressResource and runs after ProgressUpdate. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,6 @@ pub use achievements::{
|
||||
|
||||
pub mod progress;
|
||||
pub use progress::{
|
||||
level_for_xp, load_progress_from, progress_file_path, save_progress_to, xp_for_win,
|
||||
PlayerProgress,
|
||||
daily_seed_for, level_for_xp, load_progress_from, progress_file_path, save_progress_to,
|
||||
xp_for_win, PlayerProgress,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
@@ -25,6 +25,15 @@ pub fn level_for_xp(xp: u64) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic seed derived from a date, identical for all players globally.
|
||||
/// Used as the RNG seed for the daily-challenge deal.
|
||||
pub fn daily_seed_for(date: NaiveDate) -> u64 {
|
||||
let y = date.year() as u64;
|
||||
let m = date.month() as u64;
|
||||
let d = date.day() as u64;
|
||||
y * 10_000 + m * 100 + d
|
||||
}
|
||||
|
||||
/// XP awarded for winning a game.
|
||||
///
|
||||
/// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if
|
||||
@@ -86,6 +95,29 @@ impl PlayerProgress {
|
||||
pub fn leveled_up_from(&self, prev_level: u32) -> bool {
|
||||
self.level > prev_level
|
||||
}
|
||||
|
||||
/// 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 — a player can't double-count).
|
||||
///
|
||||
/// Returns `true` if this call recorded a fresh completion (i.e. it wasn't
|
||||
/// the same-day no-op case).
|
||||
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);
|
||||
self.last_modified = Utc::now();
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform-specific default path for `progress.json`.
|
||||
@@ -243,4 +275,61 @@ mod tests {
|
||||
save_progress_to(&path, &PlayerProgress::default()).expect("save");
|
||||
assert!(!path.with_extension("json.tmp").exists());
|
||||
}
|
||||
|
||||
// --- Daily challenge ---
|
||||
|
||||
#[test]
|
||||
fn daily_seed_is_deterministic_per_date() {
|
||||
let d = NaiveDate::from_ymd_opt(2026, 4, 24).unwrap();
|
||||
assert_eq!(daily_seed_for(d), daily_seed_for(d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_seed_differs_across_dates() {
|
||||
let a = NaiveDate::from_ymd_opt(2026, 4, 24).unwrap();
|
||||
let b = NaiveDate::from_ymd_opt(2026, 4, 25).unwrap();
|
||||
assert_ne!(daily_seed_for(a), daily_seed_for(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_daily_completion_starts_streak_at_1() {
|
||||
let mut p = PlayerProgress::default();
|
||||
let d = NaiveDate::from_ymd_opt(2026, 4, 24).unwrap();
|
||||
let recorded = p.record_daily_completion(d);
|
||||
assert!(recorded);
|
||||
assert_eq!(p.daily_challenge_streak, 1);
|
||||
assert_eq!(p.daily_challenge_last_completed, Some(d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_days_increment_streak() {
|
||||
let mut p = PlayerProgress::default();
|
||||
let d1 = NaiveDate::from_ymd_opt(2026, 4, 24).unwrap();
|
||||
let d2 = d1 + Duration::days(1);
|
||||
let d3 = d2 + Duration::days(1);
|
||||
p.record_daily_completion(d1);
|
||||
p.record_daily_completion(d2);
|
||||
p.record_daily_completion(d3);
|
||||
assert_eq!(p.daily_challenge_streak, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_day_resets_streak_to_1() {
|
||||
let mut p = PlayerProgress::default();
|
||||
let d1 = NaiveDate::from_ymd_opt(2026, 4, 24).unwrap();
|
||||
let d3 = d1 + Duration::days(2); // skipped d2
|
||||
p.record_daily_completion(d1);
|
||||
p.record_daily_completion(d3);
|
||||
assert_eq!(p.daily_challenge_streak, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_day_completion_is_idempotent() {
|
||||
let mut p = PlayerProgress::default();
|
||||
let d = NaiveDate::from_ymd_opt(2026, 4, 24).unwrap();
|
||||
p.record_daily_completion(d);
|
||||
let recorded_again = p.record_daily_completion(d);
|
||||
assert!(!recorded_again, "same-day completion must report no-op");
|
||||
assert_eq!(p.daily_challenge_streak, 1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user