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:
@@ -1,7 +1,7 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_engine::{
|
use solitaire_engine::{
|
||||||
AchievementPlugin, AnimationPlugin, CardPlugin, GamePlugin, InputPlugin, ProgressPlugin,
|
AchievementPlugin, AnimationPlugin, CardPlugin, DailyChallengePlugin, GamePlugin, InputPlugin,
|
||||||
StatsPlugin, TablePlugin,
|
ProgressPlugin, StatsPlugin, TablePlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -22,7 +22,8 @@ fn main() {
|
|||||||
.add_plugins(InputPlugin)
|
.add_plugins(InputPlugin)
|
||||||
.add_plugins(AnimationPlugin)
|
.add_plugins(AnimationPlugin)
|
||||||
.add_plugins(StatsPlugin::default())
|
.add_plugins(StatsPlugin::default())
|
||||||
.add_plugins(AchievementPlugin::default())
|
|
||||||
.add_plugins(ProgressPlugin::default())
|
.add_plugins(ProgressPlugin::default())
|
||||||
|
.add_plugins(AchievementPlugin::default())
|
||||||
|
.add_plugins(DailyChallengePlugin)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ pub struct AchievementContext {
|
|||||||
pub lifetime_score: u64,
|
pub lifetime_score: u64,
|
||||||
pub draw_three_wins: u32,
|
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).
|
// Last-win facts (GameWonEvent + GameState at win time).
|
||||||
pub last_win_score: i32,
|
pub last_win_score: i32,
|
||||||
pub last_win_time_seconds: u64,
|
pub last_win_time_seconds: u64,
|
||||||
@@ -96,6 +100,9 @@ fn early_bird(c: &AchievementContext) -> bool {
|
|||||||
fn speed_and_skill(c: &AchievementContext) -> bool {
|
fn speed_and_skill(c: &AchievementContext) -> bool {
|
||||||
c.last_win_time_seconds < 90 && !c.last_win_used_undo
|
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
|
/// All currently-evaluable achievements. Order is stable so persistence files
|
||||||
/// remain readable across versions (new achievements append).
|
/// remain readable across versions (new achievements append).
|
||||||
@@ -198,6 +205,13 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
|
|||||||
secret: true,
|
secret: true,
|
||||||
condition: speed_and_skill,
|
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`.
|
/// Return every `AchievementDef` whose condition is satisfied by `ctx`.
|
||||||
@@ -225,6 +239,7 @@ mod tests {
|
|||||||
best_single_score: 0,
|
best_single_score: 0,
|
||||||
lifetime_score: 0,
|
lifetime_score: 0,
|
||||||
draw_three_wins: 0,
|
draw_three_wins: 0,
|
||||||
|
daily_challenge_streak: 0,
|
||||||
last_win_score: 0,
|
last_win_score: 0,
|
||||||
last_win_time_seconds: u64::MAX,
|
last_win_time_seconds: u64::MAX,
|
||||||
last_win_used_undo: true,
|
last_win_used_undo: true,
|
||||||
@@ -310,6 +325,18 @@ mod tests {
|
|||||||
assert!(!ids.contains(&"night_owl"));
|
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]
|
#[test]
|
||||||
fn achievement_by_id_finds_known_and_returns_none_for_unknown() {
|
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"));
|
assert_eq!(achievement_by_id("first_win").map(|d| d.name), Some("First Win"));
|
||||||
|
|||||||
@@ -48,6 +48,6 @@ pub use achievements::{
|
|||||||
|
|
||||||
pub mod progress;
|
pub mod progress;
|
||||||
pub use progress::{
|
pub use progress::{
|
||||||
level_for_xp, load_progress_from, progress_file_path, save_progress_to, xp_for_win,
|
daily_seed_for, level_for_xp, load_progress_from, progress_file_path, save_progress_to,
|
||||||
PlayerProgress,
|
xp_for_win, PlayerProgress,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::fs;
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use chrono::{DateTime, NaiveDate, Utc};
|
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
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.
|
/// XP awarded for winning a game.
|
||||||
///
|
///
|
||||||
/// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if
|
/// 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 {
|
pub fn leveled_up_from(&self, prev_level: u32) -> bool {
|
||||||
self.level > prev_level
|
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`.
|
/// Platform-specific default path for `progress.json`.
|
||||||
@@ -243,4 +275,61 @@ mod tests {
|
|||||||
save_progress_to(&path, &PlayerProgress::default()).expect("save");
|
save_progress_to(&path, &PlayerProgress::default()).expect("save");
|
||||||
assert!(!path.with_extension("json.tmp").exists());
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use solitaire_data::{
|
|||||||
|
|
||||||
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
|
use crate::progress_plugin::{ProgressResource, ProgressUpdate};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||||
|
|
||||||
@@ -66,20 +67,26 @@ impl Plugin for AchievementPlugin {
|
|||||||
.insert_resource(AchievementsStoragePath(self.storage_path.clone()))
|
.insert_resource(AchievementsStoragePath(self.storage_path.clone()))
|
||||||
.add_event::<AchievementUnlockedEvent>()
|
.add_event::<AchievementUnlockedEvent>()
|
||||||
.add_event::<GameWonEvent>()
|
.add_event::<GameWonEvent>()
|
||||||
// Run after GameMutation (so GameWonEvent is available) and after
|
// Run after GameMutation (so GameWonEvent is available), after
|
||||||
// StatsUpdate (so StatsResource already reflects this win).
|
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
||||||
|
// (so daily_challenge_streak is up to date for daily_devotee).
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
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(
|
fn evaluate_on_win(
|
||||||
mut wins: EventReader<GameWonEvent>,
|
mut wins: EventReader<GameWonEvent>,
|
||||||
mut unlocks: EventWriter<AchievementUnlockedEvent>,
|
mut unlocks: EventWriter<AchievementUnlockedEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
stats: Res<StatsResource>,
|
stats: Res<StatsResource>,
|
||||||
|
progress: Res<ProgressResource>,
|
||||||
path: Res<AchievementsStoragePath>,
|
path: Res<AchievementsStoragePath>,
|
||||||
mut achievements: ResMut<AchievementsResource>,
|
mut achievements: ResMut<AchievementsResource>,
|
||||||
) {
|
) {
|
||||||
@@ -94,6 +101,7 @@ fn evaluate_on_win(
|
|||||||
best_single_score: stats.0.best_single_score,
|
best_single_score: stats.0.best_single_score,
|
||||||
lifetime_score: stats.0.lifetime_score,
|
lifetime_score: stats.0.lifetime_score,
|
||||||
draw_three_wins: stats.0.draw_three_wins,
|
draw_three_wins: stats.0.draw_three_wins,
|
||||||
|
daily_challenge_streak: progress.0.daily_challenge_streak,
|
||||||
last_win_score: ev.score,
|
last_win_score: ev.score,
|
||||||
last_win_time_seconds: ev.time_seconds,
|
last_win_time_seconds: ev.time_seconds,
|
||||||
last_win_used_undo: game.0.undo_count > 0,
|
last_win_used_undo: game.0.undo_count > 0,
|
||||||
@@ -149,6 +157,7 @@ mod tests {
|
|||||||
.add_plugins(GamePlugin)
|
.add_plugins(GamePlugin)
|
||||||
.add_plugins(TablePlugin)
|
.add_plugins(TablePlugin)
|
||||||
.add_plugins(StatsPlugin::headless())
|
.add_plugins(StatsPlugin::headless())
|
||||||
|
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
||||||
.add_plugins(AchievementPlugin::headless());
|
.add_plugins(AchievementPlugin::headless());
|
||||||
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
|
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
|
||||||
// MinimalPlugins it isn't auto-registered.
|
// MinimalPlugins it isn't auto-registered.
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ use crate::card_plugin::CardEntity;
|
|||||||
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
|
use crate::progress_plugin::LevelUpEvent;
|
||||||
|
|
||||||
/// Duration of a card slide (move) animation in seconds.
|
/// Duration of a card slide (move) animation in seconds.
|
||||||
pub const SLIDE_SECS: f32 = 0.15;
|
pub const SLIDE_SECS: f32 = 0.15;
|
||||||
|
|
||||||
const WIN_TOAST_SECS: f32 = 4.0;
|
const WIN_TOAST_SECS: f32 = 4.0;
|
||||||
const ACHIEVEMENT_TOAST_SECS: f32 = 3.0;
|
const ACHIEVEMENT_TOAST_SECS: f32 = 3.0;
|
||||||
|
const LEVELUP_TOAST_SECS: f32 = 3.0;
|
||||||
const CASCADE_STAGGER: f32 = 0.05;
|
const CASCADE_STAGGER: f32 = 0.05;
|
||||||
const CASCADE_DURATION: f32 = 0.5;
|
const CASCADE_DURATION: f32 = 0.5;
|
||||||
|
|
||||||
@@ -50,12 +52,14 @@ impl Plugin for AnimationPlugin {
|
|||||||
// is idempotent in Bevy.
|
// is idempotent in Bevy.
|
||||||
app.add_event::<GameWonEvent>()
|
app.add_event::<GameWonEvent>()
|
||||||
.add_event::<AchievementUnlockedEvent>()
|
.add_event::<AchievementUnlockedEvent>()
|
||||||
|
.add_event::<LevelUpEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
advance_card_anims,
|
advance_card_anims,
|
||||||
handle_win_cascade,
|
handle_win_cascade,
|
||||||
handle_achievement_toast,
|
handle_achievement_toast,
|
||||||
|
handle_levelup_toast,
|
||||||
tick_toasts,
|
tick_toasts,
|
||||||
)
|
)
|
||||||
.after(GameMutation),
|
.after(GameMutation),
|
||||||
@@ -133,6 +137,16 @@ fn handle_achievement_toast(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_levelup_toast(mut commands: Commands, mut events: EventReader<LevelUpEvent>) {
|
||||||
|
for ev in events.read() {
|
||||||
|
spawn_toast(
|
||||||
|
&mut commands,
|
||||||
|
format!("Level Up! → {}", ev.new_level),
|
||||||
|
LEVELUP_TOAST_SECS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn tick_toasts(
|
fn tick_toasts(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
//! Tracks the per-date daily challenge: a deterministic seed every player
|
||||||
|
//! sees on a given calendar day, plus completion bookkeeping.
|
||||||
|
//!
|
||||||
|
//! When the player wins a game whose seed matches today's daily seed and
|
||||||
|
//! today's date hasn't been completed yet, this plugin:
|
||||||
|
//! - calls `PlayerProgress::record_daily_completion`
|
||||||
|
//! - awards a fixed XP bonus (`DAILY_BONUS_XP`)
|
||||||
|
//! - persists progress
|
||||||
|
//! - emits `DailyChallengeCompletedEvent`
|
||||||
|
//!
|
||||||
|
//! Pressing **C** fires a `NewGameRequestEvent` with today's daily seed so
|
||||||
|
//! the player can start a fresh attempt.
|
||||||
|
|
||||||
|
use bevy::input::ButtonInput;
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use chrono::{Local, NaiveDate};
|
||||||
|
use solitaire_data::{daily_seed_for, save_progress_to};
|
||||||
|
|
||||||
|
use crate::events::{GameWonEvent, NewGameRequestEvent};
|
||||||
|
use crate::game_plugin::GameMutation;
|
||||||
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
|
||||||
|
/// Bonus XP awarded for completing today's daily challenge.
|
||||||
|
pub const DAILY_BONUS_XP: u64 = 100;
|
||||||
|
|
||||||
|
/// The active daily challenge — date + RNG seed for that date's deal.
|
||||||
|
#[derive(Resource, Debug, Clone, Copy)]
|
||||||
|
pub struct DailyChallengeResource {
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub seed: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DailyChallengeResource {
|
||||||
|
pub fn for_today() -> Self {
|
||||||
|
let date = Local::now().date_naive();
|
||||||
|
Self {
|
||||||
|
date,
|
||||||
|
seed: daily_seed_for(date),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fired when the player has just completed today's daily challenge.
|
||||||
|
#[derive(Event, Debug, Clone, Copy)]
|
||||||
|
pub struct DailyChallengeCompletedEvent {
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub streak: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DailyChallengePlugin;
|
||||||
|
|
||||||
|
impl Plugin for DailyChallengePlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.insert_resource(DailyChallengeResource::for_today())
|
||||||
|
.add_event::<DailyChallengeCompletedEvent>()
|
||||||
|
.add_event::<GameWonEvent>()
|
||||||
|
.add_event::<NewGameRequestEvent>()
|
||||||
|
// record/award after the base ProgressUpdate so we don't fight
|
||||||
|
// ProgressPlugin's add_xp on the same frame.
|
||||||
|
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
|
||||||
|
.add_systems(Update, handle_start_daily_request.before(GameMutation));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_daily_completion(
|
||||||
|
mut wins: EventReader<GameWonEvent>,
|
||||||
|
daily: Res<DailyChallengeResource>,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
mut progress: ResMut<ProgressResource>,
|
||||||
|
path: Res<ProgressStoragePath>,
|
||||||
|
mut completed: EventWriter<DailyChallengeCompletedEvent>,
|
||||||
|
) {
|
||||||
|
for _ in wins.read() {
|
||||||
|
if game.0.seed != daily.seed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !progress.0.record_daily_completion(daily.date) {
|
||||||
|
// Already counted today — no-op.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
progress.0.add_xp(DAILY_BONUS_XP);
|
||||||
|
if let Some(target) = &path.0 {
|
||||||
|
if let Err(e) = save_progress_to(target, &progress.0) {
|
||||||
|
warn!("failed to save progress after daily completion: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completed.send(DailyChallengeCompletedEvent {
|
||||||
|
date: daily.date,
|
||||||
|
streak: progress.0.daily_challenge_streak,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_start_daily_request(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
daily: Res<DailyChallengeResource>,
|
||||||
|
mut new_game: EventWriter<NewGameRequestEvent>,
|
||||||
|
) {
|
||||||
|
if keys.just_pressed(KeyCode::KeyC) {
|
||||||
|
new_game.send(NewGameRequestEvent {
|
||||||
|
seed: Some(daily.seed),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use crate::progress_plugin::ProgressPlugin;
|
||||||
|
use crate::table_plugin::TablePlugin;
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
|
||||||
|
fn headless_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
|
.add_plugins(TablePlugin)
|
||||||
|
.add_plugins(ProgressPlugin::headless())
|
||||||
|
.add_plugins(DailyChallengePlugin);
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resource_uses_today() {
|
||||||
|
let app = headless_app();
|
||||||
|
let r = app.world().resource::<DailyChallengeResource>();
|
||||||
|
assert_eq!(r.date, Local::now().date_naive());
|
||||||
|
assert_eq!(r.seed, daily_seed_for(r.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn winning_with_daily_seed_completes_and_fires_event() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
||||||
|
|
||||||
|
// Replace the GameState with one whose seed matches the daily seed.
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
|
GameState::new(daily_seed, DrawMode::DrawOne);
|
||||||
|
|
||||||
|
app.world_mut().send_event(GameWonEvent {
|
||||||
|
score: 500,
|
||||||
|
time_seconds: 200,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let progress = &app.world().resource::<ProgressResource>().0;
|
||||||
|
assert_eq!(progress.daily_challenge_streak, 1);
|
||||||
|
// +100 from the daily bonus
|
||||||
|
assert!(progress.total_xp >= DAILY_BONUS_XP);
|
||||||
|
|
||||||
|
let events = app.world().resource::<Events<DailyChallengeCompletedEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||||
|
assert_eq!(fired.len(), 1);
|
||||||
|
assert_eq!(fired[0].streak, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn winning_with_unrelated_seed_does_not_complete_daily() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
||||||
|
// Use a deliberately different seed.
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
|
GameState::new(daily_seed.wrapping_add(7777), DrawMode::DrawOne);
|
||||||
|
|
||||||
|
app.world_mut().send_event(GameWonEvent {
|
||||||
|
score: 500,
|
||||||
|
time_seconds: 200,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let progress = &app.world().resource::<ProgressResource>().0;
|
||||||
|
assert_eq!(progress.daily_challenge_streak, 0);
|
||||||
|
|
||||||
|
let events = app.world().resource::<Events<DailyChallengeCompletedEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
assert!(cursor.read(events).next().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_win_same_day_is_idempotent() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0 =
|
||||||
|
GameState::new(daily_seed, DrawMode::DrawOne);
|
||||||
|
|
||||||
|
app.world_mut().send_event(GameWonEvent {
|
||||||
|
score: 500,
|
||||||
|
time_seconds: 200,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
// Re-send win.
|
||||||
|
app.world_mut().send_event(GameWonEvent {
|
||||||
|
score: 500,
|
||||||
|
time_seconds: 200,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let progress = &app.world().resource::<ProgressResource>().0;
|
||||||
|
assert_eq!(progress.daily_challenge_streak, 1, "streak does not double-count");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pressing_c_fires_new_game_with_daily_seed() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
let daily_seed = app.world().resource::<DailyChallengeResource>().seed;
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::KeyC);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Events<NewGameRequestEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
let fired: Vec<_> = cursor.read(events).copied().collect();
|
||||||
|
assert_eq!(fired.len(), 1);
|
||||||
|
assert_eq!(fired[0].seed, Some(daily_seed));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
pub mod achievement_plugin;
|
pub mod achievement_plugin;
|
||||||
pub mod animation_plugin;
|
pub mod animation_plugin;
|
||||||
pub mod card_plugin;
|
pub mod card_plugin;
|
||||||
|
pub mod daily_challenge_plugin;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod game_plugin;
|
pub mod game_plugin;
|
||||||
pub mod input_plugin;
|
pub mod input_plugin;
|
||||||
@@ -13,6 +14,9 @@ pub mod stats_plugin;
|
|||||||
pub mod table_plugin;
|
pub mod table_plugin;
|
||||||
|
|
||||||
pub use achievement_plugin::{AchievementPlugin, AchievementsResource};
|
pub use achievement_plugin::{AchievementPlugin, AchievementsResource};
|
||||||
|
pub use daily_challenge_plugin::{
|
||||||
|
DailyChallengeCompletedEvent, DailyChallengePlugin, DailyChallengeResource,
|
||||||
|
};
|
||||||
pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate};
|
pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate};
|
||||||
pub use animation_plugin::{AnimationPlugin, CardAnim};
|
pub use animation_plugin::{AnimationPlugin, CardAnim};
|
||||||
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};
|
||||||
|
|||||||
Reference in New Issue
Block a user