From 68d50b502102ccf696c384d1923ab64b1f3f4876 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 8 May 2026 13:59:39 -0700 Subject: [PATCH] feat(toast): wire ToastVariant::Error for invalid-move feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resume-prompt Option C — first in-engine consumer of `ToastVariant::Error`. The variant has had a slot in the enum since v0.20.0's toast system landed; this commit wires a real driver event so the slot is no longer dead code. ### Driver: MoveRejectedEvent When a player tries an illegal placement (drops dragged cards on a real pile but the move violates the rules), `MoveRejectedEvent` fires. The existing rejection-feedback chain plays `card_invalid.wav` (audio cue) and triggers the destination-pile shake (visual cue via `feedback_anim_plugin`). This commit adds a third leg — a 2-second pink-bordered Error toast reading "Invalid move" — primarily for accessibility: - **Audio cue alone** doesn't help deaf players. - **Visual shake alone** is brief and easy to miss for low-vision players or anyone with reduce-motion enabled (which gates the shake's animation timing). - **Toast text** is persistent ~2 s, readable, and unambiguous. The three legs together cover the major perception channels. ### Implementation New `handle_move_rejected_toast` system in `animation_plugin` mirrors the shape of `handle_xp_awarded_toast` — read events, fire `spawn_toast(commands, "Invalid move", 2.0, ToastVariant::Error)`. Registered in the plugin's Update set between `handle_xp_awarded_toast` and `tick_toasts` so the toast spawn pipeline picks it up the same frame the event fires. `AnimationPlugin::build` gains `.add_message::()` so the message is initialized when the plugin runs under MinimalPlugins (tests). The message is also registered by `feedback_anim_plugin` — Bevy's `add_message` is idempotent, so both registrations coexist cleanly. Also drops the `#[allow(dead_code)]` from `ToastVariant::Error` (stale now that the variant has a real consumer) and updates the variant's doc comment to point at `handle_move_rejected_toast`. ### Test New `move_rejected_event_spawns_error_toast` pins the wiring: firing a `MoveRejectedEvent` spawns exactly one `ToastOverlay` on the next tick. Matches the shape of the existing `info_toast_event_spawns_toast_overlay` test. 1195 passing (+1 from prior 1194). Workspace clippy clean. Co-Authored-By: Claude Opus 4.7 --- solitaire_engine/src/animation_plugin.rs | 77 ++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 5 deletions(-) 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 // -----------------------------------------------------------------------