fix(engine): Draw-Three waste fan hit-testing; add HUD and input coverage

fix(input_plugin): card_position() now applies the same X-fan offset for
Draw-Three waste cards as card_plugin uses for rendering. Previously the
top waste card appeared at base_x + 0.56 * card_width but was only
hittable at base_x, making it impossible to drag from its visual position.

test(hud_plugin): add five behaviour tests — score/moves/time display
format, Zen mode score suppression, Draw-Three mode badge.

test(input_plugin): add find_draggable test that clicks the top fanned
waste card at its visual X position and confirms it hits in Draw-Three.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-27 04:42:25 +00:00
parent c06458cf80
commit 74fa6c7cff
2 changed files with 90 additions and 1 deletions
+34 -1
View File
@@ -23,6 +23,7 @@ use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::{CardEntity, TABLEAU_FAN_FRAC};
use solitaire_core::game_state::DrawMode;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{
DrawRequestEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent,
@@ -341,8 +342,15 @@ fn card_position(game: &GameState, layout: &Layout, pile: PileType, stack_index:
if matches!(pile, PileType::Tableau(_)) {
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
Vec2::new(base.x, base.y + fan * (stack_index as f32))
} else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree {
// In Draw-Three mode the top 3 waste cards are fanned in X to match
// card_plugin::card_positions(). Hit-testing must use the same offsets
// so clicking the visually rightmost (top) card actually registers.
let pile_len = game.piles.get(&pile).map_or(0, |p| p.cards.len());
let visible_start = pile_len.saturating_sub(3);
let slot = stack_index.saturating_sub(visible_start) as f32;
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
} else {
let _ = game;
base
}
}
@@ -645,6 +653,31 @@ mod tests {
);
}
#[test]
fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameMode};
let mut game = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Classic);
let waste = game.piles.get_mut(&PileType::Waste).unwrap();
waste.cards.clear();
// Three waste cards; top (id=202) is rightmost in the fan.
waste.cards.push(Card { id: 200, suit: Suit::Spades, rank: Rank::Two, face_up: true });
waste.cards.push(Card { id: 201, suit: Suit::Hearts, rank: Rank::Three, face_up: true });
waste.cards.push(Card { id: 202, suit: Suit::Clubs, rank: Rank::Four, face_up: true });
let layout = compute_layout(Vec2::new(1280.0, 800.0));
let waste_base = layout.pile_positions[&PileType::Waste];
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width.
let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
let cursor = Vec2::new(top_card_x, waste_base.y);
let result = find_draggable_at(cursor, &game, &layout);
assert!(result.is_some(), "top fanned waste card must be hittable at its visual X position");
let (pile, _start, ids) = result.unwrap();
assert_eq!(pile, PileType::Waste);
assert_eq!(ids, vec![202], "only the top card is draggable from waste");
}
#[test]
fn pile_drop_rect_is_card_sized_for_non_tableau() {
let game = GameState::new(42, DrawMode::DrawOne);