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:
funman300
2026-04-24 19:17:59 -07:00
parent 0cb8b32ec4
commit 622b35a3bf
8 changed files with 376 additions and 9 deletions
+2 -2
View File
@@ -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,
};
+90 -1
View File
@@ -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);
}
}