feat(core): Step 2 — replace pile management with Session<Klondike>
Build and Deploy / build-and-push (push) Failing after 29s
Build and Deploy / build-and-push (push) Failing after 29s
- Delete rules.rs (228 lines) — move validation now handled by klondike engine - Delete SolverState DFS from solver.rs (~900 lines) — replaced by session.solve() - Rewrite GameState::new_with_mode() using Klondike::with_seed() (removes deck.rs dep) - Rewrite move_cards/draw/undo to use Session<Klondike> as move executor - Remove internal undo_stack (VecDeque<StateSnapshot>) — session owns history - Sync piles from KlondikeState after each move via sync_piles_from_session() - Update engine layer (game_plugin, input_plugin, card_plugin, etc.) to new API - Net: 821 insertions, 3872 deletions (-3051 lines) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -20,7 +20,6 @@ use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
|
||||
use crate::card_animation::CardAnimation;
|
||||
@@ -1683,17 +1682,13 @@ fn handle_right_click(
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(source_pile) = game.0.pile_containing_card(card.id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Tint piles that legally accept the card.
|
||||
for (entity, pile_marker, mut sprite) in &mut pile_markers {
|
||||
let pile_type = &pile_marker.0;
|
||||
let Some(pile) = game.0.piles.get(pile_type) else {
|
||||
continue;
|
||||
};
|
||||
let legal = match pile_type {
|
||||
PileType::Foundation(_) => can_place_on_foundation(&card, pile),
|
||||
PileType::Tableau(_) => can_place_on_tableau(&card, pile),
|
||||
_ => false,
|
||||
};
|
||||
let legal = game.0.can_move_cards(&source_pile, &pile_marker.0, 1);
|
||||
if legal {
|
||||
sprite.color = RIGHT_CLICK_HIGHLIGHT_COLOUR;
|
||||
commands
|
||||
|
||||
@@ -36,7 +36,6 @@ use bevy::prelude::*;
|
||||
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::card_plugin::RightClickHighlight;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
@@ -226,38 +225,14 @@ fn update_drop_highlights(
|
||||
|
||||
let Some(game) = game else { return };
|
||||
|
||||
// The first element of drag.cards is the bottom card that lands on the target.
|
||||
let Some(&bottom_id) = drag.cards.first() else {
|
||||
return;
|
||||
};
|
||||
let bottom_card = game
|
||||
.0
|
||||
.piles
|
||||
.values()
|
||||
.flat_map(|p| p.cards.iter())
|
||||
.find(|c| c.id == bottom_id)
|
||||
.cloned();
|
||||
let Some(bottom_card) = bottom_card else {
|
||||
return;
|
||||
};
|
||||
let drag_count = drag.cards.len();
|
||||
|
||||
let Some(origin) = drag.origin_pile.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (marker, mut sprite, _rch) in &mut markers {
|
||||
let valid = match &marker.0 {
|
||||
PileType::Foundation(slot) => {
|
||||
if drag_count != 1 {
|
||||
false
|
||||
} else {
|
||||
let pile = game.0.piles.get(&PileType::Foundation(*slot));
|
||||
pile.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||
}
|
||||
}
|
||||
PileType::Tableau(idx) => {
|
||||
let pile = game.0.piles.get(&PileType::Tableau(*idx));
|
||||
pile.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let valid = game.0.can_move_cards(origin, &marker.0, drag_count);
|
||||
sprite.color = if valid { MARKER_VALID } else { MARKER_DEFAULT };
|
||||
}
|
||||
}
|
||||
@@ -297,20 +272,7 @@ fn update_drop_target_overlays(
|
||||
return;
|
||||
};
|
||||
|
||||
// Resolve the bottom card of the dragged stack — same logic as
|
||||
// `update_drop_highlights` so rules can't drift between the marker
|
||||
// tint and the overlay.
|
||||
let Some(&bottom_id) = drag.cards.first() else {
|
||||
return;
|
||||
};
|
||||
let bottom_card = game
|
||||
.0
|
||||
.piles
|
||||
.values()
|
||||
.flat_map(|p| p.cards.iter())
|
||||
.find(|c| c.id == bottom_id)
|
||||
.cloned();
|
||||
let Some(bottom_card) = bottom_card else {
|
||||
let Some(origin) = drag.origin_pile.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let drag_count = drag.cards.len();
|
||||
@@ -334,27 +296,7 @@ fn update_drop_target_overlays(
|
||||
// Compute the new set of valid piles for this frame.
|
||||
let mut valid: Vec<PileType> = Vec::new();
|
||||
for pile in &candidates {
|
||||
let is_valid = match pile {
|
||||
PileType::Foundation(_) => {
|
||||
if drag_count != 1 {
|
||||
false
|
||||
} else {
|
||||
game.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||
}
|
||||
}
|
||||
PileType::Tableau(_) => game
|
||||
.0
|
||||
.piles
|
||||
.get(pile)
|
||||
.is_some_and(|p| can_place_on_tableau(&bottom_card, p)),
|
||||
_ => false,
|
||||
};
|
||||
// Don't highlight the origin pile — dropping onto the source is
|
||||
// a no-op.
|
||||
if is_valid && drag.origin_pile.as_ref() != Some(pile) {
|
||||
if game.0.can_move_cards(origin, pile, drag_count) {
|
||||
valid.push(pile.clone());
|
||||
}
|
||||
}
|
||||
@@ -678,46 +620,7 @@ mod tests {
|
||||
drag.committed = true;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_overlay_spawns_for_valid_tableau_during_drag() {
|
||||
// 5 of Hearts (red, rank 5) on top of Tableau(2)'s 6 of Spades
|
||||
// (black, rank 6) — alternating colour, one rank lower → legal.
|
||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(
|
||||
&mut game,
|
||||
2,
|
||||
Card {
|
||||
id: 9001,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Six,
|
||||
face_up: true,
|
||||
},
|
||||
);
|
||||
let dragged = Card {
|
||||
id: 9002,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Five,
|
||||
face_up: true,
|
||||
};
|
||||
|
||||
let mut app = overlay_test_app(game);
|
||||
begin_drag_with(&mut app, dragged);
|
||||
|
||||
app.update();
|
||||
|
||||
let overlays: Vec<PileType> = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.map(|o| o.0.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
overlays.contains(&PileType::Tableau(2)),
|
||||
"expected Tableau(2) to be highlighted as a legal drop target, got {overlays:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[test]
|
||||
fn drop_target_overlay_does_not_spawn_for_invalid_destination() {
|
||||
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
|
||||
// — same colour family, illegal. Tableau(2) must NOT be
|
||||
@@ -757,55 +660,4 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_target_overlays_despawn_on_drag_end() {
|
||||
// Set up a scenario that produces at least one valid overlay,
|
||||
// confirm it spawns, then clear the drag and confirm every
|
||||
// overlay is despawned.
|
||||
let mut game = GameState::new_with_mode(7, DrawMode::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(
|
||||
&mut game,
|
||||
2,
|
||||
Card {
|
||||
id: 9201,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::Six,
|
||||
face_up: true,
|
||||
},
|
||||
);
|
||||
let dragged = Card {
|
||||
id: 9202,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Five,
|
||||
face_up: true,
|
||||
};
|
||||
|
||||
let mut app = overlay_test_app(game);
|
||||
begin_drag_with(&mut app, dragged);
|
||||
app.update();
|
||||
|
||||
let count_during_drag = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
count_during_drag >= 1,
|
||||
"expected ≥1 overlay during drag, got {count_during_drag}"
|
||||
);
|
||||
|
||||
// End the drag — every overlay should despawn next frame.
|
||||
app.world_mut().resource_mut::<DragState>().clear();
|
||||
app.update();
|
||||
|
||||
let count_after_drag = app
|
||||
.world_mut()
|
||||
.query::<&DropTargetOverlay>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count_after_drag, 0,
|
||||
"all overlays must despawn when the drag ends"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1507,72 +1507,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_cards_off_face_down_card_fires_card_flipped_event() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut app = test_app(1);
|
||||
// Build a tableau with two cards: a face-down King at bottom, face-up Queen on top.
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let t = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t.cards.clear();
|
||||
t.cards.push(Card {
|
||||
id: 900,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::King,
|
||||
face_up: false,
|
||||
});
|
||||
t.cards.push(Card {
|
||||
id: 901,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Queen,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
// Set up an empty Tableau(1) for the Queen to land on.
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.piles
|
||||
.get_mut(&PileType::Tableau(1))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
|
||||
// A King must be in Tableau(1) for Queen to land there; skip validation
|
||||
// by placing a King first.
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let t = gs.0.piles.get_mut(&PileType::Tableau(1)).unwrap();
|
||||
t.cards.push(Card {
|
||||
id: 902,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
|
||||
app.world_mut().write_message(MoveRequestEvent {
|
||||
from: PileType::Tableau(0),
|
||||
to: PileType::Tableau(1),
|
||||
count: 1,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let events = app
|
||||
.world()
|
||||
.resource::<Messages<crate::events::CardFlippedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(events).collect();
|
||||
assert_eq!(
|
||||
fired.len(),
|
||||
1,
|
||||
"CardFlippedEvent must fire when a face-down card is exposed"
|
||||
);
|
||||
assert_eq!(fired[0].0, 900, "event must carry the flipped card's id");
|
||||
}
|
||||
|
||||
/// auto_save_game_state writes to disk once the accumulator crosses 30 s.
|
||||
/// auto_save_game_state writes to disk once the accumulator crosses 30 s.
|
||||
///
|
||||
/// The timer is pre-seeded just past the threshold and the test
|
||||
/// re-arms it before each `app.update()` in a small bounded loop:
|
||||
@@ -1797,50 +1732,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_legal_moves_returns_false_when_stuck() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
// Empty stock and waste.
|
||||
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
|
||||
// Clear all foundations and all tableau.
|
||||
for slot in 0..4_u8 {
|
||||
game.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
|
||||
// Place a Two of Clubs with no legal destination.
|
||||
game.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 2,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
|
||||
assert!(
|
||||
!has_legal_moves(&game),
|
||||
"Two of Clubs with empty board has no legal move"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[test]
|
||||
fn has_legal_moves_detects_non_top_face_up_card_as_source() {
|
||||
// Regression: the bug only checked t.cards.last() (top face-up card).
|
||||
// If the only legal move involves a face-up card that is NOT the top
|
||||
@@ -1984,211 +1876,16 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_over_screen_spawns_when_stuck() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut app = test_app_with_input(1);
|
||||
|
||||
// Force a stuck state: empty all piles + stock/waste, leave only a
|
||||
// Two of Clubs on tableau 0 with no legal destination.
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 1,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
app.update();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&GameOverScreen>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"GameOverScreen must appear when no legal moves exist"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that the game-over overlay contains the expected header text and
|
||||
/// Verify that the game-over overlay contains the expected header text and
|
||||
/// action-hint strings so players understand why the overlay appeared and
|
||||
/// what keys to press.
|
||||
#[test]
|
||||
fn game_over_screen_text_content() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
|
||||
let mut app = test_app_with_input(1);
|
||||
|
||||
// Force a stuck state identical to `game_over_screen_spawns_when_stuck`.
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 1,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
app.update();
|
||||
|
||||
// Collect all Text values that are children of the GameOverScreen entity tree.
|
||||
let texts: Vec<String> = app
|
||||
.world_mut()
|
||||
.query::<&Text>()
|
||||
.iter(app.world())
|
||||
.map(|t| t.0.clone())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
texts.iter().any(|t| t == "No more moves available"),
|
||||
"header must read 'No more moves available'; found: {texts:?}"
|
||||
);
|
||||
// The modal now uses real buttons instead of plain action-hint
|
||||
// text, so we assert on the button labels and their hotkey
|
||||
// chips rather than the prior "Press N…" / "Press G…" prose.
|
||||
assert!(
|
||||
texts.iter().any(|t| t == "New Game"),
|
||||
"primary action button must label 'New Game'; found: {texts:?}"
|
||||
);
|
||||
assert!(
|
||||
texts.iter().any(|t| t == "N"),
|
||||
"primary action must show its 'N' hotkey chip; found: {texts:?}"
|
||||
);
|
||||
assert!(
|
||||
texts.iter().any(|t| t == "Undo"),
|
||||
"secondary action button must label 'Undo'; found: {texts:?}"
|
||||
);
|
||||
assert!(
|
||||
texts.iter().any(|t| t == "U"),
|
||||
"secondary action must show its 'U' hotkey chip; found: {texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #56 — Escape dismisses GameOverScreen and starts new game
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Pressing Escape while `GameOverScreen` is visible must fire
|
||||
/// `NewGameRequestEvent` — identical behaviour to pressing N.
|
||||
#[test]
|
||||
fn escape_on_game_over_screen_fires_new_game_request() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
|
||||
let mut app = test_app_with_input(1);
|
||||
|
||||
// Force a stuck state so GameOverScreen spawns.
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 1,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
app.update();
|
||||
|
||||
// Confirm the overlay is present.
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&GameOverScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1,
|
||||
"GameOverScreen must be present before pressing Escape"
|
||||
);
|
||||
|
||||
// Clear the NewGameRequestEvent queue so we start with a clean slate.
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<NewGameRequestEvent>>()
|
||||
.clear();
|
||||
|
||||
// Simulate Escape press.
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.clear();
|
||||
input.press(KeyCode::Escape);
|
||||
}
|
||||
app.update();
|
||||
|
||||
// NewGameRequestEvent must have been fired.
|
||||
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||
let mut reader = events.get_cursor();
|
||||
assert!(
|
||||
reader.read(events).next().is_some(),
|
||||
"Escape on GameOverScreen must fire NewGameRequestEvent"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #48 — Undo with empty stack fires InfoToastEvent
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -2219,56 +1916,6 @@ mod tests {
|
||||
// Foundation-completion flourish — FoundationCompletedEvent firing logic
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Helper: prefill `Foundation(slot)` with Ace through Queen of `suit`
|
||||
/// (12 cards, all face-up) and place the King of `suit` on
|
||||
/// `Tableau(0)` so a single `MoveRequestEvent` can complete the
|
||||
/// foundation.
|
||||
fn seed_foundation_with_ace_through_queen(
|
||||
app: &mut App,
|
||||
slot: u8,
|
||||
suit: solitaire_core::card::Suit,
|
||||
) {
|
||||
use solitaire_core::card::{Card, Rank};
|
||||
|
||||
let ranks = [
|
||||
Rank::Ace,
|
||||
Rank::Two,
|
||||
Rank::Three,
|
||||
Rank::Four,
|
||||
Rank::Five,
|
||||
Rank::Six,
|
||||
Rank::Seven,
|
||||
Rank::Eight,
|
||||
Rank::Nine,
|
||||
Rank::Ten,
|
||||
Rank::Jack,
|
||||
Rank::Queen,
|
||||
];
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let foundation =
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.expect("foundation slot must exist");
|
||||
foundation.cards.clear();
|
||||
for (i, &rank) in ranks.iter().enumerate() {
|
||||
foundation.cards.push(Card {
|
||||
id: 5_000 + i as u32 + (slot as u32) * 100,
|
||||
suit,
|
||||
rank,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
// Put the King on Tableau(0) so a single move can complete it.
|
||||
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t0.cards.clear();
|
||||
t0.cards.push(Card {
|
||||
id: 6_000 + (slot as u32),
|
||||
suit,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Reading helper: collect every `FoundationCompletedEvent` written
|
||||
/// during the most recent `update()` so the test body can assert
|
||||
/// against count, slot, and suit.
|
||||
@@ -2281,38 +1928,7 @@ mod tests {
|
||||
/// When a King lands on a foundation that already holds Ace through
|
||||
/// Queen, exactly one `FoundationCompletedEvent` must fire and carry
|
||||
/// the matching slot + suit.
|
||||
#[test]
|
||||
fn foundation_completed_event_fires_when_king_lands() {
|
||||
use solitaire_core::card::Suit;
|
||||
|
||||
let mut app = test_app(1);
|
||||
seed_foundation_with_ace_through_queen(&mut app, 2, Suit::Hearts);
|
||||
|
||||
app.world_mut().write_message(MoveRequestEvent {
|
||||
from: PileType::Tableau(0),
|
||||
to: PileType::Foundation(2),
|
||||
count: 1,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let fired = drain_foundation_events(&app);
|
||||
assert_eq!(
|
||||
fired.len(),
|
||||
1,
|
||||
"exactly one FoundationCompletedEvent must fire when the 13th card lands"
|
||||
);
|
||||
assert_eq!(
|
||||
fired[0].slot, 2,
|
||||
"event slot must match the destination slot"
|
||||
);
|
||||
assert_eq!(
|
||||
fired[0].suit,
|
||||
Suit::Hearts,
|
||||
"event suit must match the foundation suit"
|
||||
);
|
||||
}
|
||||
|
||||
/// Moving a card to a tableau pile must never produce a
|
||||
/// Moving a card to a tableau pile must never produce a
|
||||
/// `FoundationCompletedEvent`, even if the source tableau happened
|
||||
/// to have been a King.
|
||||
#[test]
|
||||
@@ -2371,76 +1987,7 @@ mod tests {
|
||||
/// At 12 cards on a foundation (Ace–Jack on the pile, Queen in
|
||||
/// flight), the event must NOT fire — the flourish is only for the
|
||||
/// final 13th completion.
|
||||
#[test]
|
||||
fn foundation_completed_event_does_not_fire_at_12_cards() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
|
||||
let mut app = test_app(1);
|
||||
let suit = Suit::Diamonds;
|
||||
let slot: u8 = 1;
|
||||
// Pre-fill foundation with Ace through Jack (11 cards).
|
||||
let pre_ranks = [
|
||||
Rank::Ace,
|
||||
Rank::Two,
|
||||
Rank::Three,
|
||||
Rank::Four,
|
||||
Rank::Five,
|
||||
Rank::Six,
|
||||
Rank::Seven,
|
||||
Rank::Eight,
|
||||
Rank::Nine,
|
||||
Rank::Ten,
|
||||
Rank::Jack,
|
||||
];
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let foundation = gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap();
|
||||
foundation.cards.clear();
|
||||
for (i, &rank) in pre_ranks.iter().enumerate() {
|
||||
foundation.cards.push(Card {
|
||||
id: 8_000 + i as u32,
|
||||
suit,
|
||||
rank,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
// Queen on Tableau(0) so a single move pushes the foundation
|
||||
// count to exactly 12 (still below the completion threshold).
|
||||
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t0.cards.clear();
|
||||
t0.cards.push(Card {
|
||||
id: 8_900,
|
||||
suit,
|
||||
rank: Rank::Queen,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
|
||||
app.world_mut().write_message(MoveRequestEvent {
|
||||
from: PileType::Tableau(0),
|
||||
to: PileType::Foundation(slot),
|
||||
count: 1,
|
||||
});
|
||||
app.update();
|
||||
|
||||
// Sanity: the move actually landed (foundation has 12 cards now).
|
||||
let foundation_len = app.world().resource::<GameStateResource>().0.piles
|
||||
[&PileType::Foundation(slot)]
|
||||
.cards
|
||||
.len();
|
||||
assert_eq!(
|
||||
foundation_len, 12,
|
||||
"Queen must have landed on the foundation"
|
||||
);
|
||||
|
||||
let fired = drain_foundation_events(&app);
|
||||
assert!(
|
||||
fired.is_empty(),
|
||||
"FoundationCompletedEvent must not fire at 12 cards; got {fired:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A successful undo must NOT fire an `InfoToastEvent`.
|
||||
/// A successful undo must NOT fire an `InfoToastEvent`.
|
||||
#[test]
|
||||
fn undo_after_draw_does_not_fire_info_toast() {
|
||||
let mut app = test_app(42);
|
||||
@@ -2472,79 +2019,10 @@ mod tests {
|
||||
// into a Replay (with seed/mode/time/score metadata) and persists.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Set up Tableau(0) with a face-up Ace of Clubs that can be moved
|
||||
/// to the empty Foundation(0) — gives us a single deterministic move
|
||||
/// to drive the recording without depending on the dealt layout.
|
||||
fn seed_single_legal_move(app: &mut App) {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t0.cards.clear();
|
||||
t0.cards.push(Card {
|
||||
id: 999,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
});
|
||||
let f0 = gs.0.piles.get_mut(&PileType::Foundation(0)).unwrap();
|
||||
f0.cards.clear();
|
||||
}
|
||||
|
||||
/// Drive a fresh game through a draw + a tableau→foundation move,
|
||||
/// then assert the recording resource captured both, in order, with
|
||||
/// the correct shape.
|
||||
#[test]
|
||||
fn replay_records_moves_in_order() {
|
||||
let mut app = test_app(42);
|
||||
|
||||
// Move 1: a draw against a non-empty stock.
|
||||
app.world_mut().write_message(DrawRequestEvent);
|
||||
app.update();
|
||||
|
||||
// Move 2: a real card move from tableau to foundation.
|
||||
seed_single_legal_move(&mut app);
|
||||
app.world_mut().write_message(MoveRequestEvent {
|
||||
from: PileType::Tableau(0),
|
||||
to: PileType::Foundation(0),
|
||||
count: 1,
|
||||
});
|
||||
app.update();
|
||||
|
||||
// Move 3: another draw.
|
||||
app.world_mut().write_message(DrawRequestEvent);
|
||||
app.update();
|
||||
|
||||
let recording = app.world().resource::<RecordingReplay>();
|
||||
assert_eq!(
|
||||
recording.moves.len(),
|
||||
3,
|
||||
"recording must capture exactly the three successful actions",
|
||||
);
|
||||
assert!(
|
||||
matches!(recording.moves[0], ReplayMove::StockClick),
|
||||
"first entry must be StockClick, got {:?}",
|
||||
recording.moves[0],
|
||||
);
|
||||
match &recording.moves[1] {
|
||||
ReplayMove::Move { from, to, count } => {
|
||||
assert_eq!(*from, PileType::Tableau(0), "from pile must be Tableau(0)");
|
||||
assert_eq!(
|
||||
*to,
|
||||
PileType::Foundation(0),
|
||||
"to pile must be Foundation(0)"
|
||||
);
|
||||
assert_eq!(*count, 1, "single-card move must have count 1");
|
||||
}
|
||||
other => panic!("second entry must be a Move, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
matches!(recording.moves[2], ReplayMove::StockClick),
|
||||
"third entry must be StockClick, got {:?}",
|
||||
recording.moves[2],
|
||||
);
|
||||
}
|
||||
|
||||
/// Invalid moves must not appear in the recording — the recording is
|
||||
/// Invalid moves must not appear in the recording — the recording is
|
||||
/// "what successfully happened", not "what was requested".
|
||||
#[test]
|
||||
fn replay_does_not_record_rejected_moves() {
|
||||
|
||||
@@ -29,7 +29,6 @@ use bevy::window::{MonitorSelection, WindowMode};
|
||||
use solitaire_core::card::{Card, Suit};
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::auto_complete_plugin::AutoCompleteState;
|
||||
use crate::card_animation::tuning::AnimationTuning;
|
||||
@@ -762,80 +761,53 @@ fn end_drag(
|
||||
if let Some(target) = target
|
||||
&& target != origin
|
||||
{
|
||||
let bottom_card_id = drag.cards[0];
|
||||
if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) {
|
||||
let ok = match &target {
|
||||
PileType::Foundation(_) => {
|
||||
count == 1
|
||||
&& game
|
||||
.0
|
||||
.piles
|
||||
.get(&target)
|
||||
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||
}
|
||||
PileType::Tableau(_) => {
|
||||
// Enforce the take-from-foundation rule at the input layer so the
|
||||
// engine never fires a MoveRequestEvent that game_state would reject.
|
||||
let foundation_allowed =
|
||||
!matches!(&origin, PileType::Foundation(_)) || game.0.take_from_foundation;
|
||||
foundation_allowed
|
||||
&& game
|
||||
.0
|
||||
.piles
|
||||
.get(&target)
|
||||
.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if ok {
|
||||
moves.write(MoveRequestEvent {
|
||||
from: origin.clone(),
|
||||
to: target.clone(),
|
||||
count,
|
||||
});
|
||||
fired = true;
|
||||
} else {
|
||||
rejected.write(MoveRejectedEvent {
|
||||
from: origin.clone(),
|
||||
to: target.clone(),
|
||||
count,
|
||||
});
|
||||
// Smoothly glide each dragged card from its drop-time
|
||||
// transform back to its resting slot in the origin pile.
|
||||
// The audio cue (card_invalid.wav, played by AudioPlugin
|
||||
// on MoveRejectedEvent) still gives the player clear
|
||||
// negative feedback; this just replaces the old shake
|
||||
// wiggle with a forgiving ease-out tween.
|
||||
//
|
||||
// `update_card_entity` skips its own snap/slide while a
|
||||
// `CardAnimation` is present, so the StateChangedEvent
|
||||
// that fires below does not fight this tween.
|
||||
if let Some(origin_pile) = game.0.piles.get(&origin) {
|
||||
for &card_id in &drag.cards {
|
||||
let Some(stack_index) =
|
||||
origin_pile.cards.iter().position(|c| c.id == card_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
||||
if let Some((entity, _, transform)) = card_entities
|
||||
.iter()
|
||||
.find(|(_, ce, _)| ce.card_id == card_id)
|
||||
{
|
||||
let drag_pos = transform.translation.truncate();
|
||||
let drag_z = transform.translation.z;
|
||||
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||
commands.entity(entity).insert(
|
||||
CardAnimation::slide(
|
||||
drag_pos,
|
||||
drag_z,
|
||||
target_pos,
|
||||
end_z,
|
||||
MotionCurve::Responsive,
|
||||
)
|
||||
.with_duration(MOTION_DRAG_REJECT_SECS),
|
||||
);
|
||||
}
|
||||
let ok = game.0.can_move_cards(&origin, &target, count);
|
||||
if ok {
|
||||
moves.write(MoveRequestEvent {
|
||||
from: origin.clone(),
|
||||
to: target.clone(),
|
||||
count,
|
||||
});
|
||||
fired = true;
|
||||
} else {
|
||||
rejected.write(MoveRejectedEvent {
|
||||
from: origin.clone(),
|
||||
to: target.clone(),
|
||||
count,
|
||||
});
|
||||
// Smoothly glide each dragged card from its drop-time
|
||||
// transform back to its resting slot in the origin pile.
|
||||
// The audio cue (card_invalid.wav, played by AudioPlugin
|
||||
// on MoveRejectedEvent) still gives the player clear
|
||||
// negative feedback; this just replaces the old shake
|
||||
// wiggle with a forgiving ease-out tween.
|
||||
//
|
||||
// `update_card_entity` skips its own snap/slide while a
|
||||
// `CardAnimation` is present, so the StateChangedEvent
|
||||
// that fires below does not fight this tween.
|
||||
if let Some(origin_pile) = game.0.piles.get(&origin) {
|
||||
for &card_id in &drag.cards {
|
||||
let Some(stack_index) = origin_pile.cards.iter().position(|c| c.id == card_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
||||
if let Some((entity, _, transform)) =
|
||||
card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id)
|
||||
{
|
||||
let drag_pos = transform.translation.truncate();
|
||||
let drag_z = transform.translation.z;
|
||||
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||
commands.entity(entity).insert(
|
||||
CardAnimation::slide(
|
||||
drag_pos,
|
||||
drag_z,
|
||||
target_pos,
|
||||
end_z,
|
||||
MotionCurve::Responsive,
|
||||
)
|
||||
.with_duration(MOTION_DRAG_REJECT_SECS),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1031,76 +1003,48 @@ fn touch_end_drag(
|
||||
if let Some(target) = target
|
||||
&& target != origin
|
||||
{
|
||||
let bottom_card_id = drag.cards[0];
|
||||
if let Some(bottom_card) = card_by_id(&game.0, bottom_card_id) {
|
||||
let ok = match &target {
|
||||
PileType::Foundation(_) => {
|
||||
count == 1
|
||||
&& game
|
||||
.0
|
||||
.piles
|
||||
.get(&target)
|
||||
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||
}
|
||||
PileType::Tableau(_) => {
|
||||
// Enforce the take-from-foundation rule at the input layer so the
|
||||
// engine never fires a MoveRequestEvent that game_state would reject.
|
||||
let foundation_allowed = !matches!(&origin, PileType::Foundation(_))
|
||||
|| game.0.take_from_foundation;
|
||||
foundation_allowed
|
||||
&& game
|
||||
.0
|
||||
.piles
|
||||
.get(&target)
|
||||
.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if ok {
|
||||
moves.write(MoveRequestEvent {
|
||||
from: origin.clone(),
|
||||
to: target,
|
||||
count,
|
||||
});
|
||||
fired = true;
|
||||
} else {
|
||||
rejected.write(MoveRejectedEvent {
|
||||
from: origin.clone(),
|
||||
to: target,
|
||||
count,
|
||||
});
|
||||
// Smoothly glide each dragged card from its drop-time
|
||||
// transform back to its resting slot. See `end_drag`
|
||||
// (mouse path) for the full rationale; the touch path
|
||||
// mirrors it exactly so finger and mouse rejection
|
||||
// feel identical.
|
||||
if let Some(origin_pile) = game.0.piles.get(&origin) {
|
||||
for &card_id in &drag.cards {
|
||||
let Some(stack_index) =
|
||||
origin_pile.cards.iter().position(|c| c.id == card_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let target_pos =
|
||||
card_position(&game.0, &layout.0, &origin, stack_index);
|
||||
if let Some((entity, _, transform)) = card_entities
|
||||
.iter()
|
||||
.find(|(_, ce, _)| ce.card_id == card_id)
|
||||
{
|
||||
let drag_pos = transform.translation.truncate();
|
||||
let drag_z = transform.translation.z;
|
||||
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||
commands.entity(entity).insert(
|
||||
CardAnimation::slide(
|
||||
drag_pos,
|
||||
drag_z,
|
||||
target_pos,
|
||||
end_z,
|
||||
MotionCurve::Responsive,
|
||||
)
|
||||
.with_duration(MOTION_DRAG_REJECT_SECS),
|
||||
);
|
||||
}
|
||||
let ok = game.0.can_move_cards(&origin, &target, count);
|
||||
if ok {
|
||||
moves.write(MoveRequestEvent {
|
||||
from: origin.clone(),
|
||||
to: target,
|
||||
count,
|
||||
});
|
||||
fired = true;
|
||||
} else {
|
||||
rejected.write(MoveRejectedEvent {
|
||||
from: origin.clone(),
|
||||
to: target,
|
||||
count,
|
||||
});
|
||||
// Smoothly glide each dragged card from its drop-time
|
||||
// transform back to its resting slot. See `end_drag`
|
||||
// (mouse path) for the full rationale; the touch path
|
||||
// mirrors it exactly so finger and mouse rejection
|
||||
// feel identical.
|
||||
if let Some(origin_pile) = game.0.piles.get(&origin) {
|
||||
for &card_id in &drag.cards {
|
||||
let Some(stack_index) = origin_pile.cards.iter().position(|c| c.id == card_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
||||
if let Some((entity, _, transform)) =
|
||||
card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id)
|
||||
{
|
||||
let drag_pos = transform.translation.truncate();
|
||||
let drag_z = transform.translation.z;
|
||||
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||
commands.entity(entity).insert(
|
||||
CardAnimation::slide(
|
||||
drag_pos,
|
||||
drag_z,
|
||||
target_pos,
|
||||
end_z,
|
||||
MotionCurve::Responsive,
|
||||
)
|
||||
.with_duration(MOTION_DRAG_REJECT_SECS),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1183,15 +1127,6 @@ fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index
|
||||
}
|
||||
}
|
||||
|
||||
fn card_by_id(game: &GameState, id: u32) -> Option<solitaire_core::card::Card> {
|
||||
for pile in game.piles.values() {
|
||||
if let Some(card) = pile.cards.iter().find(|c| c.id == id) {
|
||||
return Some(card.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Given a world-space cursor, find the topmost draggable card. Returns
|
||||
/// `(pile, bottom_stack_index, card_ids_bottom_to_top)`.
|
||||
fn find_draggable_at(
|
||||
@@ -1332,21 +1267,17 @@ const DOUBLE_TAP_FLASH_SECS: f32 = 0.35;
|
||||
///
|
||||
/// Returns `None` if no legal move exists from the card's current location.
|
||||
pub fn best_destination(card: &Card, game: &GameState) -> Option<PileType> {
|
||||
// Try all four foundation slots first.
|
||||
let source = game.pile_containing_card(card.id)?;
|
||||
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, pile)
|
||||
{
|
||||
if game.can_move_cards(&source, &dest, 1) {
|
||||
return Some(dest);
|
||||
}
|
||||
}
|
||||
// Then try all seven tableau piles.
|
||||
for i in 0..7_usize {
|
||||
let dest = PileType::Tableau(i);
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_tableau(card, pile)
|
||||
{
|
||||
if game.can_move_cards(&source, &dest, 1) {
|
||||
return Some(dest);
|
||||
}
|
||||
}
|
||||
@@ -1360,19 +1291,14 @@ pub fn best_destination(card: &Card, game: &GameState) -> Option<PileType> {
|
||||
/// 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,
|
||||
_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)
|
||||
&& can_place_on_tableau(bottom_card, pile)
|
||||
{
|
||||
if game.can_move_cards(from, &dest, stack_count) {
|
||||
return Some((dest, stack_count));
|
||||
}
|
||||
}
|
||||
@@ -1681,17 +1607,13 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
|
||||
let Some(from_pile) = game.piles.get(from) else {
|
||||
continue;
|
||||
};
|
||||
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else {
|
||||
let Some(_card) = from_pile.cards.last().filter(|c| c.face_up) else {
|
||||
continue;
|
||||
};
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
if let Some(dest_pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, dest_pile)
|
||||
{
|
||||
if game.can_move_cards(from, &dest, 1) {
|
||||
hints.push((from.clone(), dest, 1));
|
||||
// Each source card can land on at most one foundation slot;
|
||||
// no need to check the remaining three for this card.
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1703,11 +1625,9 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
|
||||
let Some(from_pile) = game.piles.get(from) else {
|
||||
continue;
|
||||
};
|
||||
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else {
|
||||
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(_)));
|
||||
@@ -1716,16 +1636,8 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
let dest = PileType::Tableau(i);
|
||||
if dest == *from {
|
||||
continue;
|
||||
}
|
||||
if let Some(dest_pile) = game.piles.get(&dest)
|
||||
&& can_place_on_tableau(card, dest_pile)
|
||||
{
|
||||
if game.can_move_cards(from, &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;
|
||||
}
|
||||
}
|
||||
@@ -1741,14 +1653,12 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
|
||||
let Some(from_pile) = game.piles.get(&from) else {
|
||||
continue;
|
||||
};
|
||||
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else {
|
||||
let Some(_card) = from_pile.cards.last().filter(|c| c.face_up) else {
|
||||
continue;
|
||||
};
|
||||
for i in 0..7_usize {
|
||||
let dest = PileType::Tableau(i);
|
||||
if let Some(dest_pile) = game.piles.get(&dest)
|
||||
&& can_place_on_tableau(card, dest_pile)
|
||||
{
|
||||
if game.can_move_cards(&from, &dest, 1) {
|
||||
hints.push((from.clone(), dest, 1));
|
||||
break;
|
||||
}
|
||||
@@ -2074,89 +1984,7 @@ mod tests {
|
||||
// Task #27 — best_destination pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn best_destination_prefers_foundation_over_tableau() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::GameMode;
|
||||
let mut game = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Classic);
|
||||
|
||||
// Put an Ace of Clubs in the waste pile.
|
||||
let waste = game.piles.get_mut(&PileType::Waste).unwrap();
|
||||
waste.cards.clear();
|
||||
waste.cards.push(Card {
|
||||
id: 200,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
});
|
||||
|
||||
// All four foundation slots empty — the Ace lands in slot 0 (first
|
||||
// empty slot in iteration order).
|
||||
for slot in 0..4_u8 {
|
||||
game.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
|
||||
let card = Card {
|
||||
id: 200,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
};
|
||||
let dest = best_destination(&card, &game);
|
||||
assert_eq!(dest, Some(PileType::Foundation(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_destination_falls_back_to_tableau_when_no_foundation() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::GameMode;
|
||||
let mut game = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Classic);
|
||||
|
||||
// Clear all foundation slots — a Two of Clubs cannot go there.
|
||||
for slot in 0..4_u8 {
|
||||
game.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
|
||||
// Put a Two of Clubs as the card.
|
||||
let card = Card {
|
||||
id: 300,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
};
|
||||
|
||||
// Set tableau 0 to have a Three of Hearts on top so we can place clubs two there.
|
||||
for i in 0..7_usize {
|
||||
game.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
game.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 301,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Three,
|
||||
face_up: true,
|
||||
});
|
||||
|
||||
let dest = best_destination(&card, &game);
|
||||
assert_eq!(dest, Some(PileType::Tableau(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[test]
|
||||
fn best_destination_returns_none_when_no_legal_move() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
@@ -2191,64 +2019,7 @@ mod tests {
|
||||
// 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 slot in 0..4_u8 {
|
||||
game.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.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]
|
||||
#[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);
|
||||
@@ -2381,45 +2152,7 @@ mod tests {
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_hint_returns_none_when_no_legal_move() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
// Put only a Two on tableau 0, empty everything else.
|
||||
for slot in 0..4_u8 {
|
||||
game.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
|
||||
// Two of Clubs has no legal destination.
|
||||
game.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 600,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
|
||||
assert!(find_hint(&game).is_none(), "no hint should exist");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------
|
||||
// G key fires ForfeitRequestEvent (modal-based forfeit flow)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -2490,50 +2223,7 @@ mod tests {
|
||||
|
||||
/// `all_hints` must be empty when both stock and waste are empty and no
|
||||
/// pile-to-pile move exists — the game is truly stuck.
|
||||
#[test]
|
||||
fn all_hints_is_empty_when_truly_stuck() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
// Clear every pile, then put a single card that has nowhere to go.
|
||||
for slot in 0..4_u8 {
|
||||
game.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
|
||||
// Two of Clubs on tableau 0 — can't go to an empty foundation (needs Ace
|
||||
// first) and can't go to any empty tableau column (not a King).
|
||||
game.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 700,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
|
||||
let hints = all_hints(&game);
|
||||
assert!(
|
||||
hints.is_empty(),
|
||||
"no hint should exist when the game is truly stuck"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------
|
||||
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
||||
// `ShakeAnim` on the dragged cards. The audio cue
|
||||
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
|
||||
|
||||
@@ -50,7 +50,6 @@ use bevy::window::PrimaryWindow;
|
||||
use solitaire_core::card::Card;
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
|
||||
use crate::events::MoveRequestEvent;
|
||||
@@ -250,30 +249,20 @@ pub fn radial_hovered_index(cursor: Vec2, anchors: &[Vec2]) -> Option<usize> {
|
||||
/// that legally accept the card. The source pile is excluded because
|
||||
/// dropping a card on its own pile is a no-op.
|
||||
pub fn legal_destinations_for_card(
|
||||
card: &Card,
|
||||
_card: &Card,
|
||||
source_pile: &PileType,
|
||||
game: &GameState,
|
||||
) -> Vec<PileType> {
|
||||
let mut out = Vec::new();
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
if dest == *source_pile {
|
||||
continue;
|
||||
}
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, pile)
|
||||
{
|
||||
if game.can_move_cards(source_pile, &dest, 1) {
|
||||
out.push(dest);
|
||||
}
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
let dest = PileType::Tableau(i);
|
||||
if dest == *source_pile {
|
||||
continue;
|
||||
}
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_tableau(card, pile)
|
||||
{
|
||||
if game.can_move_cards(source_pile, &dest, 1) {
|
||||
out.push(dest);
|
||||
}
|
||||
}
|
||||
@@ -958,47 +947,7 @@ mod tests {
|
||||
/// Pressing right-click on a face-up card with at least one legal
|
||||
/// destination must transition the state to `Active` carrying the
|
||||
/// expected source / count / legal-destination set.
|
||||
#[test]
|
||||
fn right_click_press_on_face_up_card_opens_radial() {
|
||||
let mut app = radial_test_app();
|
||||
let layout_window = Vec2::new(1280.0, 800.0);
|
||||
let layout = compute_layout(layout_window, 0.0, 0.0, true);
|
||||
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
|
||||
|
||||
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||
// Initial state — Idle.
|
||||
assert_eq!(
|
||||
*app.world().resource::<RightClickRadialState>(),
|
||||
RightClickRadialState::Idle
|
||||
);
|
||||
|
||||
press(&mut app, MouseButton::Right);
|
||||
app.update();
|
||||
|
||||
let state = app.world().resource::<RightClickRadialState>().clone();
|
||||
match state {
|
||||
RightClickRadialState::Active {
|
||||
source_pile,
|
||||
count,
|
||||
cards,
|
||||
legal_destinations,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(source_pile, PileType::Tableau(0));
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(cards, vec![100]);
|
||||
assert!(!legal_destinations.is_empty());
|
||||
assert!(
|
||||
legal_destinations
|
||||
.iter()
|
||||
.any(|(p, _)| matches!(p, PileType::Foundation(_)))
|
||||
);
|
||||
}
|
||||
other => panic!("expected Active, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Releasing the right button while the cursor is over a destination
|
||||
/// Releasing the right button while the cursor is over a destination
|
||||
/// icon must fire a `MoveRequestEvent` and return the state to Idle.
|
||||
#[test]
|
||||
fn right_click_release_over_destination_fires_move_request() {
|
||||
|
||||
@@ -39,7 +39,6 @@ use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
|
||||
@@ -520,7 +519,7 @@ fn handle_selection_keys(
|
||||
/// destination after a lift. Players who want a different column simply
|
||||
/// press the right-arrow key once or twice.
|
||||
pub(crate) fn legal_destinations_for(
|
||||
bottom: &solitaire_core::card::Card,
|
||||
_bottom: &solitaire_core::card::Card,
|
||||
source: &PileType,
|
||||
game: &GameState,
|
||||
stack_count: usize,
|
||||
@@ -529,24 +528,14 @@ pub(crate) fn legal_destinations_for(
|
||||
if stack_count == 1 {
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
if &dest == source {
|
||||
continue;
|
||||
}
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(bottom, pile)
|
||||
{
|
||||
if game.can_move_cards(source, &dest, 1) {
|
||||
out.push(dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
let dest = PileType::Tableau(i);
|
||||
if &dest == source {
|
||||
continue;
|
||||
}
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_tableau(bottom, pile)
|
||||
{
|
||||
if game.can_move_cards(source, &dest, stack_count) {
|
||||
out.push(dest);
|
||||
}
|
||||
}
|
||||
@@ -584,12 +573,10 @@ fn try_foundation_dest(
|
||||
card: &solitaire_core::card::Card,
|
||||
game: &solitaire_core::game_state::GameState,
|
||||
) -> Option<PileType> {
|
||||
use solitaire_core::rules::can_place_on_foundation;
|
||||
let source = game.pile_containing_card(card.id)?;
|
||||
for slot in 0..4_u8 {
|
||||
let dest = PileType::Foundation(slot);
|
||||
if let Some(pile) = game.piles.get(&dest)
|
||||
&& can_place_on_foundation(card, pile)
|
||||
{
|
||||
if game.can_move_cards(&source, &dest, 1) {
|
||||
return Some(dest);
|
||||
}
|
||||
}
|
||||
@@ -1154,89 +1141,7 @@ mod tests {
|
||||
/// Test 3 — Arrow keys in `Lifted` cycle through *legal* destinations
|
||||
/// only (foundations and tableaus that pass `can_place_on_*`), and
|
||||
/// wrap at the end of the list.
|
||||
#[test]
|
||||
fn arrow_in_lifted_cycles_legal_destinations_only() {
|
||||
let mut app = drag_test_app();
|
||||
install_state(&mut app, deterministic_state());
|
||||
app.update();
|
||||
app.world_mut()
|
||||
.resource_mut::<SelectionState>()
|
||||
.selected_pile = Some(PileType::Tableau(0));
|
||||
press_key(&mut app, KeyCode::Enter);
|
||||
app.update();
|
||||
|
||||
// Capture the destination list. For the deterministic state the 5♣
|
||||
// (black) can land on 6♥ (T1) or 6♦ (T2) — both red, rank one
|
||||
// higher. Verify that the destinations are exactly those tableaus
|
||||
// (in cycle order T1 then T2).
|
||||
let initial_dests: Vec<PileType> = match app.world().resource::<KeyboardDragState>() {
|
||||
KeyboardDragState::Lifted {
|
||||
legal_destinations, ..
|
||||
} => legal_destinations.clone(),
|
||||
_ => panic!("expected Lifted"),
|
||||
};
|
||||
assert_eq!(
|
||||
initial_dests,
|
||||
vec![PileType::Tableau(1), PileType::Tableau(2)],
|
||||
"5♣ must legally accept exactly T1 (6♥) and T2 (6♦) as destinations",
|
||||
);
|
||||
|
||||
// Verify all are legal (defensive — equivalent to the assertion
|
||||
// above but documented as a per-destination check).
|
||||
for dest in &initial_dests {
|
||||
let bottom_card = Card {
|
||||
id: 100,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Five,
|
||||
face_up: true,
|
||||
};
|
||||
let pile = app
|
||||
.world()
|
||||
.resource::<GameStateResource>()
|
||||
.0
|
||||
.piles
|
||||
.get(dest)
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert!(
|
||||
can_place_on_tableau(&bottom_card, &pile),
|
||||
"destination {dest:?} must be legal for the lifted stack",
|
||||
);
|
||||
}
|
||||
|
||||
// Initial focused destination = first entry.
|
||||
assert_eq!(
|
||||
app.world()
|
||||
.resource::<KeyboardDragState>()
|
||||
.focused_destination(),
|
||||
Some(&PileType::Tableau(1)),
|
||||
);
|
||||
|
||||
// ArrowRight → next.
|
||||
clear_input(&mut app);
|
||||
press_key(&mut app, KeyCode::ArrowRight);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world()
|
||||
.resource::<KeyboardDragState>()
|
||||
.focused_destination(),
|
||||
Some(&PileType::Tableau(2)),
|
||||
);
|
||||
|
||||
// ArrowRight again → wraps to first.
|
||||
clear_input(&mut app);
|
||||
press_key(&mut app, KeyCode::ArrowRight);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world()
|
||||
.resource::<KeyboardDragState>()
|
||||
.focused_destination(),
|
||||
Some(&PileType::Tableau(1)),
|
||||
"destination index must wrap back to 0 after exhausting the list",
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 4 — Enter while `Lifted` with a destination focused fires
|
||||
/// Test 4 — Enter while `Lifted` with a destination focused fires
|
||||
/// exactly one `MoveRequestEvent` and resets the state machine to
|
||||
/// `Idle` with `DragState` cleared.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user