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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user