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
+56
View File
@@ -164,4 +164,60 @@ mod tests {
GameState::new(42, DrawMode::DrawOne); GameState::new(42, DrawMode::DrawOne);
app.update(); app.update();
} }
fn read_hud_text<M: Component>(app: &mut App) -> String {
app.world_mut()
.query_filtered::<&Text, With<M>>()
.iter(app.world())
.next()
.map(|t| t.0.clone())
.unwrap_or_default()
}
#[test]
fn score_reflects_game_state() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.score = 750;
app.update();
assert_eq!(read_hud_text::<HudScore>(&mut app), "Score: 750");
}
#[test]
fn moves_reflects_game_state() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.move_count = 42;
app.update();
assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42");
}
#[test]
fn draw_three_mode_shows_draw_3_badge() {
use solitaire_core::game_state::GameMode;
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(42, DrawMode::DrawThree, GameMode::Classic);
app.update();
assert_eq!(read_hud_text::<HudMode>(&mut app), "Draw 3");
}
#[test]
fn zen_mode_hides_score() {
use solitaire_core::game_state::GameMode;
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
app.world_mut().resource_mut::<GameStateResource>().0.score = 999;
app.update();
// Zen mode spec: "No score display" → text must be empty.
assert_eq!(read_hud_text::<HudScore>(&mut app), "");
}
#[test]
fn time_display_uses_mm_ss_format() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.elapsed_seconds = 125;
app.update();
// 125 seconds = 2 minutes 5 seconds → "2:05"
assert_eq!(read_hud_text::<HudTime>(&mut app), "2:05");
}
} }
+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 solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::{CardEntity, TABLEAU_FAN_FRAC}; use crate::card_plugin::{CardEntity, TABLEAU_FAN_FRAC};
use solitaire_core::game_state::DrawMode;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{ use crate::events::{
DrawRequestEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent, NewGameConfirmEvent, 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(_)) { if matches!(pile, PileType::Tableau(_)) {
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC; let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
Vec2::new(base.x, base.y + fan * (stack_index as f32)) 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 { } else {
let _ = game;
base 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] #[test]
fn pile_drop_rect_is_card_sized_for_non_tableau() { fn pile_drop_rect_is_card_sized_for_non_tableau() {
let game = GameState::new(42, DrawMode::DrawOne); let game = GameState::new(42, DrawMode::DrawOne);