refactor: migrate PileType → KlondikePile across core/wasm/engine
Build and Deploy / build-and-push (push) Failing after 1m24s
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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user