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
+8 -530
View File
@@ -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 (AceJack 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() {