fix(engine): swallow game-mutating hotkeys during pointer drags
Test / fmt (pull_request) Successful in 6s
Test / test (pull_request) Successful in 4m43s

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 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-14 11:35:47 -07:00
parent fc4ea17789
commit 5430d4c3ab
2 changed files with 142 additions and 0 deletions
+120
View File
@@ -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::<UndoRequestEvent>();
app.add_message::<NewGameRequestEvent>();
app.add_message::<InfoToastEvent>();
app.add_message::<DrawRequestEvent>();
app.add_message::<StartZenRequestEvent>();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<DragState>();
app.add_systems(Update, handle_keyboard_core);
app
}
fn press_key(app: &mut App, key: KeyCode) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
input.clear();
input.press(key);
}
fn message_count<M: Message>(app: &App) -> usize {
let messages = app.world().resource::<Messages<M>>();
let mut cursor = messages.get_cursor();
cursor.read(messages).count()
}
fn set_drag(app: &mut App, active_touch_id: Option<u64>) {
let mut drag = app.world_mut().resource_mut::<DragState>();
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::<UndoRequestEvent>(&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::<UndoRequestEvent>(&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::<DrawRequestEvent>(&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::<UndoRequestEvent>(&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::<ForfeitRequestEvent>();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<DragState>();
app.add_systems(Update, handle_keyboard_forfeit);
set_drag(&mut app, None);
press_key(&mut app, KeyCode::KeyG);
app.update();
assert_eq!(
message_count::<ForfeitRequestEvent>(&app),
0,
"G during a mouse drag must be swallowed",
);
}