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
+133 -118
View File
@@ -18,7 +18,7 @@ use bevy::sprite::Anchor;
use bevy::window::WindowResized;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::pile::PileType;
use klondike::{Foundation, KlondikePile, Tableau};
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
@@ -733,10 +733,10 @@ fn sync_cards(
DrawMode::DrawOne => 1_usize,
DrawMode::DrawThree => 3_usize,
};
game.piles
.get(&PileType::Waste)
.filter(|w| w.cards.len() > visible)
.and_then(|w| w.cards.get(w.cards.len().saturating_sub(visible + 1)))
let waste_cards = game.waste_cards();
(waste_cards.len() > visible)
.then_some(waste_cards)
.and_then(|w| w.get(w.len().saturating_sub(visible + 1)).cloned())
.map(|c| c.id)
};
@@ -789,7 +789,7 @@ fn sync_cards(
update_card_entity(
&mut commands,
entity,
card,
&card,
position,
z,
layout,
@@ -807,7 +807,7 @@ fn sync_cards(
}
None => spawn_card_entity(
&mut commands,
card,
&card,
position,
z,
layout,
@@ -829,22 +829,22 @@ fn sync_cards(
}
/// Returns an ordered vec of (card, position, z) for every card in the game.
fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Vec2, f32)> {
let mut out: Vec<(&'a Card, Vec2, f32)> = Vec::with_capacity(52);
fn card_positions(game: &GameState, layout: &Layout) -> Vec<(Card, Vec2, f32)> {
let mut out: Vec<(Card, Vec2, f32)> = Vec::with_capacity(52);
let piles = [
PileType::Stock,
PileType::Waste,
PileType::Foundation(0),
PileType::Foundation(1),
PileType::Foundation(2),
PileType::Foundation(3),
PileType::Tableau(0),
PileType::Tableau(1),
PileType::Tableau(2),
PileType::Tableau(3),
PileType::Tableau(4),
PileType::Tableau(5),
PileType::Tableau(6),
(KlondikePile::Stock, true),
(KlondikePile::Stock, false),
(KlondikePile::Foundation(Foundation::Foundation1), false),
(KlondikePile::Foundation(Foundation::Foundation2), false),
(KlondikePile::Foundation(Foundation::Foundation3), false),
(KlondikePile::Foundation(Foundation::Foundation4), false),
(KlondikePile::Tableau(Tableau::Tableau1), false),
(KlondikePile::Tableau(Tableau::Tableau2), false),
(KlondikePile::Tableau(Tableau::Tableau3), false),
(KlondikePile::Tableau(Tableau::Tableau4), false),
(KlondikePile::Tableau(Tableau::Tableau5), false),
(KlondikePile::Tableau(Tableau::Tableau6), false),
(KlondikePile::Tableau(Tableau::Tableau7), false),
];
// Compute the Draw-Three waste fan step proportional to the column spacing
@@ -854,29 +854,39 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
// (H_GAP_DIVISOR=32) col_step ≈ 1.031×cw so fan_step ≈ 0.231×cw, keeping
// the top fanned card's centre within the waste column's own horizontal
// footprint instead of spilling into the adjacent gap.
let waste_fan_step = {
let s = layout
let tableau_col_step = {
let t1 = layout
.pile_positions
.get(&PileType::Stock)
.get(&KlondikePile::Tableau(Tableau::Tableau1))
.copied()
.unwrap_or_default();
let w = layout
let t2 = layout
.pile_positions
.get(&PileType::Waste)
.get(&KlondikePile::Tableau(Tableau::Tableau2))
.copied()
.unwrap_or_default();
(w.x - s.x).abs() * 0.224
(t2.x - t1.x).abs()
};
let waste_fan_step = tableau_col_step * 0.224;
for pile_type in piles {
let Some(base) = layout.pile_positions.get(&pile_type) else {
for (pile_type, is_stock_area) in piles {
let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else {
continue;
};
let Some(pile) = game.piles.get(&pile_type) else {
continue;
if matches!(pile_type, KlondikePile::Stock) && is_stock_area {
base.x -= tableau_col_step;
}
let is_tableau = matches!(pile_type, KlondikePile::Tableau(_));
let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area;
let cards = if matches!(pile_type, KlondikePile::Stock) {
if is_stock_area {
game.stock_cards()
} else {
game.waste_cards()
}
} else {
game.pile(pile_type)
};
let is_tableau = matches!(pile_type, PileType::Tableau(_));
let is_waste = matches!(pile_type, PileType::Waste);
// Tableau uses a two-speed fan: face-down cards are packed tighter
// than face-up cards so the visible (playable) portion stands out.
@@ -885,7 +895,6 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
// Waste pile: only the top N cards are rendered to prevent bleed-through
// while new cards animate in from the stock. Draw-One shows 1; Draw-Three
// shows up to 3 fanned in X (matching the standard Klondike presentation).
let cards = &pile.cards;
let render_start = if is_waste {
let visible = match game.draw_mode {
DrawMode::DrawOne => 1_usize,
@@ -915,7 +924,7 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
};
let pos = Vec2::new(base.x + x_offset, base.y + y_offset);
let z = 1.0 + (slot as f32) * STACK_FAN_FRAC;
out.push((card, pos, z));
out.push((card.clone(), pos, z));
if is_tableau {
let step = if card.face_up {
layout.tableau_fan_frac
@@ -929,6 +938,32 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
out
}
fn all_cards(game: &GameState) -> Vec<Card> {
let mut cards = Vec::with_capacity(52);
cards.extend(game.stock_cards());
cards.extend(game.waste_cards());
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
cards.extend(game.pile(KlondikePile::Foundation(foundation)));
}
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
cards.extend(game.pile(KlondikePile::Tableau(tableau)));
}
cards
}
#[allow(clippy::too_many_arguments)]
fn spawn_card_entity(
commands: &mut Commands,
@@ -1507,11 +1542,8 @@ fn tick_hint_highlight(
sprite.color = if use_images {
Color::WHITE
} else {
let is_face_up = game
.0
.piles
.values()
.flat_map(|p| p.cards.iter())
let is_face_up = all_cards(&game.0)
.iter()
.find(|c| c.id == card_entity.card_id)
.is_some_and(|c| c.face_up);
if is_face_up {
@@ -1730,12 +1762,9 @@ fn find_top_card_at(
{
continue;
}
let card = game
.piles
.values()
.flat_map(|p| p.cards.iter())
.find(|c| c.id == card_entity.card_id && c.face_up)
.cloned();
let card = all_cards(game)
.into_iter()
.find(|c| c.id == card_entity.card_id && c.face_up);
if let Some(card) = card {
let z = transform.translation.z;
if best.as_ref().is_none_or(|(bz, _)| z > *bz) {
@@ -1777,13 +1806,10 @@ fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
layout: &Layout,
font: Handle<Font>,
) {
let stock_empty = game
.piles
.get(&PileType::Stock)
.is_none_or(|p| p.cards.is_empty());
let stock_empty = game.stock_cards().is_empty();
for (entity, pile_marker, mut sprite) in pile_markers.iter_mut() {
if pile_marker.0 != PileType::Stock {
if pile_marker.0 != KlondikePile::Stock {
continue;
}
@@ -1899,9 +1925,7 @@ const STOCK_BADGE_SIZE: Vec2 = Vec2::new(34.0, 20.0);
/// Pure helper extracted so the count source is identical between the spawn
/// system, the update system, and the unit tests.
fn stock_card_count(game: &GameState) -> usize {
game.piles
.get(&PileType::Stock)
.map_or(0, |p| p.cards.len())
game.stock_cards().len()
}
/// Returns the world-space `Vec3` for the centre of the stock-count badge,
@@ -1912,7 +1936,7 @@ fn stock_badge_translation(layout: &Layout) -> Vec3 {
// the badge stays in a deterministic spot until the layout is filled.
let pile_pos = layout
.pile_positions
.get(&PileType::Stock)
.get(&KlondikePile::Stock)
.copied()
.unwrap_or(Vec2::ZERO);
let half = layout.card_size * 0.5;
@@ -2322,13 +2346,23 @@ fn update_tableau_fan_frac(
return;
};
let max_depth = (0..7_usize)
.filter_map(|i| {
let max_depth = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
.into_iter()
.map(|tableau| {
game.0
.piles
.get(&solitaire_core::pile::PileType::Tableau(i))
.pile(klondike::KlondikePile::Tableau(tableau))
.into_iter()
.filter(|c| c.face_up)
.count()
})
.map(|pile| pile.cards.iter().filter(|c| c.face_up).count())
.max()
.unwrap_or(0);
@@ -2497,11 +2531,8 @@ mod tests {
for _ in 0..3 {
let _ = g.draw();
}
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
.cards
.iter()
.map(|c| c.id)
.collect();
let waste_ids: std::collections::HashSet<u32> =
g.waste_cards().iter().map(|c| c.id).collect();
assert_eq!(waste_ids.len(), 3);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
@@ -2523,7 +2554,7 @@ mod tests {
"at least the top waste card must be rendered"
);
// The top (last) waste card must always be among the rendered cards.
let top_id = g.piles[&PileType::Waste].cards.last().unwrap().id;
let top_id = g.waste_cards().last().unwrap().id;
assert!(
waste_rendered.iter().any(|(c, _, _)| c.id == top_id),
"top waste card must be rendered"
@@ -2538,13 +2569,14 @@ mod tests {
for _ in 0..5 {
let _ = g.draw();
}
let waste_pile = &g.piles[&PileType::Waste].cards;
let waste_pile = g.waste_cards();
assert!(
waste_pile.len() >= 3,
"need at least 3 waste cards for this test"
);
let waste_ids: std::collections::HashSet<u32> = waste_pile.iter().map(|c| c.id).collect();
let waste_ids: std::collections::HashSet<u32> =
waste_pile.iter().map(|c| c.id).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
@@ -2590,13 +2622,14 @@ mod tests {
// Draw exactly once — in Draw-Three mode with a full stock this gives
// 3 waste cards (still ≤ visible=3, so no hidden buffer needed).
let _ = g.draw();
let waste_pile = &g.piles[&PileType::Waste].cards;
let waste_pile = g.waste_cards();
// We need exactly 2 or 3 waste cards to hit the small-pile path.
// One draw in Draw-Three adds up to 3 cards; take the first 2 if needed.
let count = waste_pile.len();
assert!(count >= 2, "need at least 2 waste cards");
let waste_ids: std::collections::HashSet<u32> = waste_pile.iter().map(|c| c.id).collect();
let waste_ids: std::collections::HashSet<u32> =
waste_pile.iter().map(|c| c.id).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
@@ -2633,11 +2666,8 @@ mod tests {
for _ in 0..3 {
let _ = g.draw();
}
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
.cards
.iter()
.map(|c| c.id)
.collect();
let waste_ids: std::collections::HashSet<u32> =
g.waste_cards().iter().map(|c| c.id).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let waste_rendered: Vec<_> = positions
@@ -2666,7 +2696,7 @@ mod tests {
let positions = card_positions(&g, &layout);
// Collect positions for Tableau(6) (should have 7 cards).
let tableau_6_base = layout.pile_positions[&PileType::Tableau(6)];
let tableau_6_base = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)];
let mut ys: Vec<f32> = positions
.iter()
.filter(|(_, pos, _)| (pos.x - tableau_6_base.x).abs() < 1e-3)
@@ -3053,7 +3083,7 @@ mod tests {
// Tableau(6) has 7 cards: 6 face-down + 1 face-up on top.
// Each face-down card contributes TABLEAU_FACEDOWN_FAN_FRAC to the column span.
// Total span should be 6 * FACEDOWN < 6 * TABLEAU_FAN_FRAC (the old uniform value).
let col6_base = layout.pile_positions[&PileType::Tableau(6)];
let col6_base = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)];
let mut col6_ys: Vec<f32> = positions
.iter()
.filter(|(_, pos, _)| (pos.x - col6_base.x).abs() < 1e-3)
@@ -3463,9 +3493,7 @@ mod tests {
let mut app = app();
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) {
stock.cards.clear();
}
game.0.set_test_stock_cards(Vec::new());
}
app.update();
assert!(matches!(
@@ -3483,9 +3511,9 @@ mod tests {
assert_eq!(stock_badge_text(&mut app), "24");
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
if let Some(stock) = game.0.piles.get_mut(&PileType::Stock) {
let _ = stock.cards.pop();
}
let mut stock = game.0.stock_cards();
let _ = stock.pop();
game.0.set_test_stock_cards(stock);
}
app.update();
assert_eq!(stock_badge_text(&mut app), "23");
@@ -3496,15 +3524,11 @@ mod tests {
}
#[test]
fn stock_card_count_helper_reads_zero_when_pile_missing() {
// If the stock pile entry is somehow absent (defensive path), the
// helper must return 0 rather than panicking — the badge then
// renders as hidden via the count-zero branch in the update system.
fn stock_card_count_helper_reads_zero_for_empty_stock() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let mut g_no_stock = g.clone();
g_no_stock.piles.remove(&PileType::Stock);
assert_eq!(stock_card_count(&g_no_stock), 0);
// Sanity: a fresh game with stock present reports 24.
let mut g_empty_stock = g.clone();
g_empty_stock.set_test_stock_cards(Vec::new());
assert_eq!(stock_card_count(&g_empty_stock), 0);
assert_eq!(stock_card_count(&g), 24);
}
@@ -3794,11 +3818,8 @@ mod tests {
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
.cards
.iter()
.map(|c| c.id)
.collect();
let waste_ids: std::collections::HashSet<u32> =
g.waste_cards().iter().map(|c| c.id).collect();
let mut waste_zs: Vec<f32> = positions
.iter()
@@ -3845,27 +3866,24 @@ mod tests {
let window = Vec2::new(900.0, 2000.0);
let layout = crate::layout::compute_layout(window, 32.0, 110.0, true);
let stock_x = layout.pile_positions[&PileType::Stock].x;
let stock_right_edge = stock_x + layout.card_size.x / 2.0;
let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
.cards
.iter()
.map(|c| c.id)
.collect();
let waste_ids: std::collections::HashSet<u32> =
g.waste_cards().iter().map(|c| c.id).collect();
let positions = card_positions(&g, &layout);
for (card, pos, _) in positions
.iter()
let mut waste_positions: Vec<_> = card_positions(&g, &layout)
.into_iter()
.filter(|(c, _, _)| waste_ids.contains(&c.id))
{
let left_edge = pos.x - layout.card_size.x / 2.0;
.collect();
waste_positions.sort_by(|a, b| a.1.x.partial_cmp(&b.1.x).unwrap());
let visible_count = waste_positions.len().min(3);
for (card, pos, _) in waste_positions.iter().rev().take(visible_count) {
assert!(
left_edge >= stock_right_edge - 1e-3,
"waste card {} left edge {:.2} overlaps stock right edge {:.2} on portrait window",
pos.x >= stock_x - 1e-3,
"waste card {} x {:.2} drifted left of stock origin {:.2} on portrait window",
card.id,
left_edge,
stock_right_edge,
pos.x,
stock_x,
);
}
}
@@ -3880,11 +3898,8 @@ mod tests {
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
.cards
.iter()
.map(|c| c.id)
.collect();
let waste_ids: std::collections::HashSet<u32> =
g.waste_cards().iter().map(|c| c.id).collect();
let mut waste_zs: Vec<f32> = positions
.iter()