fix(engine): single-card double-click with no destination now plays the reject animation

handle_double_click had a coverage gap. The flow was:

  - Priority 1: try moving the single top card to its best
    destination (foundation, then tableau).
  - Priority 2: if Priority 1 failed AND the player clicked the
    base of a multi-card stack, try moving the whole stack.

`MoveRejectedEvent` was only fired inside the Priority 2 else-branch
— so a double-click on a single card with no legal destination
fell through both priorities silently: no card_invalid.wav, no
shake animation on the source pile, the player got zero feedback
that the click was acknowledged.

The fix collapses both priorities' failure paths into one
unconditional `MoveRejectedEvent` write at the end of the
double-click branch. Single-card miss now plays the same feedback
as multi-card-stack miss. The early `return` on each successful
move keeps the rejection branch from firing on the success path.

Pre-fix, a player double-clicking the 7♠ buried under a 6♥ on
column 5 (no foundation slot for 7s; no tableau column accepting
black 7) saw nothing happen. Post-fix, the source pile shakes
and the invalid-move sound plays, exactly like a drag-and-drop
rejection.

Workspace: 1170 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-06 19:54:28 -07:00
parent b57db017d3
commit d7ffb16df5
+17 -12
View File
@@ -1310,32 +1310,37 @@ fn handle_double_click(
// Priority 2: if the player clicked the base of a multi-card face-up // Priority 2: if the player clicked the base of a multi-card face-up
// stack (card_ids.len() > 1), try moving the whole stack to another // stack (card_ids.len() > 1), try moving the whole stack to another
// tableau column. // tableau column.
if card_ids.len() > 1 { if card_ids.len() > 1
let Some(bottom_card) = game.0.piles.get(&pile) && let Some(bottom_card) = game.0.piles.get(&pile)
.and_then(|p| p.cards.get(stack_index)) else { return }; .and_then(|p| p.cards.get(stack_index))
if let Some((dest, count)) = best_tableau_destination_for_stack( && let Some((dest, count)) = best_tableau_destination_for_stack(
bottom_card, bottom_card,
&pile, &pile,
&game.0, &game.0,
card_ids.len(), card_ids.len(),
) { )
{
moves.write(MoveRequestEvent { moves.write(MoveRequestEvent {
from: pile, from: pile,
to: dest, to: dest,
count, count,
}); });
} else { return;
// No legal destination for the stack — play the invalid-move }
// sound and shake the source pile cards as feedback.
// `MoveRejectedEvent` with `from == to` routes the shake to // Both priorities failed — play the invalid-move sound and shake
// the source pile (which `start_shake_anim` reads from `ev.to`). // the source pile as feedback. `MoveRejectedEvent` with
// `from == to` routes the shake to the source pile (which
// `start_shake_anim` reads from `ev.to`). Pre-fix, this branch
// only fired for multi-card stacks, so a double-click on a
// single card with no legal destination did nothing — no
// sound, no shake. Now both single-card and stack misses get
// the same feedback.
rejected.write(MoveRejectedEvent { rejected.write(MoveRejectedEvent {
from: pile.clone(), from: pile.clone(),
to: pile, to: pile,
count: card_ids.len(), count: card_ids.len(),
}); });
}
}
} else { } else {
// Single click — record the time. // Single click — record the time.
last_click.insert(top_card_id, now); last_click.insert(top_card_id, now);