refactor: migrate PileType → KlondikePile across core/wasm/engine
Build and Deploy / build-and-push (push) Failing after 1m24s

- Replace PileType with typed KlondikePile (Foundation/Tableau variants)
  throughout solitaire_core, solitaire_wasm, and solitaire_engine;
  ReplayMove now uses SavedKlondikePile for serialisation stability
- Split replay_overlay.rs into replay_overlay/ module (mod, format,
  input, update, tests) for maintainability
- Add klondike dep to solitaire_engine and solitaire_data Cargo.toml
- Add TestPileState infrastructure to game_state.rs for engine unit tests
- Rebuild solitaire_wasm pkg (js + wasm artefacts updated)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-01 13:13:35 -07:00
parent ca612f51f1
commit 9260ca7994
36 changed files with 7429 additions and 7064 deletions
+258 -191
View File
@@ -14,7 +14,7 @@ use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use bevy::window::AppLifecycle;
use chrono::Utc;
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::pile::PileType;
use klondike::KlondikePile;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
#[allow(deprecated)]
use solitaire_data::latest_replay_path;
@@ -526,7 +526,7 @@ fn handle_new_game(
&& let Some(stock) = layout
.0
.pile_positions
.get(&solitaire_core::pile::PileType::Stock)
.get(&klondike::KlondikePile::Stock)
{
for mut tx in &mut card_transforms {
tx.translation.x = stock.x;
@@ -824,19 +824,17 @@ fn handle_draw(
// Only relevant when stock is non-empty; a recycle moves waste back to
// stock face-down, so no flip events are needed in that case.
let drawn_ids: Vec<u32> = {
let stock = game.0.piles.get(&PileType::Stock);
match stock {
Some(p) if !p.cards.is_empty() => {
let draw_count = match game.0.draw_mode {
DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
};
let n = p.cards.len();
let take = n.min(draw_count);
// The top `take` cards (at the end of the vec) will be drawn.
p.cards[n - take..].iter().map(|c| c.id).collect()
}
_ => Vec::new(),
let stock = game.0.stock_cards();
if stock.is_empty() {
Vec::new()
} else {
let draw_count = match game.0.draw_mode {
DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
};
let n = stock.len();
let take = n.min(draw_count);
stock[n - take..].iter().map(|c| c.id).collect()
}
};
@@ -875,32 +873,30 @@ fn handle_move(
let was_won = game.0.is_won;
// Identify the card that will be exposed (and may flip face-up) by the move.
// It's the card just below the bottom of the moving stack in the source pile.
let flip_candidate_id = game.0.piles.get(&ev.from).and_then(|p| {
let n = p.cards.len();
let source_cards = pile_cards(&game.0, &ev.from);
let flip_candidate_id = {
let n = source_cards.len();
if n > ev.count {
let c = &p.cards[n - ev.count - 1];
let c = &source_cards[n - ev.count - 1];
if !c.face_up { Some(c.id) } else { None }
} else {
None
}
});
match game.0.move_cards(ev.from.clone(), ev.to.clone(), ev.count) {
};
match game.0.move_cards(ev.from, ev.to, ev.count) {
Ok(()) => {
// Record the move in the in-flight replay buffer. Done
// first so the entry is captured even if a subsequent
// event-write or pile-lookup happens to bail out below.
recording.moves.push(ReplayMove::Move {
from: ev.from.clone(),
to: ev.to.clone(),
from: ev.from.into(),
to: ev.to.into(),
count: ev.count,
});
// Fire flip event if the candidate card is now face-up.
if let Some(fid) = flip_candidate_id
&& game
.0
.piles
.get(&ev.from)
.and_then(|p| p.cards.last())
&& pile_cards(&game.0, &ev.from)
.last()
.is_some_and(|c| c.id == fid && c.face_up)
{
flipped.write(crate::events::CardFlippedEvent(fid));
@@ -911,10 +907,10 @@ fn handle_move(
// the King + a golden tint on the foundation marker plus a
// short audio ping. Purely a UI / audio cue — does not
// cross `solitaire_sync` and is not persisted.
if let PileType::Foundation(slot) = ev.to
&& let Some(pile) = game.0.piles.get(&ev.to)
&& pile.cards.len() == 13
&& let Some(suit) = pile.claimed_suit()
if let KlondikePile::Foundation(slot) = ev.to
&& let Some(slot) = foundation_slot(slot)
&& game.0.pile(ev.to).len() == 13
&& let Some(suit) = game.0.pile(ev.to).first().map(|c| c.suit)
{
foundation_done.write(FoundationCompletedEvent { slot, suit });
}
@@ -1016,6 +1012,22 @@ pub fn record_replay_on_win(
}
}
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<solitaire_core::card::Card> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
}
}
fn foundation_slot(foundation: klondike::Foundation) -> Option<u8> {
match foundation {
klondike::Foundation::Foundation1 => Some(0),
klondike::Foundation::Foundation2 => Some(1),
klondike::Foundation::Foundation3 => Some(2),
klondike::Foundation::Foundation4 => Some(3),
}
}
// ---------------------------------------------------------------------------
// Task #29 — No-moves detection
// ---------------------------------------------------------------------------
@@ -1037,19 +1049,17 @@ pub fn record_replay_on_win(
/// previous heuristic incorrectly did (Quat hit this with 4 cards
/// remaining and the game just sat there).
pub fn has_legal_moves(game: &GameState) -> bool {
use solitaire_core::pile::PileType;
// Drawing from a non-empty stock, and recycling a non-empty waste back to
// stock, are always legal moves in standard Klondike (unlimited recycles).
// A game can only be genuinely stuck when both stock AND waste are exhausted.
let stock_empty = game
.piles
.get(&PileType::Stock)
.is_none_or(|p| p.cards.is_empty());
.stock_cards()
.is_empty();
let waste_empty = game
.piles
.get(&PileType::Waste)
.is_none_or(|p| p.cards.is_empty());
.waste_cards()
.is_empty();
if !stock_empty || !waste_empty {
return true;
}
@@ -1287,7 +1297,8 @@ fn save_game_state_on_exit(
#[cfg(test)]
mod tests {
use super::*;
use solitaire_core::pile::PileType;
use klondike::{Foundation, KlondikePile, Tableau};
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
/// Build a minimal headless `App` with just `GamePlugin` installed.
/// Disables persistence and overrides the seed so tests are deterministic
@@ -1326,18 +1337,27 @@ mod tests {
#[test]
fn draw_request_advances_game_state() {
let mut app = test_app(42);
let stock_before = app.world().resource::<GameStateResource>().0.piles[&PileType::Stock]
.cards
let stock_before = app
.world()
.resource::<GameStateResource>()
.0
.stock_cards()
.len();
app.world_mut().write_message(DrawRequestEvent);
app.update();
let stock_after = app.world().resource::<GameStateResource>().0.piles[&PileType::Stock]
.cards
let stock_after = app
.world()
.resource::<GameStateResource>()
.0
.stock_cards()
.len();
let waste_after = app.world().resource::<GameStateResource>().0.piles[&PileType::Waste]
.cards
let waste_after = app
.world()
.resource::<GameStateResource>()
.0
.waste_cards()
.len();
assert_eq!(stock_after, stock_before - 1);
assert_eq!(waste_after, 1);
@@ -1361,16 +1381,16 @@ mod tests {
app.world_mut().write_message(UndoRequestEvent);
app.update();
let g = &app.world().resource::<GameStateResource>().0;
assert_eq!(g.piles[&PileType::Stock].cards.len(), 24);
assert_eq!(g.piles[&PileType::Waste].cards.len(), 0);
assert_eq!(g.stock_cards().len(), 24);
assert_eq!(g.waste_cards().len(), 0);
}
#[test]
fn new_game_request_reseeds() {
let mut app = test_app(1);
let before: Vec<u32> = app.world().resource::<GameStateResource>().0.piles
[&PileType::Tableau(0)]
.cards
let before: Vec<u32> = app.world().resource::<GameStateResource>().0.pile(KlondikePile::Tableau(
Tableau::Tableau1,
))
.iter()
.map(|c| c.id)
.collect();
@@ -1382,15 +1402,43 @@ mod tests {
});
app.update();
let after: Vec<u32> = app.world().resource::<GameStateResource>().0.piles
[&PileType::Tableau(0)]
.cards
let after: Vec<u32> = app.world().resource::<GameStateResource>().0.pile(KlondikePile::Tableau(
Tableau::Tableau1,
))
.iter()
.map(|c| c.id)
.collect();
assert_ne!(before, after);
}
#[test]
fn settings_changed_updates_take_from_foundation_flag() {
let mut app = test_app(1);
assert!(
app.world().resource::<GameStateResource>().0.take_from_foundation,
"fresh game should inherit default take_from_foundation=true",
);
let mut settings = solitaire_data::Settings::default();
settings.take_from_foundation = false;
app.world_mut()
.write_message(crate::settings_plugin::SettingsChangedEvent(settings.clone()));
app.update();
assert!(
!app.world().resource::<GameStateResource>().0.take_from_foundation,
"settings event must forward take_from_foundation=false into live game state",
);
settings.take_from_foundation = true;
app.world_mut()
.write_message(crate::settings_plugin::SettingsChangedEvent(settings));
app.update();
assert!(
app.world().resource::<GameStateResource>().0.take_from_foundation,
"settings event must forward take_from_foundation=true into live game state",
);
}
#[test]
fn advance_elapsed_drains_accumulator_into_whole_seconds() {
let mut elapsed = 0;
@@ -1440,8 +1488,8 @@ mod tests {
let mut app = test_app(42);
// Stock -> Waste is InvalidDestination; no state change expected.
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Stock,
to: PileType::Waste,
from: KlondikePile::Stock,
to: KlondikePile::Stock,
count: 1,
});
app.update();
@@ -1581,46 +1629,34 @@ mod tests {
// Build a tableau with two face-up cards.
{
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: 910,
suit: Suit::Clubs,
rank: Rank::King,
face_up: true,
});
t.cards.push(Card {
id: 911,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: true,
});
}
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.piles
.get_mut(&PileType::Tableau(1))
.unwrap()
.cards
.clear();
{
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
gs.0.piles
.get_mut(&PileType::Tableau(1))
.unwrap()
.cards
.push(Card {
gs.0.set_test_tableau_cards(Tableau::Tableau1, vec![
Card {
id: 910,
suit: Suit::Clubs,
rank: Rank::King,
face_up: true,
},
Card {
id: 911,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: true,
},
]);
gs.0.set_test_tableau_cards(
Tableau::Tableau2,
vec![Card {
id: 912,
suit: Suit::Spades,
rank: Rank::King,
face_up: true,
});
}],
);
}
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Tableau(0),
to: PileType::Tableau(1),
from: KlondikePile::Tableau(Tableau::Tableau1),
to: KlondikePile::Tableau(Tableau::Tableau2),
count: 1,
});
app.update();
@@ -1659,31 +1695,36 @@ mod tests {
// are exhausted and no visible card can be moved.
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
for slot in 0..4_u8 {
game.piles
.get_mut(&PileType::Foundation(slot))
.unwrap()
.cards
.clear();
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
game.set_test_foundation_cards(foundation, Vec::new());
}
for i in 0..7_usize {
game.piles
.get_mut(&PileType::Tableau(i))
.unwrap()
.cards
.clear();
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
game.set_test_tableau_cards(tableau, Vec::new());
}
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
let stock = game.piles.get_mut(&PileType::Stock).unwrap();
stock.cards.clear();
game.set_test_waste_cards(Vec::new());
let mut stock = Vec::new();
for r in [Rank::Two, Rank::Three, Rank::Four, Rank::Five] {
stock.cards.push(Card {
stock.push(Card {
id: 100 + r as u32,
suit: Suit::Hearts,
rank: r,
face_up: false,
});
}
game.set_test_stock_cards(stock);
// Stock is non-empty, so drawing is always a valid move.
assert!(
has_legal_moves(&game),
@@ -1697,34 +1738,38 @@ mod tests {
let mut game = GameState::new(1, DrawMode::DrawOne);
// Empty stock and waste so draw is NOT available.
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
game.set_test_stock_cards(Vec::new());
game.set_test_waste_cards(Vec::new());
// Clear all tableau and foundations, put Ace of Clubs on tableau 0.
for slot in 0..4_u8 {
game.piles
.get_mut(&PileType::Foundation(slot))
.unwrap()
.cards
.clear();
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
game.set_test_foundation_cards(foundation, Vec::new());
}
for i in 0..7_usize {
game.piles
.get_mut(&PileType::Tableau(i))
.unwrap()
.cards
.clear();
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
game.set_test_tableau_cards(tableau, Vec::new());
}
game.piles
.get_mut(&PileType::Tableau(0))
.unwrap()
.cards
.push(Card {
game.set_test_tableau_cards(
Tableau::Tableau1,
vec![Card {
id: 1,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
});
}],
);
assert!(
has_legal_moves(&game),
@@ -1741,47 +1786,57 @@ mod tests {
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for slot in 0..4_u8 {
game.piles
.get_mut(&PileType::Foundation(slot))
.unwrap()
.cards
.clear();
game.set_test_stock_cards(Vec::new());
game.set_test_waste_cards(Vec::new());
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
game.set_test_foundation_cards(foundation, Vec::new());
}
for i in 0..7_usize {
game.piles
.get_mut(&PileType::Tableau(i))
.unwrap()
.cards
.clear();
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
game.set_test_tableau_cards(tableau, Vec::new());
}
// Tableau 0: face-up Queen of Spades (non-top) + face-up Jack of Hearts on top.
// King of Diamonds is on Tableau 1 (empty otherwise), so Queen→King is the
// only legal tableau move, and that move targets the Queen which is non-top.
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.push(Card {
id: 10,
suit: Suit::Spades,
rank: Rank::Queen,
face_up: true,
});
t0.cards.push(Card {
id: 11,
suit: Suit::Hearts,
rank: Rank::Jack,
face_up: true,
});
let t1 = game.piles.get_mut(&PileType::Tableau(1)).unwrap();
t1.cards.push(Card {
id: 12,
suit: Suit::Diamonds,
rank: Rank::King,
face_up: true,
});
game.set_test_tableau_cards(
Tableau::Tableau1,
vec![
Card {
id: 10,
suit: Suit::Spades,
rank: Rank::Queen,
face_up: true,
},
Card {
id: 11,
suit: Suit::Hearts,
rank: Rank::Jack,
face_up: true,
},
],
);
game.set_test_tableau_cards(
Tableau::Tableau2,
vec![Card {
id: 12,
suit: Suit::Diamonds,
rank: Rank::King,
face_up: true,
}],
);
assert!(
has_legal_moves(&game),
@@ -1942,37 +1997,41 @@ mod tests {
// there legally.
{
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();
gs.0.set_test_stock_cards(Vec::new());
gs.0.set_test_waste_cards(Vec::new());
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
gs.0.set_test_foundation_cards(foundation, Vec::new());
}
for i in 0..7_usize {
gs.0.piles
.get_mut(&PileType::Tableau(i))
.unwrap()
.cards
.clear();
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
gs.0.set_test_tableau_cards(tableau, Vec::new());
}
gs.0.piles
.get_mut(&PileType::Tableau(0))
.unwrap()
.cards
.push(Card {
gs.0.set_test_tableau_cards(
Tableau::Tableau1,
vec![Card {
id: 7_000,
suit: Suit::Spades,
rank: Rank::King,
face_up: true,
});
}],
);
}
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Tableau(0),
to: PileType::Tableau(1),
from: KlondikePile::Tableau(Tableau::Tableau1),
to: KlondikePile::Tableau(Tableau::Tableau2),
count: 1,
});
app.update();
@@ -2029,8 +2088,8 @@ mod tests {
let mut app = test_app(42);
// Stock → Waste is InvalidDestination; the live engine rejects it.
app.world_mut().write_message(MoveRequestEvent {
from: PileType::Stock,
to: PileType::Waste,
from: KlondikePile::Stock,
to: KlondikePile::Stock,
count: 1,
});
app.update();
@@ -2117,8 +2176,8 @@ mod tests {
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.push(ReplayMove::StockClick);
recording.moves.push(ReplayMove::Move {
from: PileType::Waste,
to: PileType::Tableau(2),
from: SavedKlondikePile::Stock,
to: SavedKlondikePile::Tableau(SavedTableau(2)),
count: 1,
});
}
@@ -2157,8 +2216,8 @@ mod tests {
assert!(matches!(loaded.moves[0], ReplayMove::StockClick));
match &loaded.moves[1] {
ReplayMove::Move { from, to, count } => {
assert_eq!(*from, PileType::Waste);
assert_eq!(*to, PileType::Tableau(2));
assert_eq!(*from, SavedKlondikePile::Stock);
assert_eq!(*to, SavedKlondikePile::Tableau(SavedTableau(2)));
assert_eq!(*count, 1);
}
other => panic!("second entry must be a Move, got {other:?}"),
@@ -2280,11 +2339,19 @@ mod tests {
);
// Cross-check: the dealt tableau must match GameState::new(999) byte-for-byte.
let expected = GameState::new(999, DrawMode::DrawOne);
for i in 0..7 {
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
assert_eq!(
app.world().resource::<GameStateResource>().0.piles[&PileType::Tableau(i)].cards,
expected.piles[&PileType::Tableau(i)].cards,
"tableau column {i} must match the unfiltered seed",
app.world().resource::<GameStateResource>().0.pile(KlondikePile::Tableau(tableau)),
expected.pile(KlondikePile::Tableau(tableau)),
"tableau column {tableau:?} must match the unfiltered seed",
);
}
}