diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 9eb9bd3..23b1563 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -1,7 +1,7 @@ use bevy::prelude::*; use solitaire_engine::{ - AchievementPlugin, AnimationPlugin, CardPlugin, GamePlugin, InputPlugin, ProgressPlugin, - StatsPlugin, TablePlugin, + AchievementPlugin, AnimationPlugin, CardPlugin, DailyChallengePlugin, GamePlugin, InputPlugin, + ProgressPlugin, StatsPlugin, TablePlugin, }; fn main() { @@ -22,7 +22,8 @@ fn main() { .add_plugins(InputPlugin) .add_plugins(AnimationPlugin) .add_plugins(StatsPlugin::default()) - .add_plugins(AchievementPlugin::default()) .add_plugins(ProgressPlugin::default()) + .add_plugins(AchievementPlugin::default()) + .add_plugins(DailyChallengePlugin) .run(); } diff --git a/solitaire_core/src/achievement.rs b/solitaire_core/src/achievement.rs index 193add8..9ee95dd 100644 --- a/solitaire_core/src/achievement.rs +++ b/solitaire_core/src/achievement.rs @@ -20,6 +20,10 @@ pub struct AchievementContext { pub lifetime_score: u64, pub draw_three_wins: u32, + // Progression. + /// Current daily-challenge completion streak (consecutive days). + pub daily_challenge_streak: u32, + // Last-win facts (GameWonEvent + GameState at win time). pub last_win_score: i32, pub last_win_time_seconds: u64, @@ -96,6 +100,9 @@ fn early_bird(c: &AchievementContext) -> bool { fn speed_and_skill(c: &AchievementContext) -> bool { c.last_win_time_seconds < 90 && !c.last_win_used_undo } +fn daily_devotee(c: &AchievementContext) -> bool { + c.daily_challenge_streak >= 7 +} /// All currently-evaluable achievements. Order is stable so persistence files /// remain readable across versions (new achievements append). @@ -198,6 +205,13 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ secret: true, condition: speed_and_skill, }, + AchievementDef { + id: "daily_devotee", + name: "Daily Devotee", + description: "Complete the daily challenge 7 days in a row", + secret: false, + condition: daily_devotee, + }, ]; /// Return every `AchievementDef` whose condition is satisfied by `ctx`. @@ -225,6 +239,7 @@ mod tests { best_single_score: 0, lifetime_score: 0, draw_three_wins: 0, + daily_challenge_streak: 0, last_win_score: 0, last_win_time_seconds: u64::MAX, last_win_used_undo: true, @@ -310,6 +325,18 @@ mod tests { assert!(!ids.contains(&"night_owl")); } + #[test] + fn daily_devotee_requires_7_day_streak() { + let mut c = ctx(); + c.daily_challenge_streak = 6; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"daily_devotee")); + + c.daily_challenge_streak = 7; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"daily_devotee")); + } + #[test] fn achievement_by_id_finds_known_and_returns_none_for_unknown() { assert_eq!(achievement_by_id("first_win").map(|d| d.name), Some("First Win")); diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index 938bee5..7956f8d 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -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, }; diff --git a/solitaire_data/src/progress.rs b/solitaire_data/src/progress.rs index 23e7e41..26c1713 100644 --- a/solitaire_data/src/progress.rs +++ b/solitaire_data/src/progress.rs @@ -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); + } } diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs index 722ff31..097fad1 100644 --- a/solitaire_engine/src/achievement_plugin.rs +++ b/solitaire_engine/src/achievement_plugin.rs @@ -18,6 +18,7 @@ use solitaire_data::{ use crate::events::{AchievementUnlockedEvent, GameWonEvent}; use crate::game_plugin::GameMutation; +use crate::progress_plugin::{ProgressResource, ProgressUpdate}; use crate::resources::GameStateResource; use crate::stats_plugin::{StatsResource, StatsUpdate}; @@ -66,20 +67,26 @@ impl Plugin for AchievementPlugin { .insert_resource(AchievementsStoragePath(self.storage_path.clone())) .add_event::() .add_event::() - // Run after GameMutation (so GameWonEvent is available) and after - // StatsUpdate (so StatsResource already reflects this win). + // Run after GameMutation (so GameWonEvent is available), after + // StatsUpdate (so stats reflect this win), and after ProgressUpdate + // (so daily_challenge_streak is up to date for daily_devotee). .add_systems( Update, - evaluate_on_win.after(GameMutation).after(StatsUpdate), + evaluate_on_win + .after(GameMutation) + .after(StatsUpdate) + .after(ProgressUpdate), ); } } +#[allow(clippy::too_many_arguments)] fn evaluate_on_win( mut wins: EventReader, mut unlocks: EventWriter, game: Res, stats: Res, + progress: Res, path: Res, mut achievements: ResMut, ) { @@ -94,6 +101,7 @@ fn evaluate_on_win( best_single_score: stats.0.best_single_score, lifetime_score: stats.0.lifetime_score, draw_three_wins: stats.0.draw_three_wins, + daily_challenge_streak: progress.0.daily_challenge_streak, last_win_score: ev.score, last_win_time_seconds: ev.time_seconds, last_win_used_undo: game.0.undo_count > 0, @@ -149,6 +157,7 @@ mod tests { .add_plugins(GamePlugin) .add_plugins(TablePlugin) .add_plugins(StatsPlugin::headless()) + .add_plugins(crate::progress_plugin::ProgressPlugin::headless()) .add_plugins(AchievementPlugin::headless()); // StatsPlugin's UI toggle system reads ButtonInput; under // MinimalPlugins it isn't auto-registered. diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index a0ff7c5..cfdff29 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -10,12 +10,14 @@ use crate::card_plugin::CardEntity; use crate::events::{AchievementUnlockedEvent, GameWonEvent}; use crate::game_plugin::GameMutation; use crate::layout::LayoutResource; +use crate::progress_plugin::LevelUpEvent; /// Duration of a card slide (move) animation in seconds. pub const SLIDE_SECS: f32 = 0.15; const WIN_TOAST_SECS: f32 = 4.0; const ACHIEVEMENT_TOAST_SECS: f32 = 3.0; +const LEVELUP_TOAST_SECS: f32 = 3.0; const CASCADE_STAGGER: f32 = 0.05; const CASCADE_DURATION: f32 = 0.5; @@ -50,12 +52,14 @@ impl Plugin for AnimationPlugin { // is idempotent in Bevy. app.add_event::() .add_event::() + .add_event::() .add_systems( Update, ( advance_card_anims, handle_win_cascade, handle_achievement_toast, + handle_levelup_toast, tick_toasts, ) .after(GameMutation), @@ -133,6 +137,16 @@ fn handle_achievement_toast( } } +fn handle_levelup_toast(mut commands: Commands, mut events: EventReader) { + for ev in events.read() { + spawn_toast( + &mut commands, + format!("Level Up! → {}", ev.new_level), + LEVELUP_TOAST_SECS, + ); + } +} + fn tick_toasts( mut commands: Commands, time: Res