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
+126 -94
View File
@@ -47,9 +47,9 @@ use bevy::input::touch::Touches;
use bevy::math::Vec2;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use klondike::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::Card;
use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
use crate::events::{MoveRejectedEvent, MoveRequestEvent};
@@ -107,7 +107,7 @@ pub enum RightClickRadialState {
/// `hovered_index` (or none).
Active {
/// Pile the right-clicked card came from.
source_pile: PileType,
source_pile: KlondikePile,
/// Number of cards that would be moved (always `1` — only the
/// top face-up card is ever offered for a quick-drop, since the
/// radial is built around single-card foundation/tableau
@@ -122,7 +122,7 @@ pub enum RightClickRadialState {
/// [`RADIAL_RADIUS_PX`] centred on the press position. A single
/// destination is placed directly above the cursor; multiple
/// destinations span an arc.
legal_destinations: Vec<(PileType, Vec2)>,
legal_destinations: Vec<(KlondikePile, Vec2)>,
/// Cursor position (world space) the radial was opened at —
/// used as the centre of the ring for cursor-hover hit testing.
centre: Vec2,
@@ -250,18 +250,18 @@ pub fn radial_hovered_index(cursor: Vec2, anchors: &[Vec2]) -> Option<usize> {
/// dropping a card on its own pile is a no-op.
pub fn legal_destinations_for_card(
_card: &Card,
source_pile: &PileType,
source_pile: &KlondikePile,
game: &GameState,
) -> Vec<PileType> {
) -> Vec<KlondikePile> {
let mut out = Vec::new();
for slot in 0..4_u8 {
let dest = PileType::Foundation(slot);
for foundation in foundations() {
let dest = KlondikePile::Foundation(foundation);
if game.can_move_cards(source_pile, &dest, 1) {
out.push(dest);
}
}
for i in 0..7_usize {
let dest = PileType::Tableau(i);
for tableau in tableaus() {
let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(source_pile, &dest, 1) {
out.push(dest);
}
@@ -281,36 +281,34 @@ pub fn find_top_face_up_card_at(
cursor: Vec2,
game: &GameState,
layout: &Layout,
) -> Option<(PileType, Card)> {
) -> Option<(KlondikePile, Card)> {
let piles = [
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,
KlondikePile::Foundation(Foundation::Foundation1),
KlondikePile::Foundation(Foundation::Foundation2),
KlondikePile::Foundation(Foundation::Foundation3),
KlondikePile::Foundation(Foundation::Foundation4),
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Tableau(Tableau::Tableau2),
KlondikePile::Tableau(Tableau::Tableau3),
KlondikePile::Tableau(Tableau::Tableau4),
KlondikePile::Tableau(Tableau::Tableau5),
KlondikePile::Tableau(Tableau::Tableau6),
KlondikePile::Tableau(Tableau::Tableau7),
];
for pile in piles {
let Some(pile_cards) = game.piles.get(&pile) else {
continue;
};
if pile_cards.cards.is_empty() {
let pile_cards = pile_cards(game, &pile);
if pile_cards.is_empty() {
continue;
}
let is_tableau = matches!(pile, PileType::Tableau(_));
for i in (0..pile_cards.cards.len()).rev() {
let card = &pile_cards.cards[i];
let is_tableau = matches!(pile, KlondikePile::Tableau(_));
for i in (0..pile_cards.len()).rev() {
let card = &pile_cards[i];
if !card.face_up {
continue;
}
// Only the top card is draggable on non-tableau piles.
if !is_tableau && i != pile_cards.cards.len() - 1 {
if !is_tableau && i != pile_cards.len() - 1 {
continue;
}
let pos = card_position(game, layout, &pile, i);
@@ -331,19 +329,17 @@ pub fn find_top_face_up_card_at(
/// Mirror of `input_plugin::card_position` — kept private to this
/// module so the radial's hit-test geometry tracks renderer geometry
/// without depending on `input_plugin` internals.
fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index: usize) -> Vec2 {
fn card_position(game: &GameState, layout: &Layout, pile: &KlondikePile, stack_index: usize) -> Vec2 {
let base = layout.pile_positions[pile];
if matches!(pile, PileType::Tableau(_)) {
if matches!(pile, KlondikePile::Tableau(_)) {
let mut y_offset = 0.0_f32;
if let Some(pile_cards) = game.piles.get(pile) {
for card in pile_cards.cards.iter().take(stack_index) {
let step = if card.face_up {
TABLEAU_FAN_FRAC
} else {
TABLEAU_FACEDOWN_FAN_FRAC
};
y_offset -= layout.card_size.y * step;
}
for card in pile_cards(game, pile).iter().take(stack_index) {
let step = if card.face_up {
TABLEAU_FAN_FRAC
} else {
TABLEAU_FACEDOWN_FAN_FRAC
};
y_offset -= layout.card_size.y * step;
}
Vec2::new(base.x, base.y + y_offset)
} else {
@@ -351,8 +347,36 @@ fn card_position(game: &GameState, layout: &Layout, pile: &PileType, stack_index
}
}
fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<Card> {
match pile {
KlondikePile::Stock => game.waste_cards(),
_ => game.pile(*pile),
}
}
const fn foundations() -> [Foundation; 4] {
[
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
]
}
const fn tableaus() -> [Tableau; 7] {
[
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
]
}
/// Builds the `(destination, anchor)` list for a fresh radial open.
fn build_radial_destinations(centre: Vec2, dests: Vec<PileType>) -> Vec<(PileType, Vec2)> {
fn build_radial_destinations(centre: Vec2, dests: Vec<KlondikePile>) -> Vec<(KlondikePile, Vec2)> {
let count = dests.len();
dests
.into_iter()
@@ -442,7 +466,7 @@ fn radial_open_on_right_click(
if dests.is_empty() {
// No legal destinations — shake the source pile as feedback.
rejected.write(MoveRejectedEvent {
from: source_pile.clone(),
from: source_pile,
to: source_pile,
count: 1,
});
@@ -606,8 +630,8 @@ fn radial_handle_release_or_cancel(
&& let Some((dest, _)) = legal_destinations.get(*idx)
{
moves.write(MoveRequestEvent {
from: source_pile.clone(),
to: dest.clone(),
from: *source_pile,
to: *dest,
count: *count,
});
}
@@ -769,33 +793,37 @@ mod tests {
fn ace_only_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne);
// Wipe everything.
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for slot in 0..4_u8 {
g.piles
.get_mut(&PileType::Foundation(slot))
.unwrap()
.cards
.clear();
g.set_test_stock_cards(Vec::new());
g.set_test_waste_cards(Vec::new());
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
g.set_test_foundation_cards(foundation, Vec::new());
}
for i in 0..7_usize {
g.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,
] {
g.set_test_tableau_cards(tableau, Vec::new());
}
// Ace of Clubs on Tableau(0).
g.piles
.get_mut(&PileType::Tableau(0))
.unwrap()
.cards
.push(CoreCard {
g.set_test_tableau_cards(
Tableau::Tableau1,
vec![CoreCard {
id: 100,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: true,
});
}],
);
g
}
@@ -803,32 +831,36 @@ mod tests {
/// must skip it.
fn face_down_only_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne);
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for slot in 0..4_u8 {
g.piles
.get_mut(&PileType::Foundation(slot))
.unwrap()
.cards
.clear();
g.set_test_stock_cards(Vec::new());
g.set_test_waste_cards(Vec::new());
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
g.set_test_foundation_cards(foundation, Vec::new());
}
for i in 0..7_usize {
g.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,
] {
g.set_test_tableau_cards(tableau, Vec::new());
}
g.piles
.get_mut(&PileType::Tableau(0))
.unwrap()
.cards
.push(CoreCard {
g.set_test_tableau_cards(
Tableau::Tableau1,
vec![CoreCard {
id: 100,
suit: Suit::Spades,
rank: Rank::King,
face_up: false,
});
}],
);
g
}
@@ -926,14 +958,14 @@ mod tests {
rank: Rank::Ace,
face_up: true,
};
let dests = legal_destinations_for_card(&card, &PileType::Tableau(0), &g);
let dests = legal_destinations_for_card(&card, &KlondikePile::Tableau(Tableau::Tableau1), &g);
// Ace can be placed on every empty foundation. We only need
// the count to be ≥ 1 and the source pile to be excluded.
assert!(
!dests.is_empty(),
"Ace must have at least one legal destination"
);
assert!(!dests.contains(&PileType::Tableau(0)));
assert!(!dests.contains(&KlondikePile::Tableau(Tableau::Tableau1)));
}
#[test]
@@ -945,8 +977,8 @@ mod tests {
rank: Rank::Ace,
face_up: true,
};
let dests = legal_destinations_for_card(&card, &PileType::Foundation(0), &g);
assert!(!dests.contains(&PileType::Foundation(0)));
let dests = legal_destinations_for_card(&card, &KlondikePile::Foundation(Foundation::Foundation1), &g);
assert!(!dests.contains(&KlondikePile::Foundation(Foundation::Foundation1)));
}
// -----------------------------------------------------------------------
@@ -963,7 +995,7 @@ mod tests {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
let ace_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
press(&mut app, MouseButton::Right);
@@ -990,7 +1022,7 @@ mod tests {
let events = collect_move_events(&mut app);
assert_eq!(events.len(), 1, "exactly one MoveRequestEvent expected");
let evt = &events[0];
assert_eq!(evt.from, PileType::Tableau(0));
assert_eq!(evt.from, KlondikePile::Tableau(Tableau::Tableau1));
assert_eq!(evt.to, dest_pile);
assert_eq!(evt.count, 1);
// State must return to Idle.
@@ -1007,7 +1039,7 @@ mod tests {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
let ace_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
press(&mut app, MouseButton::Right);
@@ -1038,7 +1070,7 @@ mod tests {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
let ace_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
press(&mut app, MouseButton::Right);
@@ -1064,7 +1096,7 @@ mod tests {
let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window, 0.0, 0.0, true);
let king_pos = layout.pile_positions[&PileType::Tableau(0)];
let king_pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
install_resources(&mut app, face_down_only_state(), layout_window, king_pos);
press(&mut app, MouseButton::Right);