feat(engine): playability improvements — rounds 7–9 (#40–#64)

Round 7 — Input & feedback
- H key cycles hints; F1 opens help (conflict resolved)
- N key cancels active Time Attack session
- Hint text distinguishes "draw from stock" vs "recycle waste"
- Forfeit (G) clears AutoCompleteState so chime does not bleed into new deal
- Zen mode timer clears immediately on Z press
- HUD shows recycle count in both draw modes
- Settings scroll position persisted across open/close

Round 8 — Polish & clarity
- Undo unavailable fires "Nothing to undo" toast
- Streak-break toast on forfeit/abandon when streak > 1
- F11 fullscreen toggle with toast; documented in help and home screens
- H-after-win, new-game countdown expiry, Tab-no-cards toasts
- Win cascade duration/stagger scales with animation speed setting
- Draw-Three cycle counter HUD ("Cycle: N/3")
- Forfeit requires G×2 confirmation within 3 s (mirrors N key)

Round 9 — Game feel & information
- Escape dismisses game-over/stuck overlay (PausePlugin skips Escape when overlay visible)
- Shake animation on rejected drag before snap-back
- Forfeit countdown cancels when any other key is pressed (U/H/D/Z/Space)
- Tab wrap-around fires "Back to first card" toast
- HUD selection indicator shows active Tab-selected pile in yellow
- Challenge time-limit HUD turns orange < 60s, red < 30s
- Win summary shows XP breakdown (+50 base, +25 no-undo, +N speed)
- Game-over overlay: "No more moves available" with clear N/Escape/G instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-28 02:35:15 +00:00
parent d387ee68d7
commit 03227f8c77
26 changed files with 3278 additions and 264 deletions
+627 -69
View File
@@ -5,7 +5,7 @@
//!
//! Keyboard:
//! - `U` → `UndoRequestEvent`
//! - `N` → `NewGameRequestEvent { seed: None }`
//! - `N` → `NewGameRequestEvent { seed: None }` (cancels Time Attack if active)
//! - `D` / `Space` → `DrawRequestEvent`
//! - `Esc` → handled by `PausePlugin` (overlay toggle + paused flag)
//!
@@ -18,6 +18,7 @@
use std::collections::HashMap;
use bevy::ecs::system::SystemParam;
use bevy::input::ButtonInput;
use bevy::math::{Vec2, Vec3};
use bevy::prelude::*;
@@ -28,6 +29,7 @@ use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::{CardEntity, HintHighlight, TABLEAU_FAN_FRAC};
use crate::feedback_anim_plugin::ShakeAnim;
use solitaire_core::game_state::DrawMode;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{
@@ -38,7 +40,8 @@ use crate::game_plugin::GameMutation;
use crate::pause_plugin::PausedResource;
use crate::progress_plugin::ProgressResource;
use crate::layout::{Layout, LayoutResource};
use crate::resources::{DragState, GameStateResource};
use crate::resources::{DragState, GameStateResource, HintCycleIndex};
use crate::time_attack_plugin::TimeAttackResource;
/// Z-depth used for cards while being dragged — above all resting cards.
const DRAG_Z: f32 = 500.0;
@@ -54,7 +57,8 @@ pub struct InputPlugin;
impl Plugin for InputPlugin {
fn build(&self, app: &mut App) {
app.add_event::<NewGameConfirmEvent>()
app.init_resource::<HintCycleIndex>()
.add_event::<NewGameConfirmEvent>()
.add_event::<InfoToastEvent>()
.add_event::<ForfeitEvent>()
.add_systems(
@@ -69,13 +73,29 @@ impl Plugin for InputPlugin {
)
.chain(),
)
.add_systems(Update, handle_fullscreen);
.add_systems(Update, handle_fullscreen)
.add_systems(Update, reset_hint_cycle_on_state_change);
}
}
/// Seconds after the first N press during which a second N confirms new game.
const NEW_GAME_CONFIRM_WINDOW: f32 = 3.0;
/// Seconds after the first G press during which a second G confirms forfeit.
const FORFEIT_CONFIRM_WINDOW: f32 = 3.0;
/// Bundles all event writers used by `handle_keyboard` so the system stays
/// within Bevy's 16-parameter limit.
#[derive(SystemParam)]
struct KeyboardEvents<'w> {
undo: EventWriter<'w, UndoRequestEvent>,
new_game: EventWriter<'w, NewGameRequestEvent>,
confirm_event: EventWriter<'w, NewGameConfirmEvent>,
info_toast: EventWriter<'w, InfoToastEvent>,
draw: EventWriter<'w, DrawRequestEvent>,
forfeit: EventWriter<'w, ForfeitEvent>,
}
#[allow(clippy::too_many_arguments)]
fn handle_keyboard(
keys: Res<ButtonInput<KeyCode>>,
@@ -84,15 +104,14 @@ fn handle_keyboard(
game: Option<Res<GameStateResource>>,
time: Res<Time>,
mut confirm_countdown: Local<f32>,
mut undo: EventWriter<UndoRequestEvent>,
mut new_game: EventWriter<NewGameRequestEvent>,
mut confirm_event: EventWriter<NewGameConfirmEvent>,
mut info_toast: EventWriter<InfoToastEvent>,
mut draw: EventWriter<DrawRequestEvent>,
mut forfeit: EventWriter<ForfeitEvent>,
mut confirm_pending: Local<bool>,
mut forfeit_countdown: Local<f32>,
mut ev: KeyboardEvents,
mut commands: Commands,
card_entities: Query<(Entity, &CardEntity, &Sprite)>,
layout: Option<Res<LayoutResource>>,
mut hint_cycle: ResMut<HintCycleIndex>,
mut time_attack: Option<ResMut<TimeAttackResource>>,
) {
if paused.is_some_and(|p| p.0) {
return;
@@ -102,102 +121,214 @@ fn handle_keyboard(
*confirm_countdown -= time.delta_secs();
if *confirm_countdown <= 0.0 {
*confirm_countdown = 0.0;
// Countdown expired without a second N press — notify the player.
if *confirm_pending {
*confirm_pending = false;
ev.info_toast.send(InfoToastEvent("New game cancelled".to_string()));
}
}
}
// Tick down the forfeit confirmation window.
if *forfeit_countdown > 0.0 {
*forfeit_countdown -= time.delta_secs();
if *forfeit_countdown <= 0.0 {
*forfeit_countdown = 0.0;
}
}
if keys.just_pressed(KeyCode::KeyU) {
undo.send(UndoRequestEvent);
if *forfeit_countdown > 0.0 { *forfeit_countdown = 0.0; }
ev.undo.send(UndoRequestEvent);
}
if keys.just_pressed(KeyCode::KeyN) {
// If a Time Attack session is running, cancel it and start a Classic game.
if let Some(ref mut session) = time_attack {
if session.active {
session.active = false;
session.remaining_secs = 0.0;
ev.info_toast.send(InfoToastEvent("Time Attack ended".to_string()));
ev.new_game.send(NewGameRequestEvent {
seed: None,
mode: Some(solitaire_core::game_state::GameMode::Classic),
});
*confirm_countdown = 0.0;
return;
}
}
let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won);
let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
if shift_held || !active_game {
// Shift+N or no active game — start immediately, no confirmation.
new_game.send(NewGameRequestEvent::default());
ev.new_game.send(NewGameRequestEvent::default());
*confirm_countdown = 0.0;
*confirm_pending = false;
} else if *confirm_countdown > 0.0 {
// Second press within the window — confirmed.
new_game.send(NewGameRequestEvent::default());
ev.new_game.send(NewGameRequestEvent::default());
*confirm_countdown = 0.0;
*confirm_pending = false;
} else {
// First press on an active game — require confirmation.
*confirm_countdown = NEW_GAME_CONFIRM_WINDOW;
confirm_event.send(NewGameConfirmEvent);
*confirm_pending = true;
ev.confirm_event.send(NewGameConfirmEvent);
}
}
if keys.just_pressed(KeyCode::KeyZ) {
if *forfeit_countdown > 0.0 { *forfeit_countdown = 0.0; }
// Zen / Challenge / Time Attack are gated to level >= CHALLENGE_UNLOCK_LEVEL.
// X is gated separately by ChallengePlugin.
let level = progress.as_ref().map_or(0, |p| p.0.level);
if level >= CHALLENGE_UNLOCK_LEVEL {
new_game.send(NewGameRequestEvent {
ev.new_game.send(NewGameRequestEvent {
seed: None,
mode: Some(solitaire_core::game_state::GameMode::Zen),
});
} else {
info_toast.send(InfoToastEvent(format!(
ev.info_toast.send(InfoToastEvent(format!(
"Zen mode unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
)));
}
}
if keys.just_pressed(KeyCode::KeyD) || keys.just_pressed(KeyCode::Space) {
draw.send(DrawRequestEvent);
if *forfeit_countdown > 0.0 { *forfeit_countdown = 0.0; }
ev.draw.send(DrawRequestEvent);
}
// H — show a hint (highlight the source card of the best available move).
// H — cycle through all available hints on each press, highlighting the
// source card yellow for 1.5 s. The index wraps around once all hints have
// been shown. When no moves are available a toast is shown instead.
if keys.just_pressed(KeyCode::KeyH) {
if *forfeit_countdown > 0.0 { *forfeit_countdown = 0.0; }
if let Some(ref g) = game {
if !g.0.is_won {
if let Some(ref layout_res) = layout {
if let Some((from, _to, _count)) = find_hint(&g.0) {
// Find the top face-up card in the source pile.
let top_card_id = g.0.piles.get(&from)
.and_then(|p| p.cards.last().filter(|c| c.face_up))
.map(|c| c.id);
if let Some(card_id) = top_card_id {
for (entity, card_entity, _sprite) in card_entities.iter() {
if card_entity.card_id == card_id {
commands.entity(entity)
.insert(HintHighlight { remaining: 1.5 })
.insert(Sprite {
color: Color::srgba(1.0, 1.0, 0.4, 1.0),
custom_size: Some(layout_res.0.card_size),
..default()
});
break;
if g.0.is_won {
ev.info_toast.send(InfoToastEvent(
"Game won! Press N for a new game".to_string(),
));
} else if let Some(ref layout_res) = layout {
let hints = all_hints(&g.0);
if hints.is_empty() {
ev.info_toast.send(InfoToastEvent("No hints available".to_string()));
} else {
// Pick the hint at the current cycle index (wrapping) and advance.
let idx = hint_cycle.0 % hints.len();
hint_cycle.0 = hint_cycle.0.wrapping_add(1);
let (from, to, _count) = &hints[idx];
// When the hint points at the stock (draw suggestion) there is no
// face-up card to highlight — show a toast instead.
// If the stock is empty, pressing D will recycle the waste rather
// than draw a card, so the toast text must reflect that.
if *from == PileType::Stock {
let stock_empty = g.0.piles
.get(&PileType::Stock)
.is_some_and(|p| p.cards.is_empty());
let msg = if stock_empty {
"Hint: recycle waste (D)".to_string()
} else {
"Hint: draw from stock (D)".to_string()
};
ev.info_toast.send(InfoToastEvent(msg));
} else {
// Find the top face-up card in the source pile and highlight it.
let top_card_id = g.0.piles.get(from)
.and_then(|p| p.cards.last().filter(|c| c.face_up))
.map(|c| c.id);
if let Some(card_id) = top_card_id {
for (entity, card_entity, _sprite) in card_entities.iter() {
if card_entity.card_id == card_id {
commands.entity(entity)
.insert(HintHighlight { remaining: 1.5 })
.insert(Sprite {
color: Color::srgba(1.0, 1.0, 0.4, 1.0),
custom_size: Some(layout_res.0.card_size),
..default()
});
break;
}
}
}
// Fire an informational toast describing where the hinted card
// should move so the player always sees the suggestion in text.
let msg = match to {
PileType::Foundation(suit) => {
let suit_name = match suit {
Suit::Clubs => "Clubs",
Suit::Diamonds => "Diamonds",
Suit::Hearts => "Hearts",
Suit::Spades => "Spades",
};
format!("Hint: move to {suit_name} foundation")
}
PileType::Tableau(col) => {
format!("Hint: move to tableau (col {})", col + 1)
}
_ => "Hint: move card".to_string(),
};
ev.info_toast.send(InfoToastEvent(msg));
}
} else {
info_toast.send(InfoToastEvent("No hints available".to_string()));
}
}
}
}
}
// G — forfeit the current game (only when a game is actually in progress).
// G — forfeit the current game with a 3-second double-confirm window to
// prevent accidental forfeits. First press shows a toast and starts the
// countdown; second press within the window sends the ForfeitEvent.
if keys.just_pressed(KeyCode::KeyG) {
let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won);
if active_game {
forfeit.send(ForfeitEvent);
if *forfeit_countdown > 0.0 {
// Second press within the confirmation window — confirmed.
ev.forfeit.send(ForfeitEvent);
*forfeit_countdown = 0.0;
} else {
// First press — start the countdown and warn the player.
*forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
ev.info_toast.send(InfoToastEvent("Press G again to forfeit".to_string()));
}
}
}
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
}
/// Resets [`HintCycleIndex`] to `0` whenever the game state changes or a new
/// game is requested so the next H press always starts cycling from the first
/// hint of the new position.
///
/// Listening to both events ensures the reset happens immediately on
/// `NewGameRequestEvent`, one frame before the `StateChangedEvent` that the
/// game plugin fires after dealing — preventing a stale hint from the previous
/// game being shown when H is pressed in that gap frame.
fn reset_hint_cycle_on_state_change(
mut state_events: EventReader<StateChangedEvent>,
mut new_game_events: EventReader<NewGameRequestEvent>,
mut hint_cycle: ResMut<HintCycleIndex>,
) {
if state_events.read().next().is_some() || new_game_events.read().next().is_some() {
hint_cycle.0 = 0;
}
}
/// `F11` toggles between borderless-fullscreen and windowed mode.
/// Not gated by the pause flag — the player can always resize the window.
fn handle_fullscreen(
keys: Res<ButtonInput<KeyCode>>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
mut toast: EventWriter<InfoToastEvent>,
) {
if !keys.just_pressed(KeyCode::F11) {
return;
}
let Ok(mut window) = windows.get_single_mut() else { return };
window.mode = match window.mode {
let new_mode = match window.mode {
WindowMode::Windowed => WindowMode::BorderlessFullscreen(MonitorSelection::Current),
_ => WindowMode::Windowed,
};
window.mode = new_mode;
let label = match window.mode {
WindowMode::Windowed => "Fullscreen: off",
_ => "Fullscreen: on",
};
toast.send(InfoToastEvent(label.to_string()));
}
fn handle_stock_click(
@@ -239,7 +370,7 @@ fn start_drag(
layout: Option<Res<LayoutResource>>,
game: Res<GameStateResource>,
mut drag: ResMut<DragState>,
mut card_transforms: Query<(&CardEntity, &mut Transform)>,
mut card_visuals: Query<(&CardEntity, &mut Transform, &mut Sprite)>,
) {
if paused.is_some_and(|p| p.0) {
return;
@@ -268,13 +399,15 @@ fn start_drag(
let bottom_pos = card_position(&game.0, &layout.0, pile.clone(), stack_index);
let cursor_offset = bottom_pos - world;
// Elevate dragged cards to DRAG_Z.
// Elevate dragged cards to DRAG_Z and dim them slightly so the board
// beneath remains visible during the drag.
for (i, id) in card_ids.iter().enumerate() {
if let Some((_, mut transform)) = card_transforms
if let Some((_, mut transform, mut sprite)) = card_visuals
.iter_mut()
.find(|(entity, _)| entity.card_id == *id)
.find(|(entity, _, _)| entity.card_id == *id)
{
transform.translation.z = DRAG_Z + (i as f32) * 0.01;
sprite.color.set_alpha(0.85);
}
}
@@ -328,6 +461,8 @@ fn end_drag(
mut moves: EventWriter<MoveRequestEvent>,
mut rejected: EventWriter<MoveRejectedEvent>,
mut changed: EventWriter<StateChangedEvent>,
mut commands: Commands,
card_entities: Query<(Entity, &CardEntity, &Transform)>,
) {
if paused.is_some_and(|p| p.0) {
drag.clear();
@@ -385,6 +520,19 @@ fn end_drag(
to: target.clone(),
count,
});
// Shake each dragged card so the player gets immediate
// visual feedback that the drop was rejected.
for &card_id in &drag.cards {
if let Some((entity, _, transform)) = card_entities
.iter()
.find(|(_, ce, _)| ce.card_id == card_id)
{
commands.entity(entity).insert(ShakeAnim {
elapsed: 0.0,
origin_x: transform.translation.x,
});
}
}
}
}
}
@@ -604,8 +752,43 @@ pub fn best_destination(card: &Card, game: &GameState) -> Option<PileType> {
None
}
/// Find the best tableau column onto which the stack rooted at `bottom_card`
/// can be legally placed, excluding the stack's own source pile.
///
/// Returns `(destination, stack_count)` if a legal target exists, or `None`
/// if the stack cannot move anywhere. Only tableau destinations are considered
/// because multi-card stacks cannot go to foundations.
pub fn best_tableau_destination_for_stack(
bottom_card: &Card,
from: &PileType,
game: &GameState,
stack_count: usize,
) -> Option<(PileType, usize)> {
for i in 0..7_usize {
let dest = PileType::Tableau(i);
if dest == *from {
continue;
}
if let Some(pile) = game.piles.get(&dest) {
if can_place_on_tableau(bottom_card, pile) {
return Some((dest, stack_count));
}
}
}
None
}
/// System that detects double-clicks on face-up cards and fires `MoveRequestEvent`
/// to the best legal destination (foundation before tableau).
/// to the best legal destination.
///
/// Move priority:
/// 1. Move the single **top** card to its best foundation (or tableau) destination.
/// 2. If no single-card move exists and the clicked card is the base of a
/// multi-card face-up stack, move the whole stack to the best tableau column.
///
/// When a multi-card stack double-click finds no legal destination (Priority 2
/// returns `None`), fires `MoveRejectedEvent` with `from == to == pile` so the
/// invalid-move sound plays and the source pile cards shake as feedback.
#[allow(clippy::too_many_arguments)]
fn handle_double_click(
buttons: Res<ButtonInput<MouseButton>>,
@@ -618,6 +801,7 @@ fn handle_double_click(
game: Res<GameStateResource>,
mut last_click: Local<HashMap<u32, f32>>,
mut moves: EventWriter<MoveRequestEvent>,
mut rejected: EventWriter<MoveRejectedEvent>,
) {
if paused.is_some_and(|p| p.0) {
return;
@@ -628,18 +812,17 @@ fn handle_double_click(
let Some(layout) = layout else { return };
let Some(world) = cursor_world(&windows, &cameras) else { return };
// Identify which card was clicked (must be face-up and draggable).
// Identify which card (or stack base) was clicked (must be face-up and draggable).
let Some((pile, stack_index, card_ids)) = find_draggable_at(world, &game.0, &layout.0) else {
return;
};
// Only auto-move a single card (top card of the stack).
let Some(&top_card_id) = card_ids.last() else { return };
// The top draggable card is at `stack_index + card_ids.len() - 1`.
let top_index = stack_index + card_ids.len() - 1;
let Some(card) = game.0.piles.get(&pile)
.and_then(|p| p.cards.get(top_index)) else { return };
if !card.face_up || card.id != top_card_id {
// The topmost card in the draggable run — used as the double-click key.
let Some(&top_card_id) = card_ids.last() else { return };
let top_index = stack_index + card_ids.len() - 1;
let Some(top_card) = game.0.piles.get(&pile)
.and_then(|p| p.cards.get(top_index)) else { return };
if !top_card.face_up || top_card.id != top_card_id {
return;
}
@@ -647,14 +830,47 @@ fn handle_double_click(
let prev = last_click.get(&top_card_id).copied().unwrap_or(f32::NEG_INFINITY);
if now - prev <= DOUBLE_CLICK_WINDOW {
// Double-click detected — find and fire the best move.
// Double-click confirmed.
last_click.remove(&top_card_id);
if let Some(dest) = best_destination(card, &game.0) {
// Priority 1: move the single top card (foundation preferred, then tableau).
if let Some(dest) = best_destination(top_card, &game.0) {
moves.send(MoveRequestEvent {
from: pile,
to: dest,
count: 1,
});
return;
}
// Priority 2: if the player clicked the base of a multi-card face-up
// stack (card_ids.len() > 1), try moving the whole stack to another
// tableau column.
if card_ids.len() > 1 {
let Some(bottom_card) = game.0.piles.get(&pile)
.and_then(|p| p.cards.get(stack_index)) else { return };
if let Some((dest, count)) = best_tableau_destination_for_stack(
bottom_card,
&pile,
&game.0,
card_ids.len(),
) {
moves.send(MoveRequestEvent {
from: pile,
to: dest,
count,
});
} else {
// No legal destination for the stack — play the invalid-move
// sound and shake the source pile cards as feedback.
// `MoveRejectedEvent` with `from == to` routes the shake to
// the source pile (which `start_shake_anim` reads from `ev.to`).
rejected.send(MoveRejectedEvent {
from: pile.clone(),
to: pile,
count: card_ids.len(),
});
}
}
} else {
// Single click — record the time.
@@ -666,12 +882,19 @@ fn handle_double_click(
// Task #28 — Hint system helpers
// ---------------------------------------------------------------------------
/// Find one valid move in the current game state.
/// Build the complete list of legal moves available in `game`, ordered so that
/// foundation moves come first, then tableau-to-tableau moves, with "draw from
/// stock" appended last when the stock is non-empty and nothing else is
/// available.
///
/// Returns `(from, to, count)` for the first legal move found, or `None` if
/// no move is available. Sources checked: Waste top, then Tableau 06.
/// Destinations checked: all 4 Foundations, then all 7 Tableau piles.
pub fn find_hint(game: &GameState) -> Option<(PileType, PileType, usize)> {
/// Each entry is `(from, to, count)` the same triple used by
/// [`MoveRequestEvent`]. The list may be empty when no move exists at all
/// (game is stuck).
///
/// This is the backing data for the cycling hint system: the H key steps
/// through `hints[HintCycleIndex % hints.len()]` on each press.
pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let sources: Vec<PileType> = {
let mut s = vec![PileType::Waste];
for i in 0..7_usize {
@@ -680,23 +903,38 @@ pub fn find_hint(game: &GameState) -> Option<(PileType, PileType, usize)> {
s
};
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let mut hints: Vec<(PileType, PileType, usize)> = Vec::new();
// Pass 1 — foundation moves (highest priority, shown first).
for from in &sources {
let Some(from_pile) = game.piles.get(from) else { continue };
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
// Check foundations.
for &suit in &suits {
let dest = PileType::Foundation(suit);
if let Some(dest_pile) = game.piles.get(&dest) {
if can_place_on_foundation(card, dest_pile, suit) {
return Some((from.clone(), dest, 1));
hints.push((from.clone(), dest, 1));
// Each source card can go to at most one foundation suit;
// no need to check the remaining three for this card.
break;
}
}
}
}
// Check tableau piles (skip the source pile itself).
// Pass 2 — tableau moves (deduplicated by source pile so we don't
// repeat the same source card multiple times for different destinations).
for from in &sources {
let Some(from_pile) = game.piles.get(from) else { continue };
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
// Skip if this source already has a foundation hint — prefer to show
// that one when cycling rather than suggesting a less optimal move.
let already_has_foundation_hint = hints.iter().any(|(f, t, _)| {
f == from && matches!(t, PileType::Foundation(_))
});
if already_has_foundation_hint {
continue;
}
for i in 0..7_usize {
let dest = PileType::Tableau(i);
if dest == *from {
@@ -704,12 +942,41 @@ pub fn find_hint(game: &GameState) -> Option<(PileType, PileType, usize)> {
}
if let Some(dest_pile) = game.piles.get(&dest) {
if can_place_on_tableau(card, dest_pile) {
return Some((from.clone(), dest, 1));
hints.push((from.clone(), dest, 1));
// One tableau destination per source card is enough for the
// hint list — the player can see where else a card can go
// via the right-click destination highlights.
break;
}
}
}
}
None
// Pass 3 — suggest drawing from the stock when no other hint was found.
if hints.is_empty() {
let stock_non_empty = game.piles.get(&PileType::Stock)
.is_some_and(|p| !p.cards.is_empty());
let waste_can_recycle = game.piles.get(&PileType::Stock)
.is_some_and(|p| p.cards.is_empty())
&& game.piles.get(&PileType::Waste)
.is_some_and(|p| !p.cards.is_empty());
if stock_non_empty || waste_can_recycle {
// Stock→Waste is not a real pile-to-pile move, but we reuse the
// triple to signal "draw". The H handler only reads `from` to
// locate the card to highlight; we point at the stock pile.
hints.push((PileType::Stock, PileType::Waste, 1));
}
}
hints
}
/// Find one valid move in the current game state.
///
/// Returns `(from, to, count)` for the first legal move found, or `None` if
/// no move is available. This is a convenience wrapper over [`all_hints`].
pub fn find_hint(game: &GameState) -> Option<(PileType, PileType, usize)> {
all_hints(game).into_iter().next()
}
#[cfg(test)]
@@ -999,6 +1266,102 @@ mod tests {
assert!(best_destination(&card, &game).is_none());
}
// -----------------------------------------------------------------------
// best_tableau_destination_for_stack pure-function tests
// -----------------------------------------------------------------------
#[test]
fn best_tableau_destination_for_stack_finds_legal_column() {
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
// Clear all piles for a clean test.
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
// Tableau 0: King of Spades (the source stack base), Queen of Hearts on top.
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.push(Card { id: 100, suit: Suit::Spades, rank: Rank::King, face_up: true });
t0.cards.push(Card { id: 101, suit: Suit::Hearts, rank: Rank::Queen, face_up: true });
// Tableau 1..6: empty — Kings can land on any of them.
let bottom_card = Card { id: 100, suit: Suit::Spades, rank: Rank::King, face_up: true };
let result = best_tableau_destination_for_stack(
&bottom_card,
&PileType::Tableau(0),
&game,
2,
);
assert!(result.is_some(), "should find a destination for King-stack");
let (dest, count) = result.unwrap();
assert!(matches!(dest, PileType::Tableau(_)));
assert_ne!(dest, PileType::Tableau(0), "must not return the source pile");
assert_eq!(count, 2);
}
#[test]
fn best_tableau_destination_for_stack_skips_source_pile() {
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
// Only tableau 0 has anything; every other column is empty.
// A King is the only card that can go on an empty tableau column.
// Source is Tableau(0), so the result must NOT be Tableau(0).
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.push(Card { id: 200, suit: Suit::Hearts, rank: Rank::King, face_up: true });
let bottom_card = Card { id: 200, suit: Suit::Hearts, rank: Rank::King, face_up: true };
let result = best_tableau_destination_for_stack(
&bottom_card,
&PileType::Tableau(0),
&game,
1,
);
// Result must be some other empty tableau column, never the source.
if let Some((dest, _)) = result {
assert_ne!(dest, PileType::Tableau(0));
}
}
#[test]
fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
game.piles.get_mut(&PileType::Foundation(suit)).unwrap().cards.clear();
}
for i in 0..7_usize {
game.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
// Source: tableau 0 has a Two of Clubs (can't go on empty pile; not a King).
// All other piles are empty — no legal tableau target.
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.push(Card { id: 300, suit: Suit::Clubs, rank: Rank::Two, face_up: true });
let bottom_card = Card { id: 300, suit: Suit::Clubs, rank: Rank::Two, face_up: true };
let result = best_tableau_destination_for_stack(
&bottom_card,
&PileType::Tableau(0),
&game,
1,
);
assert!(result.is_none(), "Two of Clubs has no legal tableau destination on empty piles");
}
// -----------------------------------------------------------------------
// Task #28 — find_hint pure-function tests
// -----------------------------------------------------------------------
@@ -1047,6 +1410,201 @@ mod tests {
assert!(find_hint(&game).is_none(), "no hint should exist");
}
// -----------------------------------------------------------------------
// Task #54 — forfeit double-confirm logic pure-function tests
// -----------------------------------------------------------------------
/// Verify the FORFEIT_CONFIRM_WINDOW constant is positive so the countdown
/// window actually opens on the first G press.
#[test]
fn forfeit_confirm_window_is_positive() {
assert!(FORFEIT_CONFIRM_WINDOW > 0.0, "FORFEIT_CONFIRM_WINDOW must be > 0");
}
/// Simulate the first G press: countdown was 0, so it should become
/// FORFEIT_CONFIRM_WINDOW and no ForfeitEvent should be "sent" yet.
#[test]
fn forfeit_first_press_opens_countdown() {
// Simulate: forfeit_countdown starts at 0 (no pending confirmation).
let mut forfeit_countdown: f32 = 0.0;
let active_game = true;
// --- first G press logic (mirrors handle_keyboard) ---
let forfeit_sent = if active_game {
if forfeit_countdown > 0.0 {
// Second press — would send ForfeitEvent.
forfeit_countdown = 0.0;
true
} else {
// First press — open window, send toast (not ForfeitEvent).
forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
false
}
} else {
false
};
assert!(!forfeit_sent, "ForfeitEvent must NOT fire on first G press");
assert_eq!(
forfeit_countdown, FORFEIT_CONFIRM_WINDOW,
"countdown must be opened to FORFEIT_CONFIRM_WINDOW after first press"
);
}
/// Simulate the second G press within the window: countdown > 0, so
/// ForfeitEvent should fire and countdown resets to 0.
#[test]
fn forfeit_second_press_within_window_sends_event() {
// Countdown is open from the first press.
let mut forfeit_countdown: f32 = FORFEIT_CONFIRM_WINDOW - 1.0; // still in window
let active_game = true;
// --- second G press logic ---
let forfeit_sent = if active_game {
if forfeit_countdown > 0.0 {
forfeit_countdown = 0.0;
true
} else {
forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
false
}
} else {
false
};
assert!(forfeit_sent, "ForfeitEvent MUST fire on second G press within window");
assert_eq!(forfeit_countdown, 0.0, "countdown must reset to 0 after confirmation");
}
/// Simulate G press after the countdown has expired: countdown ticked to 0,
/// so the next G press opens a fresh window (no ForfeitEvent).
#[test]
fn forfeit_press_after_countdown_expired_reopens_window() {
// Countdown already expired.
let mut forfeit_countdown: f32 = 0.0;
let active_game = true;
let forfeit_sent = if active_game {
if forfeit_countdown > 0.0 {
forfeit_countdown = 0.0;
true
} else {
forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
false
}
} else {
false
};
assert!(!forfeit_sent, "ForfeitEvent must NOT fire when countdown expired before second press");
assert_eq!(
forfeit_countdown, FORFEIT_CONFIRM_WINDOW,
"a new confirmation window must open"
);
}
/// Pressing any other key (e.g. U for undo) while the forfeit countdown is
/// active must immediately cancel it (reset to 0.0).
#[test]
fn forfeit_cancelled_by_other_key_press() {
// Countdown is open from the first G press.
let mut forfeit_countdown: f32 = FORFEIT_CONFIRM_WINDOW - 0.5; // still in window
// --- simulate U (undo) press: cancel countdown ---
if forfeit_countdown > 0.0 {
forfeit_countdown = 0.0;
}
// Then perform undo logic (omitted here as it requires Bevy infrastructure).
assert_eq!(
forfeit_countdown, 0.0,
"forfeit countdown must be reset to 0.0 when another key is pressed"
);
}
/// G press when no game is active must never fire ForfeitEvent and must
/// not open a countdown.
#[test]
fn forfeit_no_active_game_does_nothing() {
let mut forfeit_countdown: f32 = 0.0;
let active_game = false;
let forfeit_sent = if active_game {
if forfeit_countdown > 0.0 {
forfeit_countdown = 0.0;
true
} else {
forfeit_countdown = FORFEIT_CONFIRM_WINDOW;
false
}
} else {
false
};
assert!(!forfeit_sent, "ForfeitEvent must not fire when no game is active");
assert_eq!(forfeit_countdown, 0.0, "countdown must remain 0 when no game is active");
}
// -----------------------------------------------------------------------
// Task #57 — ShakeAnim insertion on rejected drag
// -----------------------------------------------------------------------
/// Verifies that `ShakeAnim` constructed for a rejected drag has the
/// correct initial values: `elapsed` starts at 0.0 and `origin_x` matches
/// the card's current transform X position.
///
/// The Bevy ECS part (Commands + Query) is exercised at runtime; this test
/// covers the data path — that we build the component with the right values
/// before handing it to `commands.entity(...).insert(...)`.
#[test]
fn shake_anim_for_rejected_drag_has_correct_initial_values() {
use crate::feedback_anim_plugin::ShakeAnim;
// Simulate the transform X that a dragged card would have at the
// moment the drag is released (could be anywhere on screen).
let current_x = 123.5_f32;
// This mirrors the ShakeAnim construction in `end_drag`.
let anim = ShakeAnim {
elapsed: 0.0,
origin_x: current_x,
};
assert_eq!(
anim.elapsed, 0.0,
"ShakeAnim must start with elapsed=0.0 so the animation plays from the beginning"
);
assert!(
(anim.origin_x - current_x).abs() < 1e-6,
"ShakeAnim origin_x must match the card's transform X at drop time, \
expected {current_x}, got {}",
anim.origin_x
);
}
/// When a drag is rejected, every card id in `drag.cards` should receive a
/// `ShakeAnim`. Verify that the set of card ids we would iterate matches
/// exactly the ids stored in `DragState::cards` at rejection time.
#[test]
fn rejected_drag_shakes_all_dragged_cards() {
// Simulate a DragState with two card ids (a stack drag).
let dragged_ids: Vec<u32> = vec![10, 11];
// In `end_drag`, we iterate `drag.cards` and look up each id in
// `card_entities`. The ids we would insert ShakeAnim on must exactly
// match the dragged set.
let mut shaken: Vec<u32> = Vec::new();
for &card_id in &dragged_ids {
// Simulate finding the entity for card_id (always succeeds here).
shaken.push(card_id);
}
assert_eq!(
shaken, dragged_ids,
"every card id in drag.cards must receive a ShakeAnim on rejection"
);
}
}
// `Vec3` is referenced only via the `DRAG_Z` constant; keep the import silenced