fix(engine): resolve input coordination bugs in selection/pause/keyboard
- SelectionPlugin: add clear_selection_on_state_change system so undo/move/reject never leave a stale selection pointing at the wrong card - SelectionPlugin: expose SelectionKeySet system set for cross-plugin ordering - PausePlugin: skip Escape→pause when a card is keyboard-selected; toggle_pause now runs before SelectionKeySet so it reads SelectionState before it is cleared - InputPlugin: guard Space→DrawRequestEvent when SelectionState has an active pile so Space executes a card move instead of also drawing from stock - window: enforce 800×600 minimum via WindowResizeConstraints - game_state: add precondition doc to next_auto_complete_move (waste exclusion) - card_plugin: 12 unit tests for constants, face_colour, label_visibility, label_for - pause_plugin: add paused_resource_default and draw_mode_label exhaustiveness tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,11 @@ fn main() {
|
||||
primary_window: Some(Window {
|
||||
title: "Solitaire Quest".into(),
|
||||
resolution: (1280u32, 800u32).into(),
|
||||
resize_constraints: bevy::window::WindowResizeConstraints {
|
||||
min_width: 800.0,
|
||||
min_height: 600.0,
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
|
||||
@@ -361,6 +361,15 @@ impl GameState {
|
||||
/// Scans tableau piles 0–6 in order, returning the first top card that
|
||||
/// can be placed on any foundation pile. The scan order ensures Aces are
|
||||
/// resolved before higher ranks that depend on them.
|
||||
///
|
||||
/// # Precondition
|
||||
///
|
||||
/// This function is only called when `is_auto_completable` is `true`.
|
||||
/// Auto-completability requires the waste pile to be empty, as enforced by
|
||||
/// [`check_auto_complete`](Self::check_auto_complete) — it returns `false`
|
||||
/// whenever `piles[Waste]` is non-empty. Therefore, skipping the waste pile
|
||||
/// in this scan is intentional and correct: by the time this function is
|
||||
/// reached, there are guaranteed to be no cards there to move.
|
||||
pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> {
|
||||
if !self.is_auto_completable || self.is_won {
|
||||
return None;
|
||||
|
||||
@@ -1322,6 +1322,129 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Constant sanity bounds (pure)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tableau_fan_frac_is_in_unit_interval() {
|
||||
assert!(
|
||||
TABLEAU_FAN_FRAC > 0.0 && TABLEAU_FAN_FRAC < 1.0,
|
||||
"TABLEAU_FAN_FRAC must be in (0, 1), got {TABLEAU_FAN_FRAC}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flip_half_secs_is_positive() {
|
||||
assert!(
|
||||
FLIP_HALF_SECS > 0.0,
|
||||
"FLIP_HALF_SECS must be positive, got {FLIP_HALF_SECS}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_size_frac_is_positive_and_reasonable() {
|
||||
assert!(
|
||||
FONT_SIZE_FRAC > 0.0 && FONT_SIZE_FRAC <= 1.0,
|
||||
"FONT_SIZE_FRAC should be in (0, 1], got {FONT_SIZE_FRAC}"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// face_colour (pure) — color-blind mode
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn face_colour_normal_mode_returns_card_face_colour_for_red_suit() {
|
||||
let card = Card { id: 0, suit: Suit::Hearts, rank: Rank::King, face_up: true };
|
||||
assert_eq!(face_colour(&card, false), CARD_FACE_COLOUR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_colour_normal_mode_returns_card_face_colour_for_black_suit() {
|
||||
let card = Card { id: 0, suit: Suit::Spades, rank: Rank::King, face_up: true };
|
||||
assert_eq!(face_colour(&card, false), CARD_FACE_COLOUR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_colour_color_blind_mode_gives_red_suits_a_different_tint() {
|
||||
let red_card = Card { id: 0, suit: Suit::Diamonds, rank: Rank::Queen, face_up: true };
|
||||
let cbm_colour = face_colour(&red_card, true);
|
||||
assert_ne!(
|
||||
cbm_colour, CARD_FACE_COLOUR,
|
||||
"color-blind mode must tint red-suit cards differently from the standard face colour"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_colour_color_blind_mode_does_not_change_black_suits() {
|
||||
let black_card = Card { id: 0, suit: Suit::Clubs, rank: Rank::Jack, face_up: true };
|
||||
assert_eq!(
|
||||
face_colour(&black_card, true),
|
||||
CARD_FACE_COLOUR,
|
||||
"color-blind mode must not alter black-suit card face colour"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// label_visibility (pure)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn label_visibility_face_up_is_inherited() {
|
||||
let card = Card { id: 0, suit: Suit::Clubs, rank: Rank::Ace, face_up: true };
|
||||
assert_eq!(label_visibility(&card), Visibility::Inherited);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_visibility_face_down_is_hidden() {
|
||||
let card = Card { id: 0, suit: Suit::Clubs, rank: Rank::Ace, face_up: false };
|
||||
assert_eq!(label_visibility(&card), Visibility::Hidden);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// label_for — remaining ranks not yet covered
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn label_for_all_ranks_contain_suit_letter() {
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
let letters = ["C", "D", "H", "S"];
|
||||
for (suit, letter) in suits.iter().zip(letters.iter()) {
|
||||
let card = Card { id: 0, suit: *suit, rank: Rank::King, face_up: true };
|
||||
assert!(
|
||||
label_for(&card).ends_with(letter),
|
||||
"label for {suit:?} must end with '{letter}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_for_face_cards_use_letter_prefix() {
|
||||
let make = |rank| Card { id: 0, suit: Suit::Spades, rank, face_up: true };
|
||||
assert!(label_for(&make(Rank::Jack)).starts_with('J'));
|
||||
assert!(label_for(&make(Rank::Queen)).starts_with('Q'));
|
||||
assert!(label_for(&make(Rank::King)).starts_with('K'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_for_numeric_ranks_two_through_nine() {
|
||||
let make = |rank| Card { id: 0, suit: Suit::Clubs, rank, face_up: true };
|
||||
let expected = [
|
||||
(Rank::Two, "2C"),
|
||||
(Rank::Three, "3C"),
|
||||
(Rank::Four, "4C"),
|
||||
(Rank::Five, "5C"),
|
||||
(Rank::Six, "6C"),
|
||||
(Rank::Seven, "7C"),
|
||||
(Rank::Eight, "8C"),
|
||||
(Rank::Nine, "9C"),
|
||||
];
|
||||
for (rank, label) in expected {
|
||||
assert_eq!(label_for(&make(rank)), label, "rank {rank:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
|
||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||
|
||||
@@ -43,6 +43,7 @@ use crate::pause_plugin::PausedResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::{DragState, GameStateResource, HintCycleIndex};
|
||||
use crate::selection_plugin::SelectionState;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
|
||||
/// Z-depth used for cards while being dragged — above all resting cards.
|
||||
@@ -144,6 +145,7 @@ fn handle_keyboard_core(
|
||||
mut confirm: ResMut<KeyboardConfirmState>,
|
||||
mut ev: CoreKeyboardMessages<'_>,
|
||||
mut time_attack: Option<ResMut<TimeAttackResource>>,
|
||||
selection: Option<Res<SelectionState>>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
@@ -224,7 +226,11 @@ fn handle_keyboard_core(
|
||||
}
|
||||
}
|
||||
|
||||
if keys.just_pressed(KeyCode::KeyD) || keys.just_pressed(KeyCode::Space) {
|
||||
// Space draws only when no card is keyboard-selected; when a card IS selected,
|
||||
// SelectionPlugin handles Space to execute the move.
|
||||
let space_draws = keys.just_pressed(KeyCode::Space)
|
||||
&& selection.as_ref().is_none_or(|s| s.selected_pile.is_none());
|
||||
if keys.just_pressed(KeyCode::KeyD) || space_draws {
|
||||
// Cancel any pending forfeit when the player takes another action.
|
||||
confirm.forfeit_countdown = 0.0;
|
||||
ev.draw.write(DrawRequestEvent);
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::events::StateChangedEvent;
|
||||
use crate::game_plugin::{GameOverScreen, GameStatePath};
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::selection_plugin::{SelectionKeySet, SelectionState};
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
|
||||
use crate::stats_plugin::StatsResource;
|
||||
|
||||
@@ -58,7 +59,15 @@ impl Plugin for PausePlugin {
|
||||
app.add_message::<SettingsChangedEvent>()
|
||||
.add_message::<StateChangedEvent>()
|
||||
.init_resource::<PausedResource>()
|
||||
.add_systems(Update, (toggle_pause, handle_pause_draw_toggle));
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
// toggle_pause must see SelectionState *before* handle_selection_keys
|
||||
// clears it, so it can skip Escape when a card is selected.
|
||||
toggle_pause.before(SelectionKeySet),
|
||||
handle_pause_draw_toggle,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,10 +85,16 @@ fn toggle_pause(
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
mut drag: Option<ResMut<DragState>>,
|
||||
mut changed: MessageWriter<StateChangedEvent>,
|
||||
selection: Option<Res<SelectionState>>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::Escape) {
|
||||
return;
|
||||
}
|
||||
// If a card is currently selected, let SelectionPlugin handle this Escape
|
||||
// (it will clear the selection). Pause must not also open in the same frame.
|
||||
if selection.is_some_and(|s| s.selected_pile.is_some()) {
|
||||
return;
|
||||
}
|
||||
// If the game-over overlay is visible, let handle_game_over_input consume
|
||||
// the Escape key (to start a new game). Do not open the pause overlay.
|
||||
if !game_over_screens.is_empty() {
|
||||
@@ -415,6 +430,16 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PausedResource default (pure)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn paused_resource_default_is_unpaused() {
|
||||
let p = PausedResource::default();
|
||||
assert!(!p.0, "game must start unpaused");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// draw_mode_label (pure function) — Task #64
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -429,6 +454,17 @@ mod tests {
|
||||
assert_eq!(draw_mode_label(DrawMode::DrawThree), "Draw 3");
|
||||
}
|
||||
|
||||
/// Both variants are covered so the match is exhaustive — this test would
|
||||
/// fail to compile if a new DrawMode variant were added without updating
|
||||
/// `draw_mode_label`.
|
||||
#[test]
|
||||
fn draw_mode_label_covers_all_variants() {
|
||||
for mode in [DrawMode::DrawOne, DrawMode::DrawThree] {
|
||||
let label = draw_mode_label(mode);
|
||||
assert!(!label.is_empty(), "draw_mode_label must never return an empty string");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// pause_draw_toggle_flips_draw_mode — Task #64
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -22,7 +22,7 @@ use solitaire_core::card::Suit;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::{InfoToastEvent, MoveRequestEvent};
|
||||
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::input_plugin::{best_destination, best_tableau_destination_for_stack};
|
||||
use crate::layout::LayoutResource;
|
||||
@@ -42,6 +42,13 @@ pub struct SelectionState {
|
||||
pub selected_pile: Option<PileType>,
|
||||
}
|
||||
|
||||
/// System set label for the key-handling system.
|
||||
///
|
||||
/// `PausePlugin` registers `toggle_pause` before this set so it can read
|
||||
/// [`SelectionState`] before `handle_selection_keys` clears it on Escape.
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct SelectionKeySet;
|
||||
|
||||
/// Marker component placed on the outline sprite used as the keyboard-selection
|
||||
/// highlight.
|
||||
///
|
||||
@@ -59,7 +66,10 @@ impl Plugin for SelectionPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
handle_selection_keys.before(GameMutation),
|
||||
handle_selection_keys
|
||||
.in_set(SelectionKeySet)
|
||||
.before(GameMutation),
|
||||
clear_selection_on_state_change.after(GameMutation),
|
||||
update_selection_highlight.after(GameMutation),
|
||||
),
|
||||
);
|
||||
@@ -329,6 +339,20 @@ fn try_foundation_dest(
|
||||
None
|
||||
}
|
||||
|
||||
/// Clears the selection whenever the game state changes.
|
||||
///
|
||||
/// Without this, an undo or a rejected move could leave `selected_pile`
|
||||
/// pointing at a pile whose top card changed, causing the highlight to
|
||||
/// trail a different card than the player expects.
|
||||
fn clear_selection_on_state_change(
|
||||
mut state_events: MessageReader<StateChangedEvent>,
|
||||
mut selection: ResMut<SelectionState>,
|
||||
) {
|
||||
if state_events.read().next().is_some() {
|
||||
selection.selected_pile = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Maintains the `SelectionHighlight` outline sprite.
|
||||
///
|
||||
/// When a pile is selected, a cyan sprite is placed at the selected card's
|
||||
|
||||
Reference in New Issue
Block a user