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
+98 -52
View File
@@ -7,7 +7,7 @@ use std::collections::HashMap;
use bevy::math::Vec2;
use bevy::prelude::{Resource, SystemSet};
use solitaire_core::pile::PileType;
use klondike::{Foundation, KlondikePile, Tableau};
/// Schedule labels for layout-related systems so cross-plugin ordering is
/// explicit instead of relying on Bevy's automatic resource-conflict ordering
@@ -138,9 +138,9 @@ pub struct Layout {
/// Centre position of each pile, in 2D world coordinates.
///
/// World origin `(0, 0)` is the window centre; `+x` is right, `+y` is up.
/// Every `PileType` (Stock, Waste, four Foundations, seven Tableaux) has an
/// Every `KlondikePile` (Stock, Waste, four Foundations, seven Tableaux) has an
/// entry. The map always contains exactly 13 entries after `compute_layout`.
pub pile_positions: HashMap<PileType, Vec2>,
pub pile_positions: HashMap<KlondikePile, Vec2>,
/// Per-step vertical offset fraction for face-up tableau cards, as a
/// fraction of `card_size.y`. On height-limited (desktop) windows this
/// equals `TABLEAU_FAN_FRAC` (0.18); on width-limited (portrait phone)
@@ -241,21 +241,35 @@ pub fn compute_layout(
let top_y = window.y / 2.0 - safe_area_top - band_h - h_gap - card_height / 2.0;
let tableau_y = top_y - card_height - vertical_gap;
let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13);
let mut pile_positions: HashMap<KlondikePile, Vec2> = HashMap::with_capacity(13);
pile_positions.insert(PileType::Stock, Vec2::new(col_x(0), top_y));
pile_positions.insert(PileType::Waste, Vec2::new(col_x(1), top_y));
pile_positions.insert(KlondikePile::Stock, Vec2::new(col_x(1), top_y));
// Column 2 is skipped — visual separation between waste and foundations.
for slot in 0..4_u8 {
let foundation = match slot {
0 => Foundation::Foundation1,
1 => Foundation::Foundation2,
2 => Foundation::Foundation3,
_ => Foundation::Foundation4,
};
pile_positions.insert(
PileType::Foundation(slot),
KlondikePile::Foundation(foundation),
Vec2::new(col_x(3 + slot as usize), top_y),
);
}
for i in 0..7 {
pile_positions.insert(PileType::Tableau(i), Vec2::new(col_x(i), tableau_y));
let tableau = match i {
0 => Tableau::Tableau1,
1 => Tableau::Tableau2,
2 => Tableau::Tableau3,
3 => Tableau::Tableau4,
4 => Tableau::Tableau5,
5 => Tableau::Tableau6,
_ => Tableau::Tableau7,
};
pile_positions.insert(KlondikePile::Tableau(tableau), Vec2::new(col_x(i), tableau_y));
}
// Adaptive tableau fan fraction. On height-limited (desktop) windows the
@@ -301,23 +315,35 @@ mod tests {
use super::*;
fn assert_all_piles_present(layout: &Layout) {
assert!(layout.pile_positions.contains_key(&PileType::Stock));
assert!(layout.pile_positions.contains_key(&PileType::Waste));
for slot in 0..4_u8 {
assert!(layout.pile_positions.contains_key(&KlondikePile::Stock));
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
assert!(
layout
.pile_positions
.contains_key(&PileType::Foundation(slot)),
"missing foundation slot {slot}",
.contains_key(&KlondikePile::Foundation(foundation)),
"missing foundation slot {foundation:?}",
);
}
for i in 0..7 {
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
assert!(
layout.pile_positions.contains_key(&PileType::Tableau(i)),
"missing tableau {i}"
layout.pile_positions.contains_key(&KlondikePile::Tableau(tableau)),
"missing tableau {tableau:?}"
);
}
assert_eq!(layout.pile_positions.len(), 13);
assert_eq!(layout.pile_positions.len(), 12);
}
#[test]
@@ -376,9 +402,18 @@ mod tests {
#[test]
fn tableau_columns_are_sorted_left_to_right() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
for i in 0..6 {
let lhs = layout.pile_positions[&PileType::Tableau(i)].x;
let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x;
let tableaus = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
];
for i in 0..tableaus.len() - 1 {
let lhs = layout.pile_positions[&KlondikePile::Tableau(tableaus[i])].x;
let rhs = layout.pile_positions[&KlondikePile::Tableau(tableaus[i + 1])].x;
assert!(lhs < rhs, "tableau {i} should be left of tableau {}", i + 1);
}
}
@@ -386,8 +421,8 @@ mod tests {
#[test]
fn top_row_is_above_tableau_row() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let stock_y = layout.pile_positions[&PileType::Stock].y;
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
let stock_y = layout.pile_positions[&KlondikePile::Stock].y;
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)].y;
assert!(stock_y > tableau_y);
}
@@ -399,7 +434,7 @@ mod tests {
fn top_row_clears_hud_band() {
let window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0, true);
let stock_y = layout.pile_positions[&PileType::Stock].y;
let stock_y = layout.pile_positions[&KlondikePile::Stock].y;
let card_top = stock_y + layout.card_size.y / 2.0;
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
assert!(
@@ -411,24 +446,35 @@ mod tests {
#[test]
fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let stock_x = layout.pile_positions[&PileType::Stock].x;
let waste_x = layout.pile_positions[&PileType::Waste].x;
let t0_x = layout.pile_positions[&PileType::Tableau(0)].x;
let t1_x = layout.pile_positions[&PileType::Tableau(1)].x;
assert!((stock_x - t0_x).abs() < 1e-5);
assert!((waste_x - t1_x).abs() < 1e-5);
let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
let t1_x = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau2)].x;
assert!((stock_x - t1_x).abs() < 1e-5);
}
#[test]
fn foundations_align_with_tableau_cols_3_to_6() {
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
for slot in 0..4_u8 {
let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x;
let target_tableaus = [
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
];
for (idx, foundation) in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
]
.iter()
.enumerate()
{
let f_x = layout.pile_positions[&KlondikePile::Foundation(*foundation)].x;
let t_x = layout.pile_positions[&KlondikePile::Tableau(target_tableaus[idx])].x;
assert!(
(f_x - t_x).abs() < 1e-5,
"foundation slot {slot} should align with tableau {}",
3 + slot as usize,
"foundation slot {idx} should align with tableau {}",
3 + idx,
);
}
}
@@ -470,7 +516,7 @@ mod tests {
// Default app resolution (see solitaire_app/src/main.rs).
let window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0, true);
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y;
let card_h = layout.card_size.y;
// Bottom edge of the 13th fanned face-up card.
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
@@ -489,7 +535,7 @@ mod tests {
// The bug originally reproduced at 1920x1080. Lock in a regression test.
let window = Vec2::new(1920.0, 1080.0);
let layout = compute_layout(window, 0.0, 0.0, true);
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y;
let card_h = layout.card_size.y;
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
let h_gap = layout.card_size.x / 4.0;
@@ -520,7 +566,7 @@ mod tests {
fn expanded_fan_fits_phone_viewport() {
let window = Vec2::new(360.0, 800.0);
let layout = compute_layout(window, 0.0, 0.0, true);
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
let tableau_y = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)].y;
let card_h = layout.card_size.y;
let h_gap = layout.card_size.x / 4.0;
// Bottom of the 13th (worst-case) fanned face-up card.
@@ -579,8 +625,8 @@ mod tests {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 32.0, 0.0, true);
let stock_no_inset = without.pile_positions[&PileType::Stock].y;
let stock_with_inset = with_inset.pile_positions[&PileType::Stock].y;
let stock_no_inset = without.pile_positions[&KlondikePile::Stock].y;
let stock_with_inset = with_inset.pile_positions[&KlondikePile::Stock].y;
assert!(
stock_with_inset < stock_no_inset,
"safe_area_top=32 must shift stock pile down (y decreased): {} → {}",
@@ -602,10 +648,10 @@ mod tests {
let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 32.0, 0.0, true);
for pile in [
PileType::Stock,
PileType::Waste,
PileType::Tableau(0),
PileType::Tableau(6),
KlondikePile::Stock,
KlondikePile::Stock,
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Tableau(Tableau::Tableau7),
] {
assert!(
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
@@ -628,7 +674,7 @@ mod tests {
with_inset.tableau_fan_frac,
);
let card_h = with_inset.card_size.y;
let tableau_y = with_inset.pile_positions[&PileType::Tableau(6)].y;
let tableau_y = with_inset.pile_positions[&KlondikePile::Tableau(Tableau::Tableau7)].y;
let bottom_edge = tableau_y - 12.0 * card_h * with_inset.tableau_fan_frac - card_h / 2.0;
let h_gap = with_inset.card_size.x / 4.0;
let margin = -window.y / 2.0 + 48.0 + h_gap;
@@ -661,8 +707,8 @@ mod tests {
// Verify the "wrong" layout actually differs — the bug would push the
// top card row upward by exactly safe_top pixels.
let fresh_stock_y = fresh.pile_positions[&PileType::Stock].y;
let wrong_stock_y = wrong.pile_positions[&PileType::Stock].y;
let fresh_stock_y = fresh.pile_positions[&KlondikePile::Stock].y;
let wrong_stock_y = wrong.pile_positions[&KlondikePile::Stock].y;
// In Bevy's +y-is-up system, adding safe_area_top pushes the stock
// downward (y direction). So wrong_stock_y > fresh_stock_y by safe_top.
assert!(
@@ -680,14 +726,14 @@ mod tests {
"card size must be preserved after resume",
);
assert!(
(corrected.pile_positions[&PileType::Stock].y - fresh_stock_y).abs() < 1e-3,
(corrected.pile_positions[&KlondikePile::Stock].y - fresh_stock_y).abs() < 1e-3,
"stock y must match fresh launch after resume: \
corrected={:.2} fresh={fresh_stock_y:.2}",
corrected.pile_positions[&PileType::Stock].y,
corrected.pile_positions[&KlondikePile::Stock].y,
);
assert!(
(corrected.pile_positions[&PileType::Stock].x
- fresh.pile_positions[&PileType::Stock].x)
(corrected.pile_positions[&KlondikePile::Stock].x
- fresh.pile_positions[&KlondikePile::Stock].x)
.abs()
< 1e-3,
"stock x must be unchanged after resume",
@@ -695,7 +741,7 @@ mod tests {
// The HUD band top clearance (distance from window top to card top)
// must match as well — this is the quantity directly visible in Bug 2.
let card_top = |layout: &super::Layout| {
layout.pile_positions[&PileType::Stock].y + layout.card_size.y / 2.0
layout.pile_positions[&KlondikePile::Stock].y + layout.card_size.y / 2.0
};
assert!(
(card_top(&corrected) - card_top(&fresh)).abs() < 1e-3,
@@ -712,7 +758,7 @@ mod tests {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 0.0, 48.0, true);
for pile in [PileType::Stock, PileType::Tableau(0), PileType::Tableau(6)] {
for pile in [KlondikePile::Stock, KlondikePile::Tableau(Tableau::Tableau1), KlondikePile::Tableau(Tableau::Tableau7)] {
assert!(
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
"{pile:?} x-position must not change with safe_area_bottom",