From f579b96d7683de487639381aac409b1e25ef2c06 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 27 Apr 2026 01:38:25 +0000 Subject: [PATCH] feat(engine): wire AnimSpeed to animation, new achievements, leaderboard opt-in, daily goal display - AnimSpeed setting now drives card slide duration (Normal=0.15s, Fast=0.07s, Instant=snap); EffectiveSlideDuration resource updated on SettingsChangedEvent; AnimSpeed row added to Settings panel - GameState.recycle_count tracks waste recycles; perfectionist/comeback/zen_winner achievements added with full unit tests - SyncProvider gains opt_in_leaderboard(); SolitaireServerClient implements POST /api/leaderboard/opt-in; Opt In button added to leaderboard panel - DailyChallengeResource stores goal_description/target_score/max_time_secs from server; pressing C shows goal description as toast (DailyGoalAnnouncementEvent) Co-Authored-By: Claude Sonnet 4.6 --- solitaire_core/src/achievement.rs | 82 +++++++++++++++++ solitaire_core/src/game_state.rs | 24 +++++ solitaire_data/src/lib.rs | 8 ++ solitaire_data/src/sync_client.rs | 36 ++++++++ solitaire_engine/src/achievement_plugin.rs | 2 + solitaire_engine/src/animation_plugin.rs | 59 ++++++++++++- solitaire_engine/src/card_plugin.rs | 19 ++-- .../src/daily_challenge_plugin.rs | 88 ++++++++++++++++++- solitaire_engine/src/leaderboard_plugin.rs | 83 ++++++++++++++++- solitaire_engine/src/settings_plugin.rs | 71 +++++++++++++-- 10 files changed, 453 insertions(+), 19 deletions(-) diff --git a/solitaire_core/src/achievement.rs b/solitaire_core/src/achievement.rs index e848b7c..d703583 100644 --- a/solitaire_core/src/achievement.rs +++ b/solitaire_core/src/achievement.rs @@ -32,6 +32,11 @@ pub struct AchievementContext { /// Local hour (0–23) at the time of win. `None` if unknown. pub wall_clock_hour: Option, + + /// Number of times waste was recycled back to stock during the won game. + pub last_win_recycle_count: u32, + /// `true` if the game was played in Zen mode. + pub last_win_is_zen: bool, } /// Reward granted when an achievement is first unlocked. @@ -118,6 +123,15 @@ fn speed_and_skill(c: &AchievementContext) -> bool { fn daily_devotee(c: &AchievementContext) -> bool { c.daily_challenge_streak >= 7 } +fn perfectionist(c: &AchievementContext) -> bool { + !c.last_win_used_undo && c.last_win_score >= 5_000 +} +fn comeback(c: &AchievementContext) -> bool { + c.last_win_recycle_count >= 3 +} +fn zen_winner(c: &AchievementContext) -> bool { + c.last_win_is_zen +} /// All currently-evaluable achievements. Order is stable so persistence files /// remain readable across versions (new achievements append). @@ -242,6 +256,30 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[ reward: Some(Reward::Background(3)), condition: daily_devotee, }, + AchievementDef { + id: "perfectionist", + name: "Perfectionist", + description: "Win without undo and score at least 5,000", + secret: false, + reward: Some(Reward::Badge), + condition: perfectionist, + }, + AchievementDef { + id: "comeback", + name: "???", + description: "A secret achievement", + secret: true, + reward: Some(Reward::Background(4)), + condition: comeback, + }, + AchievementDef { + id: "zen_winner", + name: "???", + description: "A secret achievement", + secret: true, + reward: Some(Reward::Badge), + condition: zen_winner, + }, ]; /// Return every `AchievementDef` whose condition is satisfied by `ctx`. @@ -274,6 +312,8 @@ mod tests { last_win_time_seconds: u64::MAX, last_win_used_undo: true, wall_clock_hour: None, + last_win_recycle_count: 0, + last_win_is_zen: false, } } @@ -367,6 +407,48 @@ mod tests { assert!(ids.contains(&"daily_devotee")); } + #[test] + fn perfectionist_requires_no_undo_and_high_score() { + let mut c = ctx(); + c.last_win_used_undo = false; + c.last_win_score = 5_000; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"perfectionist")); + + c.last_win_used_undo = true; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"perfectionist")); + + c.last_win_used_undo = false; + c.last_win_score = 4_999; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"perfectionist")); + } + + #[test] + fn comeback_requires_at_least_three_recycles() { + let mut c = ctx(); + c.last_win_recycle_count = 2; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"comeback")); + + c.last_win_recycle_count = 3; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"comeback")); + } + + #[test] + fn zen_winner_requires_zen_mode() { + let mut c = ctx(); + c.last_win_is_zen = false; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(!ids.contains(&"zen_winner")); + + c.last_win_is_zen = true; + let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect(); + assert!(ids.contains(&"zen_winner")); + } + #[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_core/src/game_state.rs b/solitaire_core/src/game_state.rs index e814263..69328d9 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -80,6 +80,10 @@ pub struct GameState { /// Number of times `undo()` has been successfully invoked this game. /// Used by achievement conditions like `no_undo`. pub undo_count: u32, + /// Number of times the waste pile has been recycled back to stock this game. + /// Used by the `comeback` achievement condition. + #[serde(default)] + pub recycle_count: u32, undo_stack: VecDeque, } @@ -116,6 +120,7 @@ impl GameState { is_won: false, is_auto_completable: false, undo_count: 0, + recycle_count: 0, undo_stack: VecDeque::new(), } } @@ -167,6 +172,7 @@ impl GameState { card.face_up = false; stock.cards.push(card); } + self.recycle_count = self.recycle_count.saturating_add(1); return Ok(()); } @@ -481,6 +487,24 @@ mod tests { assert!(g.piles[&PileType::Waste].cards.is_empty()); } + #[test] + fn recycle_count_increments_on_each_waste_recycle() { + let mut g = new_game(); + assert_eq!(g.recycle_count, 0); + // Drain entire stock to waste. + while !g.piles[&PileType::Stock].cards.is_empty() { + g.draw().unwrap(); + } + g.draw().unwrap(); // first recycle + assert_eq!(g.recycle_count, 1); + // Drain again and recycle a second time. + while !g.piles[&PileType::Stock].cards.is_empty() { + g.draw().unwrap(); + } + g.draw().unwrap(); // second recycle + assert_eq!(g.recycle_count, 2); + } + #[test] fn draw_from_empty_stock_and_waste_returns_error() { // The only stop condition for draw() is: both stock AND waste are diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index 8a3b5a0..755ee92 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -41,6 +41,11 @@ pub trait SyncProvider: Send + Sync { async fn fetch_daily_challenge(&self) -> Result, SyncError> { Ok(None) } + /// Opt the authenticated player into the leaderboard with the given + /// display name. No-op for backends that don't support leaderboards. + async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError> { + Ok(()) + } } /// Blanket impl so `Box` (returned by @@ -68,6 +73,9 @@ impl SyncProvider for Box { async fn fetch_daily_challenge(&self) -> Result, SyncError> { (**self).fetch_daily_challenge().await } + async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> { + (**self).opt_in_leaderboard(display_name).await + } } pub mod stats; diff --git a/solitaire_data/src/sync_client.rs b/solitaire_data/src/sync_client.rs index 76668bf..9e85ae0 100644 --- a/solitaire_data/src/sync_client.rs +++ b/solitaire_data/src/sync_client.rs @@ -225,6 +225,42 @@ impl SyncProvider for SolitaireServerClient { } } + async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> { + let token = self.access_token()?; + let url = format!("{}/api/leaderboard/opt-in", self.base_url); + + let resp = self + .client + .post(&url) + .bearer_auth(&token) + .json(&serde_json::json!({ "display_name": display_name })) + .send() + .await + .map_err(|e| SyncError::Network(e.to_string()))?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + self.refresh_token().await?; + let new_token = self.access_token()?; + let resp = self + .client + .post(&url) + .bearer_auth(new_token) + .json(&serde_json::json!({ "display_name": display_name })) + .send() + .await + .map_err(|e| SyncError::Network(e.to_string()))?; + if !resp.status().is_success() { + return Err(SyncError::Auth(format!("opt-in failed: {}", resp.status()))); + } + return Ok(()); + } + + if !resp.status().is_success() { + return Err(SyncError::Auth(format!("opt-in failed: {}", resp.status()))); + } + Ok(()) + } + async fn fetch_leaderboard(&self) -> Result, SyncError> { let token = self.access_token()?; let url = format!("{}/api/leaderboard", self.base_url); diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs index eec2149..2903994 100644 --- a/solitaire_engine/src/achievement_plugin.rs +++ b/solitaire_engine/src/achievement_plugin.rs @@ -114,6 +114,8 @@ fn evaluate_on_win( last_win_time_seconds: ev.time_seconds, last_win_used_undo: game.0.undo_count > 0, wall_clock_hour: Some(Local::now().hour()), + last_win_recycle_count: game.0.recycle_count, + last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen, }; let hits = check_achievements(&ctx); diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index 0e5ab17..5eb00e9 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -4,23 +4,44 @@ //! it directly when adding animations outside this file. use bevy::prelude::*; +use solitaire_data::AnimSpeed; use crate::achievement_plugin::display_name_for; use crate::auto_complete_plugin::AutoCompleteState; use crate::card_plugin::CardEntity; use crate::challenge_plugin::ChallengeAdvancedEvent; -use crate::daily_challenge_plugin::DailyChallengeCompletedEvent; +use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent}; use crate::events::{AchievementUnlockedEvent, GameWonEvent}; use crate::game_plugin::GameMutation; use crate::layout::LayoutResource; use crate::progress_plugin::LevelUpEvent; -use crate::settings_plugin::SettingsChangedEvent; +use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::time_attack_plugin::TimeAttackEndedEvent; use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent; -/// Duration of a card slide (move) animation in seconds. +/// Duration of a card slide (move) animation in seconds at Normal speed. pub const SLIDE_SECS: f32 = 0.15; +/// The effective slide duration, updated whenever `Settings::animation_speed` changes. +#[derive(Resource, Debug, Clone, Copy)] +pub struct EffectiveSlideDuration { + pub slide_secs: f32, +} + +impl Default for EffectiveSlideDuration { + fn default() -> Self { + Self { slide_secs: SLIDE_SECS } + } +} + +fn anim_speed_to_secs(speed: &AnimSpeed) -> f32 { + match speed { + AnimSpeed::Normal => SLIDE_SECS, + AnimSpeed::Fast => 0.07, + AnimSpeed::Instant => 0.0, + } +} + const WIN_TOAST_SECS: f32 = 4.0; const ACHIEVEMENT_TOAST_SECS: f32 = 3.0; const LEVELUP_TOAST_SECS: f32 = 3.0; @@ -65,17 +86,22 @@ impl Plugin for AnimationPlugin { .add_event::() .add_event::() .add_event::() + .add_event::() .add_event::() .add_event::() .add_event::() .add_event::() + .init_resource::() + .add_systems(Startup, init_slide_duration) .add_systems( Update, ( advance_card_anims, + sync_slide_duration, handle_win_cascade, handle_achievement_toast, handle_levelup_toast, + handle_daily_goal_announcement_toast, handle_daily_toast, handle_weekly_toast, handle_time_attack_toast, @@ -89,6 +115,24 @@ impl Plugin for AnimationPlugin { } } +fn init_slide_duration( + settings: Option>, + mut dur: ResMut, +) { + if let Some(s) = settings { + dur.slide_secs = anim_speed_to_secs(&s.0.animation_speed); + } +} + +fn sync_slide_duration( + mut events: EventReader, + mut dur: ResMut, +) { + for ev in events.read() { + dur.slide_secs = anim_speed_to_secs(&ev.0.animation_speed); + } +} + fn advance_card_anims( mut commands: Commands, time: Res