refactor(core,engine,wasm): canonical FOUNDATIONS/TABLEAUS consts; adopt upstream SUITS/RANKS #137

Merged
funman300 merged 1 commits from refactor/quat-enum-consts into master 2026-07-07 03:05:11 +00:00
11 changed files with 120 additions and 297 deletions
+28
View File
@@ -19,5 +19,33 @@ pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, K
// former `solitaire_data::solver` wrapper module. // former `solitaire_data::solver` wrapper module.
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome}; pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
/// All four foundation slots, in slot order.
///
/// Canonical iteration source for `Foundation` — upstream `klondike` has no
/// `Foundation::ALL` (unlike `Suit::SUITS` / `Rank::RANKS` in `card_game`),
/// and inherent impls cannot be added to a foreign type, so the workspace
/// const lives here. Use this instead of hand-rolling `[Foundation; 4]`
/// arrays; scattered copies can silently diverge.
pub const FOUNDATIONS: [Foundation; 4] = [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
];
/// All seven tableau columns, in column order (left to right on screen).
///
/// Canonical iteration source for `Tableau` — see [`FOUNDATIONS`] for why
/// this lives here rather than upstream.
pub const TABLEAUS: [Tableau; 7] = [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
];
#[cfg(test)] #[cfg(test)]
mod proptest_tests; mod proptest_tests;
+5 -3
View File
@@ -74,9 +74,11 @@ pub const ALL_RANKS: [Rank; 13] = [
Rank::King, Rank::King,
]; ];
/// Every suit in `Clubs, Diamonds, Hearts, Spades` order — matches /// Iteration order for the SVG generator and the pin test only —
/// `card_plugin::load_card_images` so the suit index used here lines /// output files are keyed by `suit_filename`, so no runtime index
/// up with `CardImageSet.faces[suit]`. /// depends on this order. Kept local (not `Suit::SUITS`) because
/// reordering would churn the pinned snapshot ordering for no
/// benefit.
pub const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; pub const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
/// The rank component of the on-disk filename — `A`, `2`..`10`, `J`, /// The rank component of the on-disk filename — `A`, `2`..`10`, `J`,
+21 -2
View File
@@ -137,6 +137,23 @@ pub const RED_SUIT_COLOUR_HC: Color = Color::srgb(1.000, 0.408, 0.408);
/// high-contrast boost path. /// high-contrast boost path.
pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.910, 0.910, 0.910); pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.910, 0.910, 0.910);
/// Canonical outer index of `s` in [`CardImageSet::faces`].
///
/// Derived from the upstream `card_game::Suit` discriminants (0..=3 in
/// `Suit::SUITS` order), so every reader and writer of `faces` computes
/// the same layout from the same source. Three hand-rolled copies of this
/// mapping once lived in card_plugin and theme/plugin and were one
/// reorder away from drawing the wrong art.
pub(crate) const fn suit_index(s: solitaire_core::Suit) -> usize {
s as usize
}
/// Canonical inner index of `r` in [`CardImageSet::faces`] — upstream
/// `card_game::Rank` discriminants are 1..=13 in `Rank::RANKS` order.
pub(crate) const fn rank_index(r: solitaire_core::Rank) -> usize {
r as usize - 1
}
/// Pre-loaded [`Handle<Image>`]s for card face and back PNG textures. /// Pre-loaded [`Handle<Image>`]s for card face and back PNG textures.
/// ///
/// Loaded once at startup by [`load_card_images`]. When this resource is /// Loaded once at startup by [`load_card_images`]. When this resource is
@@ -146,8 +163,10 @@ pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.910, 0.910, 0.910);
pub struct CardImageSet { pub struct CardImageSet {
/// Per-card face images indexed by `[suit][rank]`. /// Per-card face images indexed by `[suit][rank]`.
/// ///
/// Suit order: Clubs=0, Diamonds=1, Hearts=2, Spades=3. /// Layout is pinned to the upstream declaration order — index with
/// Rank order: Ace=0, Two=1 … King=12. /// [`suit_index`] / [`rank_index`], never a hand-rolled match.
/// Suit order: `Suit::SUITS` (Spades=0, Hearts=1, Clubs=2, Diamonds=3).
/// Rank order: `Rank::RANKS` (Ace=0 … King=12).
pub faces: [[Handle<Image>; 13]; 4], pub faces: [[Handle<Image>; 13]; 4],
/// One handle per unlockable card-back design (indices 04). These /// One handle per unlockable card-back design (indices 04). These
/// correspond to the legacy `assets/cards/backs/back_N.png` art, indexed /// correspond to the legacy `assets/cards/backs/back_N.png` art, indexed
+9 -54
View File
@@ -105,25 +105,12 @@ pub(super) fn load_card_images(asset_server: Option<Res<AssetServer>>, mut comma
return; return;
}; };
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; // faces[suit_index(s)][rank_index(r)] — see the canonical helpers in
const RANKS: [Rank; 13] = [ // card_plugin::mod; building from SUITS/RANKS order matches them.
Rank::Ace,
Rank::Two,
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
Rank::King,
];
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|si| { let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|si| {
std::array::from_fn(|ri| asset_server.load(card_face_asset_path(RANKS[ri], SUITS[si]))) std::array::from_fn(|ri| {
asset_server.load(card_face_asset_path(Rank::RANKS[ri], Suit::SUITS[si]))
})
}); });
let backs = let backs =
std::array::from_fn(|i| asset_server.load(format!("cards/backs/classic/back_{i}.png"))); std::array::from_fn(|i| asset_server.load(format!("cards/backs/classic/back_{i}.png")));
@@ -149,27 +136,8 @@ pub(super) fn card_sprite(
) -> Sprite { ) -> Sprite {
if let Some(set) = card_images { if let Some(set) = card_images {
let image = if face_up { let image = if face_up {
let suit_idx = match card.suit() { let suit_idx = suit_index(card.suit());
Suit::Clubs => 0, let rank_idx = rank_index(card.rank());
Suit::Diamonds => 1,
Suit::Hearts => 2,
Suit::Spades => 3,
};
let rank_idx = match card.rank() {
Rank::Ace => 0,
Rank::Two => 1,
Rank::Three => 2,
Rank::Four => 3,
Rank::Five => 4,
Rank::Six => 5,
Rank::Seven => 6,
Rank::Eight => 7,
Rank::Nine => 8,
Rank::Ten => 9,
Rank::Jack => 10,
Rank::Queen => 11,
Rank::King => 12,
};
set.faces[suit_idx][rank_idx].clone() set.faces[suit_idx][rank_idx].clone()
} else if let Some(theme_back) = &set.theme_back { } else if let Some(theme_back) = &set.theme_back {
// Active theme provides its own back — always wins over the // Active theme provides its own back — always wins over the
@@ -519,23 +487,10 @@ pub(super) fn all_cards(game: &GameState) -> Vec<(Card, bool)> {
let mut cards: Vec<(Card, bool)> = Vec::with_capacity(52); let mut cards: Vec<(Card, bool)> = Vec::with_capacity(52);
cards.extend(game.stock_cards()); cards.extend(game.stock_cards());
cards.extend(game.waste_cards()); cards.extend(game.waste_cards());
for foundation in [ for foundation in solitaire_core::FOUNDATIONS {
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
cards.extend(game.pile(KlondikePile::Foundation(foundation))); cards.extend(game.pile(KlondikePile::Foundation(foundation)));
} }
for tableau in [ for tableau in solitaire_core::TABLEAUS {
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
cards.extend(game.pile(KlondikePile::Tableau(tableau))); cards.extend(game.pile(KlondikePile::Tableau(tableau)));
} }
cards cards
+9 -27
View File
@@ -28,6 +28,7 @@ use bevy::window::PrimaryWindow;
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
use bevy::window::{MonitorSelection, WindowMode}; use bevy::window::{MonitorSelection, WindowMode};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau}; use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::{Card, Suit}; use solitaire_core::{Card, Suit};
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
@@ -1329,13 +1330,13 @@ const DOUBLE_TAP_FLASH_SECS: f32 = 0.35;
pub fn best_destination(card: &Card, game: &GameState) -> Option<KlondikePile> { pub fn best_destination(card: &Card, game: &GameState) -> Option<KlondikePile> {
let source = game.pile_containing_card(card.clone())?; let source = game.pile_containing_card(card.clone())?;
for foundation in foundations() { for foundation in FOUNDATIONS {
let dest = KlondikePile::Foundation(foundation); let dest = KlondikePile::Foundation(foundation);
if game.can_move_cards(&source, &dest, 1) { if game.can_move_cards(&source, &dest, 1) {
return Some(dest); return Some(dest);
} }
} }
for tableau in tableaus() { for tableau in TABLEAUS {
let dest = KlondikePile::Tableau(tableau); let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(&source, &dest, 1) { if game.can_move_cards(&source, &dest, 1) {
return Some(dest); return Some(dest);
@@ -1356,7 +1357,7 @@ pub fn best_tableau_destination_for_stack(
game: &GameState, game: &GameState,
stack_count: usize, stack_count: usize,
) -> Option<(KlondikePile, usize)> { ) -> Option<(KlondikePile, usize)> {
for tableau in tableaus() { for tableau in TABLEAUS {
let dest = KlondikePile::Tableau(tableau); let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(from, &dest, stack_count) { if game.can_move_cards(from, &dest, stack_count) {
return Some((dest, stack_count)); return Some((dest, stack_count));
@@ -1692,7 +1693,7 @@ pub(crate) fn hint_piles(
fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> { fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
let sources: Vec<KlondikePile> = { let sources: Vec<KlondikePile> = {
let mut s = vec![KlondikePile::Stock]; let mut s = vec![KlondikePile::Stock];
for tableau in tableaus() { for tableau in TABLEAUS {
s.push(KlondikePile::Tableau(tableau)); s.push(KlondikePile::Tableau(tableau));
} }
s s
@@ -1706,7 +1707,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else { let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
continue; continue;
}; };
for foundation in foundations() { for foundation in FOUNDATIONS {
let dest = KlondikePile::Foundation(foundation); let dest = KlondikePile::Foundation(foundation);
if game.can_move_cards(from, &dest, 1) { if game.can_move_cards(from, &dest, 1) {
hints.push((*from, dest)); hints.push((*from, dest));
@@ -1728,7 +1729,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
if already_has_foundation_hint { if already_has_foundation_hint {
continue; continue;
} }
for tableau in tableaus() { for tableau in TABLEAUS {
let dest = KlondikePile::Tableau(tableau); let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(from, &dest, 1) { if game.can_move_cards(from, &dest, 1) {
hints.push((*from, dest)); hints.push((*from, dest));
@@ -1742,13 +1743,13 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
// should never hint Foundation→Foundation. Here we handle the return path // should never hint Foundation→Foundation. Here we handle the return path
// separately so the guarded `take_from_foundation` rule is respected. // separately so the guarded `take_from_foundation` rule is respected.
if game.take_from_foundation { if game.take_from_foundation {
for foundation in foundations() { for foundation in FOUNDATIONS {
let from = KlondikePile::Foundation(foundation); let from = KlondikePile::Foundation(foundation);
let from_pile = pile_cards(game, &from); let from_pile = pile_cards(game, &from);
let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else { let Some(_card) = from_pile.last().filter(|(_, face_up)| *face_up) else {
continue; continue;
}; };
for tableau in tableaus() { for tableau in TABLEAUS {
let dest = KlondikePile::Tableau(tableau); let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(&from, &dest, 1) { if game.can_move_cards(&from, &dest, 1) {
hints.push((from, dest)); hints.push((from, dest));
@@ -1782,26 +1783,7 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
} }
} }
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,
]
}
const fn tableau_number(tableau: Tableau) -> u8 { const fn tableau_number(tableau: Tableau) -> u8 {
match tableau { match tableau {
+3 -24
View File
@@ -220,32 +220,11 @@ mod tests {
] { ] {
game.set_test_foundation_cards(foundation, Vec::new()); game.set_test_foundation_cards(foundation, Vec::new());
} }
for tableau in [ for tableau in solitaire_core::TABLEAUS {
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
game.set_test_tableau_cards(tableau, Vec::new()); game.set_test_tableau_cards(tableau, Vec::new());
} }
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; let suits = Suit::SUITS;
let ranks_below_king = [ let ranks_below_king = &Rank::RANKS[..12]; // everything below King
Rank::Ace,
Rank::Two,
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
];
for (foundation, suit) in [ for (foundation, suit) in [
Foundation::Foundation1, Foundation::Foundation1,
Foundation::Foundation2, Foundation::Foundation2,
+3 -21
View File
@@ -48,6 +48,7 @@ use bevy::math::Vec2;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
@@ -254,13 +255,13 @@ pub fn legal_destinations_for_card(
game: &GameState, game: &GameState,
) -> Vec<KlondikePile> { ) -> Vec<KlondikePile> {
let mut out = Vec::new(); let mut out = Vec::new();
for foundation in foundations() { for foundation in FOUNDATIONS {
let dest = KlondikePile::Foundation(foundation); let dest = KlondikePile::Foundation(foundation);
if game.can_move_cards(source_pile, &dest, 1) { if game.can_move_cards(source_pile, &dest, 1) {
out.push(dest); out.push(dest);
} }
} }
for tableau in tableaus() { for tableau in TABLEAUS {
let dest = KlondikePile::Tableau(tableau); let dest = KlondikePile::Tableau(tableau);
if game.can_move_cards(source_pile, &dest, 1) { if game.can_move_cards(source_pile, &dest, 1) {
out.push(dest); out.push(dest);
@@ -360,26 +361,7 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
} }
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. /// Builds the `(destination, anchor)` list for a fresh radial open.
/// ///
+6 -23
View File
@@ -6,7 +6,8 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::WindowResized; use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::KlondikePile;
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::Suit; use solitaire_core::Suit;
use crate::events::{HintVisualEvent, StateChangedEvent}; use crate::events::{HintVisualEvent, StateChangedEvent};
@@ -280,10 +281,10 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
let mut piles: Vec<KlondikePile> = Vec::with_capacity(12); let mut piles: Vec<KlondikePile> = Vec::with_capacity(12);
piles.push(KlondikePile::Stock); piles.push(KlondikePile::Stock);
for foundation in foundations() { for foundation in FOUNDATIONS {
piles.push(KlondikePile::Foundation(foundation)); piles.push(KlondikePile::Foundation(foundation));
} }
for tableau in tableaus() { for tableau in TABLEAUS {
piles.push(KlondikePile::Tableau(tableau)); piles.push(KlondikePile::Tableau(tableau));
} }
@@ -576,31 +577,13 @@ fn pile_cards(
} }
} }
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,
]
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use solitaire_core::{Foundation, Tableau};
/// Minimal headless app — omits windowing so pile markers are spawned with /// Minimal headless app — omits windowing so pile markers are spawned with
/// the default 1280×800 layout and no camera is created. /// the default 1280×800 layout and no camera is created.
@@ -940,7 +923,7 @@ mod tests {
#[test] #[test]
fn suit_symbol_all_four_are_distinct() { fn suit_symbol_all_four_are_distinct() {
let symbols: Vec<&str> = [Suit::Spades, Suit::Hearts, Suit::Diamonds, Suit::Clubs] let symbols: Vec<&str> = Suit::SUITS
.iter() .iter()
.map(suit_symbol) .map(suit_symbol)
.collect(); .collect();
+4 -16
View File
@@ -62,22 +62,10 @@ impl CardKey {
/// Iterator over all 52 valid keys, in suit-major / rank-ascending order. /// Iterator over all 52 valid keys, in suit-major / rank-ascending order.
/// Used to enumerate the manifest's required entries. /// Used to enumerate the manifest's required entries.
pub fn all() -> impl Iterator<Item = CardKey> { pub fn all() -> impl Iterator<Item = CardKey> {
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; // Order-independent enumeration — consumers check completeness and
const RANKS: [Rank; 13] = [ // round-trips, never positions.
Rank::Ace, const SUITS: [Suit; 4] = Suit::SUITS;
Rank::Two, const RANKS: [Rank; 13] = Rank::RANKS;
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
Rank::King,
];
SUITS SUITS
.into_iter() .into_iter()
.flat_map(|s| RANKS.into_iter().map(move |r| CardKey::new(s, r))) .flat_map(|s| RANKS.into_iter().map(move |r| CardKey::new(s, r)))
+11 -58
View File
@@ -17,7 +17,7 @@ use solitaire_core::{Rank, Suit};
use crate::assets::{ use crate::assets::{
bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir, bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir,
}; };
use crate::card_plugin::CardImageSet; use crate::card_plugin::{CardImageSet, rank_index, suit_index};
use crate::events::StateChangedEvent; use crate::events::StateChangedEvent;
use super::loader::CardThemeLoader; use super::loader::CardThemeLoader;
@@ -242,22 +242,8 @@ fn sync_card_image_set_with_active_theme(
/// `theme_back` when present, so writing here is sufficient to make /// `theme_back` when present, so writing here is sufficient to make
/// every face-down card pick up the theme's art on the next sync. /// every face-down card pick up the theme's art on the next sync.
fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet) { fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet) {
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] { for suit in Suit::SUITS {
for rank in [ for rank in Rank::RANKS {
Rank::Ace,
Rank::Two,
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
Rank::King,
] {
if let Some(handle) = theme.faces.get(&CardKey::new(suit, rank)) { if let Some(handle) = theme.faces.get(&CardKey::new(suit, rank)) {
image_set.faces[suit_index(suit)][rank_index(rank)] = handle.clone(); image_set.faces[suit_index(suit)][rank_index(rank)] = handle.clone();
} }
@@ -266,36 +252,7 @@ fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet
image_set.theme_back = Some(theme.back.clone()); image_set.theme_back = Some(theme.back.clone());
} }
/// Index used by [`CardImageSet::faces`] for a given suit. Mirrors
/// the `card_plugin` doc comment: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
const fn suit_index(s: Suit) -> usize {
match s {
Suit::Clubs => 0,
Suit::Diamonds => 1,
Suit::Hearts => 2,
Suit::Spades => 3,
}
}
/// Index used by [`CardImageSet::faces`] for a given rank.
/// Ace=0, Two=1 … King=12.
const fn rank_index(r: Rank) -> usize {
match r {
Rank::Ace => 0,
Rank::Two => 1,
Rank::Three => 2,
Rank::Four => 3,
Rank::Five => 4,
Rank::Six => 5,
Rank::Seven => 6,
Rank::Eight => 7,
Rank::Nine => 8,
Rank::Ten => 9,
Rank::Jack => 10,
Rank::Queen => 11,
Rank::King => 12,
}
}
/// Switches the active theme to the one served at /// Switches the active theme to the one served at
/// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>` /// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>`
@@ -446,21 +403,17 @@ mod tests {
} }
#[test] #[test]
fn suit_index_ranges_match_card_plugin_layout() { fn suit_index_matches_upstream_suits_order() {
assert_eq!(suit_index(Suit::Clubs), 0); for (i, s) in Suit::SUITS.iter().enumerate() {
assert_eq!(suit_index(Suit::Diamonds), 1); assert_eq!(suit_index(*s), i, "faces outer layout = Suit::SUITS order");
assert_eq!(suit_index(Suit::Hearts), 2); }
assert_eq!(suit_index(Suit::Spades), 3);
} }
#[test] #[test]
fn rank_index_starts_at_ace_zero_and_ends_at_king_twelve() { fn rank_index_matches_upstream_ranks_order() {
assert_eq!(rank_index(Rank::Ace), 0); for (i, r) in Rank::RANKS.iter().enumerate() {
assert_eq!(rank_index(Rank::Two), 1); assert_eq!(rank_index(*r), i, "faces inner layout = Rank::RANKS order");
assert_eq!(rank_index(Rank::Ten), 9); }
assert_eq!(rank_index(Rank::Jack), 10);
assert_eq!(rank_index(Rank::Queen), 11);
assert_eq!(rank_index(Rank::King), 12);
} }
#[test] #[test]
+21 -69
View File
@@ -19,7 +19,7 @@
//! is the contract. //! is the contract.
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau}; use solitaire_core::{KlondikeInstruction, KlondikePile};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use solitaire_core::{Card, Deck, Rank, Suit}; use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::error::MoveError; use solitaire_core::error::MoveError;
@@ -145,21 +145,10 @@ impl ReplayPlayer {
let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> { let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
self.game.pile(t).iter().map(CardSnapshot::from).collect() self.game.pile(t).iter().map(CardSnapshot::from).collect()
}; };
let foundations: [Vec<CardSnapshot>; 4] = [ let foundations: [Vec<CardSnapshot>; 4] = solitaire_core::FOUNDATIONS
pile_cards(KlondikePile::Foundation(Foundation::Foundation1)), .map(|f| pile_cards(KlondikePile::Foundation(f)));
pile_cards(KlondikePile::Foundation(Foundation::Foundation2)), let tableaus: [Vec<CardSnapshot>; 7] =
pile_cards(KlondikePile::Foundation(Foundation::Foundation3)), solitaire_core::TABLEAUS.map(|t| pile_cards(KlondikePile::Tableau(t)));
pile_cards(KlondikePile::Foundation(Foundation::Foundation4)),
];
let tableaus: [Vec<CardSnapshot>; 7] = [
pile_cards(KlondikePile::Tableau(Tableau::Tableau1)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau2)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau3)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau4)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau5)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau6)),
pile_cards(KlondikePile::Tableau(Tableau::Tableau7)),
];
StateSnapshot { StateSnapshot {
step_idx: self.step_idx, step_idx: self.step_idx,
total_steps: self.moves.len(), total_steps: self.moves.len(),
@@ -353,21 +342,9 @@ fn legal_moves_for_game(game: &GameState) -> Vec<DebugMove> {
fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport { fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport {
let stock = game.stock_cards(); let stock = game.stock_cards();
let waste = game.waste_cards(); let waste = game.waste_cards();
let foundations = [ let foundations =
game.pile(KlondikePile::Foundation(Foundation::Foundation1)), solitaire_core::FOUNDATIONS.map(|f| game.pile(KlondikePile::Foundation(f)));
game.pile(KlondikePile::Foundation(Foundation::Foundation2)), let tableaus = solitaire_core::TABLEAUS.map(|t| game.pile(KlondikePile::Tableau(t)));
game.pile(KlondikePile::Foundation(Foundation::Foundation3)),
game.pile(KlondikePile::Foundation(Foundation::Foundation4)),
];
let tableaus = [
game.pile(KlondikePile::Tableau(Tableau::Tableau1)),
game.pile(KlondikePile::Tableau(Tableau::Tableau2)),
game.pile(KlondikePile::Tableau(Tableau::Tableau3)),
game.pile(KlondikePile::Tableau(Tableau::Tableau4)),
game.pile(KlondikePile::Tableau(Tableau::Tableau5)),
game.pile(KlondikePile::Tableau(Tableau::Tableau6)),
game.pile(KlondikePile::Tableau(Tableau::Tableau7)),
];
let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new(); let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new();
let mut duplicate_cards = Vec::new(); let mut duplicate_cards = Vec::new();
@@ -488,21 +465,9 @@ impl SolitaireGame {
.iter() .iter()
.map(CardSnapshot::from) .map(CardSnapshot::from)
.collect(), .collect(),
foundations: [ foundations: solitaire_core::FOUNDATIONS
cards(KlondikePile::Foundation(Foundation::Foundation1)), .map(|f| cards(KlondikePile::Foundation(f))),
cards(KlondikePile::Foundation(Foundation::Foundation2)), tableaus: solitaire_core::TABLEAUS.map(|t| cards(KlondikePile::Tableau(t))),
cards(KlondikePile::Foundation(Foundation::Foundation3)),
cards(KlondikePile::Foundation(Foundation::Foundation4)),
],
tableaus: [
cards(KlondikePile::Tableau(Tableau::Tableau1)),
cards(KlondikePile::Tableau(Tableau::Tableau2)),
cards(KlondikePile::Tableau(Tableau::Tableau3)),
cards(KlondikePile::Tableau(Tableau::Tableau4)),
cards(KlondikePile::Tableau(Tableau::Tableau5)),
cards(KlondikePile::Tableau(Tableau::Tableau6)),
cards(KlondikePile::Tableau(Tableau::Tableau7)),
],
} }
} }
@@ -513,34 +478,21 @@ impl SolitaireGame {
let slot: u8 = s["foundation-".len()..] let slot: u8 = s["foundation-".len()..]
.parse() .parse()
.map_err(|_| format!("bad pile: {s}"))?; .map_err(|_| format!("bad pile: {s}"))?;
if slot >= 4 { let foundation = solitaire_core::FOUNDATIONS
return Err(format!("foundation slot out of range: {slot}")); .get(slot as usize)
} .copied()
Ok(KlondikePile::Foundation(match slot { .ok_or_else(|| format!("foundation slot out of range: {slot}"))?;
0 => Foundation::Foundation1, Ok(KlondikePile::Foundation(foundation))
1 => Foundation::Foundation2,
2 => Foundation::Foundation3,
3 => Foundation::Foundation4,
_ => return Err(format!("foundation slot out of range: {slot}")),
}))
} }
_ if s.starts_with("tableau-") => { _ if s.starts_with("tableau-") => {
let col: usize = s["tableau-".len()..] let col: usize = s["tableau-".len()..]
.parse() .parse()
.map_err(|_| format!("bad pile: {s}"))?; .map_err(|_| format!("bad pile: {s}"))?;
if col >= 7 { let tableau = solitaire_core::TABLEAUS
return Err(format!("tableau col out of range: {col}")); .get(col)
} .copied()
Ok(KlondikePile::Tableau(match col { .ok_or_else(|| format!("tableau col out of range: {col}"))?;
0 => Tableau::Tableau1, Ok(KlondikePile::Tableau(tableau))
1 => Tableau::Tableau2,
2 => Tableau::Tableau3,
3 => Tableau::Tableau4,
4 => Tableau::Tableau5,
5 => Tableau::Tableau6,
6 => Tableau::Tableau7,
_ => return Err(format!("tableau col out of range: {col}")),
}))
} }
_ => Err(format!("unknown pile: {s}")), _ => Err(format!("unknown pile: {s}")),
} }