feat(core): Step 2 — replace pile management with Session<Klondike>
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:
funman300
2026-05-29 17:31:09 -07:00
parent d4796fa252
commit 6496e130f3
11 changed files with 840 additions and 3891 deletions
+105 -415
View File
@@ -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