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:
@@ -164,4 +164,60 @@ mod tests {
|
||||
GameState::new(42, DrawMode::DrawOne);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user