diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 450b87b..fb2d4c9 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -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() diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index c7c89ff..cc758b5 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -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; diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index 80812eb..ba33ff3 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -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); diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index 89adf31..a06d7f7 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -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, mut ev: CoreKeyboardMessages<'_>, mut time_attack: Option>, + selection: Option>, ) { 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); diff --git a/solitaire_engine/src/pause_plugin.rs b/solitaire_engine/src/pause_plugin.rs index 719da67..bb52242 100644 --- a/solitaire_engine/src/pause_plugin.rs +++ b/solitaire_engine/src/pause_plugin.rs @@ -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::() .add_message::() .init_resource::() - .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>, mut drag: Option>, mut changed: MessageWriter, + selection: Option>, ) { 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 // ----------------------------------------------------------------------- diff --git a/solitaire_engine/src/selection_plugin.rs b/solitaire_engine/src/selection_plugin.rs index 1849246..0f3d9a6 100644 --- a/solitaire_engine/src/selection_plugin.rs +++ b/solitaire_engine/src/selection_plugin.rs @@ -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, } +/// 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, + mut selection: ResMut, +) { + 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