feat(toast): wire ToastVariant::Error for invalid-move feedback

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::<MoveRejectedEvent>()` 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 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-08 13:59:39 -07:00
parent ec804d54c6
commit 68d50b5021
+72 -5
View File
@@ -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::<ChallengeAdvancedEvent>()
.add_message::<SettingsChangedEvent>()
.add_message::<InfoToastEvent>()
.add_message::<MoveRejectedEvent>()
.add_message::<XpAwardedEvent>()
.init_resource::<EffectiveSlideDuration>()
.init_resource::<ToastQueue>()
@@ -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<XpA
}
}
/// Spawns a 2-second pink-bordered Error toast when the player tries an
/// illegal placement (`MoveRejectedEvent`). Adds a third leg to the
/// existing rejection feedback stool — `card_invalid.wav` already plays
/// (audio cue) and `feedback_anim_plugin::queue_shake_for_rejected_move`
/// fires the destination-pile shake (visual cue). The toast is the
/// accessibility-focused leg: persistent ~2 s text that's readable for
/// deaf players and impossible to miss for players who blink during the
/// shake. First in-engine consumer of `ToastVariant::Error` — exercises
/// the variant's pink border accent and the design-system "rejected
/// action" semantic.
fn handle_move_rejected_toast(
mut commands: Commands,
mut events: MessageReader<MoveRejectedEvent>,
) {
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
// -----------------------------------------------------------------------