From 5430d4c3ab64c7ba7973fc35deaa03b27d5784af Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 14 Jul 2026 11:35:47 -0700 Subject: [PATCH] fix(engine): swallow game-mutating hotkeys during pointer drags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit U (undo), N (new game), Z (zen), D/Space (draw) and G (forfeit) were not gated on an active drag, so pressing one mid-drag fired a StateChangedEvent whose card re-sync inserted a `CardAnim` on the dragged cards — fighting `follow_drag`'s per-frame Transform writes (jittering XY, animated Z) and leaving `DragState` origin indices stale against the mutated state. `handle_keyboard_core` and `handle_keyboard_forfeit` now reuse the mutual-exclusion guard `handle_selection_keys` already documents: skip while `DragState` is non-idle, unless it is the keyboard-lift sentinel (`KEYBOARD_DRAG_TOUCH_ID`), whose lift is already dropped cleanly by `clear_selection_on_state_change`. Co-Authored-By: Claude Fable 5 --- solitaire_engine/src/input_plugin/mod.rs | 22 ++++ solitaire_engine/src/input_plugin/tests.rs | 120 +++++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/solitaire_engine/src/input_plugin/mod.rs b/solitaire_engine/src/input_plugin/mod.rs index ad2cfad..0589ee2 100644 --- a/solitaire_engine/src/input_plugin/mod.rs +++ b/solitaire_engine/src/input_plugin/mod.rs @@ -215,6 +215,7 @@ fn handle_keyboard_core( keys: Res>, paused: Option>, progress: Option>, + drag: Res, mut ev: CoreKeyboardMessages<'_>, mut time_attack: Option>, selection: Option>, @@ -227,6 +228,19 @@ fn handle_keyboard_core( return; } + // Mutual exclusion with pointer drags — mirrors `handle_selection_keys`. + // Undo / draw / new-game during an active mouse or touch drag would fire + // a StateChangedEvent whose card re-sync inserts a `CardAnim` on the + // dragged cards, fighting `follow_drag`'s per-frame Transform writes and + // leaving `DragState` origin indices stale against the mutated state. + // The keyboard-drag sentinel may proceed: `clear_selection_on_state_change` + // already drops that lift cleanly when the state moves. + if !drag.is_idle() + && drag.active_touch_id != Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID) + { + return; + } + // During replay playback (Playing or Completed) all game-input shortcuts // are suppressed. The replay overlay owns Space (pause/resume) and the // arrow keys (step). Letting game input through would mutate @@ -473,11 +487,19 @@ pub fn emit_hint_visuals( fn handle_keyboard_forfeit( keys: Res>, paused: Option>, + drag: Res, mut requests: MessageWriter, ) { if paused.is_some_and(|p| p.0) { return; } + // Same pointer-drag exclusion as `handle_keyboard_core` — forfeiting + // mid-drag would mutate the game state under the dragged cards. + if !drag.is_idle() + && drag.active_touch_id != Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID) + { + return; + } if !keys.just_pressed(KeyCode::KeyG) { return; } diff --git a/solitaire_engine/src/input_plugin/tests.rs b/solitaire_engine/src/input_plugin/tests.rs index 612ecb0..4beba8b 100644 --- a/solitaire_engine/src/input_plugin/tests.rs +++ b/solitaire_engine/src/input_plugin/tests.rs @@ -817,3 +817,123 @@ fn pressing_h_spawns_pending_hint_task() { "pressing H must spawn an async hint task", ); } + +// ----------------------------------------------------------------------- +// Pointer-drag / keyboard mutual exclusion. +// +// A state mutation fired while `follow_drag` is writing the dragged cards' +// transforms every frame would make the card re-sync insert a `CardAnim` +// on the same entities (two systems fighting over `Transform`) and leave +// `DragState`'s origin indices stale. `handle_keyboard_core` and +// `handle_keyboard_forfeit` therefore swallow game-mutating shortcuts +// while a mouse or touch drag is live, mirroring `handle_selection_keys`. +// The keyboard-lift sentinel is exempt: `clear_selection_on_state_change` +// drops that lift cleanly when the state moves. +// ----------------------------------------------------------------------- + +fn keyboard_core_app() -> App { + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + app.add_message::(); + app.add_message::(); + app.add_message::(); + app.add_message::(); + app.add_message::(); + app.init_resource::>(); + app.init_resource::(); + app.add_systems(Update, handle_keyboard_core); + app +} + +fn press_key(app: &mut App, key: KeyCode) { + let mut input = app.world_mut().resource_mut::>(); + input.release(key); + input.clear(); + input.press(key); +} + +fn message_count(app: &App) -> usize { + let messages = app.world().resource::>(); + let mut cursor = messages.get_cursor(); + cursor.read(messages).count() +} + +fn set_drag(app: &mut App, active_touch_id: Option) { + let mut drag = app.world_mut().resource_mut::(); + drag.cards = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two)]; + drag.committed = true; + drag.active_touch_id = active_touch_id; +} + +#[test] +fn u_key_fires_undo_when_no_drag_active() { + let mut app = keyboard_core_app(); + press_key(&mut app, KeyCode::KeyU); + app.update(); + assert_eq!( + message_count::(&app), + 1, + "U with an idle DragState must fire UndoRequestEvent", + ); +} + +#[test] +fn u_key_ignored_during_committed_mouse_drag() { + let mut app = keyboard_core_app(); + set_drag(&mut app, None); // mouse drag + press_key(&mut app, KeyCode::KeyU); + app.update(); + assert_eq!( + message_count::(&app), + 0, + "U during a mouse drag must be swallowed", + ); +} + +#[test] +fn draw_key_ignored_during_touch_drag() { + let mut app = keyboard_core_app(); + set_drag(&mut app, Some(7)); // real touch id + press_key(&mut app, KeyCode::KeyD); + app.update(); + assert_eq!( + message_count::(&app), + 0, + "D during a touch drag must be swallowed", + ); +} + +#[test] +fn u_key_allowed_during_keyboard_lift() { + let mut app = keyboard_core_app(); + set_drag( + &mut app, + Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID), + ); + press_key(&mut app, KeyCode::KeyU); + app.update(); + assert_eq!( + message_count::(&app), + 1, + "the keyboard-lift sentinel must not block core shortcuts", + ); +} + +#[test] +fn g_key_forfeit_ignored_during_mouse_drag() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + app.add_message::(); + app.init_resource::>(); + app.init_resource::(); + app.add_systems(Update, handle_keyboard_forfeit); + + set_drag(&mut app, None); + press_key(&mut app, KeyCode::KeyG); + app.update(); + assert_eq!( + message_count::(&app), + 0, + "G during a mouse drag must be swallowed", + ); +}