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
+12 -3
View File
@@ -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::<AchievementUnlockedEvent>()
.add_event::<GameWonEvent>()
// 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<GameWonEvent>,
mut unlocks: EventWriter<AchievementUnlockedEvent>,
game: Res<GameStateResource>,
stats: Res<StatsResource>,
progress: Res<ProgressResource>,
path: Res<AchievementsStoragePath>,
mut achievements: ResMut<AchievementsResource>,
) {
@@ -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<KeyCode>; under
// MinimalPlugins it isn't auto-registered.
+14
View File
@@ -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::<GameWonEvent>()
.add_event::<AchievementUnlockedEvent>()
.add_event::<LevelUpEvent>()
.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<LevelUpEvent>) {
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<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));
}
}
+4
View File
@@ -3,6 +3,7 @@
pub mod achievement_plugin;
pub mod animation_plugin;
pub mod card_plugin;
pub mod daily_challenge_plugin;
pub mod events;
pub mod game_plugin;
pub mod input_plugin;
@@ -13,6 +14,9 @@ pub mod stats_plugin;
pub mod table_plugin;
pub use achievement_plugin::{AchievementPlugin, AchievementsResource};
pub use daily_challenge_plugin::{
DailyChallengeCompletedEvent, DailyChallengePlugin, DailyChallengeResource,
};
pub use progress_plugin::{LevelUpEvent, ProgressPlugin, ProgressResource, ProgressUpdate};
pub use animation_plugin::{AnimationPlugin, CardAnim};
pub use card_plugin::{CardEntity, CardLabel, CardPlugin};