diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index 9561181..a0358f3 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -21,8 +21,9 @@ use crate::card_animation::{sample_curve, CardAnimation, MotionCurve}; use crate::card_plugin::CardEntity; use crate::challenge_plugin::ChallengeAdvancedEvent; use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent}; -use crate::events::{InfoToastEvent, XpAwardedEvent}; -use crate::events::{AchievementUnlockedEvent, GameWonEvent}; +use crate::events::{ + AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, MoveRejectedEvent, XpAwardedEvent, +}; use crate::game_plugin::GameMutation; use crate::layout::LayoutResource; use crate::pause_plugin::PausedResource; @@ -162,6 +163,7 @@ impl Plugin for AnimationPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .init_resource::() .init_resource::() @@ -183,6 +185,7 @@ impl Plugin for AnimationPlugin { handle_settings_toast, handle_auto_complete_toast, handle_xp_awarded_toast, + handle_move_rejected_toast, tick_toasts, (enqueue_toasts, drive_toast_display).chain(), ) @@ -565,9 +568,11 @@ pub enum ToastVariant { /// event; kept so future warning-flavoured toasts have a slot. #[allow(dead_code)] Warning, - /// Failure / rejected action — pink border. Currently unused; kept so - /// future error-flavoured toasts have a slot. - #[allow(dead_code)] + /// Failure / rejected action — pink border. Used by + /// [`handle_move_rejected_toast`] for illegal-placement + /// feedback; the third leg of the rejection-feedback stool + /// alongside `card_invalid.wav` (audio) and the destination- + /// pile shake (visual). Error, /// Reward / milestone — lavender border. Used for XP awards, /// achievement unlocks, level-ups, daily/weekly/challenge completions. @@ -622,6 +627,30 @@ fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader, +) { + for _ev in events.read() { + spawn_toast( + &mut commands, + "Invalid move".to_string(), + 2.0, + ToastVariant::Error, + ); + } +} + /// Ticks down `ToastTimer` on each toast and despawns it when the timer expires. /// /// Skipped while the game is paused so toast countdowns freeze along with the @@ -966,6 +995,44 @@ mod tests { let _ = count; } + #[test] + fn move_rejected_event_spawns_error_toast() { + // The first in-engine consumer of `ToastVariant::Error`. Firing + // a `MoveRejectedEvent` (illegal placement) must spawn exactly + // one `ToastOverlay` carrying the rejection-feedback message. + // Pairs the existing audio (`card_invalid.wav`) and visual + // (`feedback_anim_plugin::queue_shake_for_rejected_move`) feedback + // with an accessibility-focused readable text cue. + use solitaire_core::pile::PileType; + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin); + + // Baseline: no toast overlays exist before the event. + let before = app + .world_mut() + .query::<&ToastOverlay>() + .iter(app.world()) + .count(); + + app.world_mut().write_message(MoveRejectedEvent { + from: PileType::Tableau(0), + to: PileType::Tableau(1), + count: 1, + }); + app.update(); + + let after = app + .world_mut() + .query::<&ToastOverlay>() + .iter(app.world()) + .count(); + assert_eq!( + after, + before + 1, + "MoveRejectedEvent must spawn exactly one error toast", + ); + } + // ----------------------------------------------------------------------- // Task #67 — Toast queue pure-function tests // -----------------------------------------------------------------------