fix(web): rebuild Bevy canvas WASM; add SolitaireGame interactive API
Grey screen fix (canvas_bg.wasm): - Rebuilt Bevy WASM from refactored solitaire_core that removes the per-game KlondikeAdapter field from GameState. The old binary was built with wasm-opt -Oz; the large adapter allocation pattern appears to trigger an over-aggressive wasm-opt optimisation that corrupts Bevy's render pipeline, causing a permanent grey screen on /play. - build_wasm.sh: change wasm-opt -Oz → -O2. Speed-optimised level avoids the size-focused transforms that miscompile Bevy's deep render stacks. solitaire_core refactoring: - game_state.rs: remove adapter: KlondikeAdapter field; use static KlondikeAdapter::config_for() instead of a per-instance allocation. Gate test_pile_state behind #[cfg(feature = "test-support")] so production builds carry no test-only heap state. Add instruction_history() public accessor (delegates to saved_moves()). - card.rs: add Card::new(), face_up(), face_down() const constructors for more ergonomic test and wasm code. - pile.rs, solver.rs: cargo fmt. solitaire_wasm interactive API: - lib.rs: add SolitaireGame wasm-bindgen struct with draw(), move_cards(), undo(), auto_complete_step(), serialize(), from_saved() — the full player-action surface used by game.js. Add DebugSnapshot, DebugMove, DebugInvariantReport structs and debug_snapshot(), debug_legal_moves(), debug_apply_move_json() methods for e2e test automation (window.__FERROUS_DEBUG__ bridge). Add replay_moves() to export the current game as a Replay v2 payload. - solitaire_wasm.js + solitaire_wasm_bg.wasm: rebuilt with new API. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,10 @@ version.workspace = true
|
||||
license.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -111,6 +111,28 @@ pub struct Card {
|
||||
pub face_up: bool,
|
||||
}
|
||||
|
||||
impl Card {
|
||||
/// Creates a card with explicit face orientation.
|
||||
pub const fn new(id: u32, suit: Suit, rank: Rank, face_up: bool) -> Self {
|
||||
Self {
|
||||
id,
|
||||
suit,
|
||||
rank,
|
||||
face_up,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a face-up card.
|
||||
pub const fn face_up(id: u32, suit: Suit, rank: Rank) -> Self {
|
||||
Self::new(id, suit, rank, true)
|
||||
}
|
||||
|
||||
/// Creates a face-down card.
|
||||
pub const fn face_down(id: u32, suit: Suit, rank: Rank) -> Self {
|
||||
Self::new(id, suit, rank, false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -166,4 +188,19 @@ mod tests {
|
||||
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
|
||||
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_constructors_set_fields() {
|
||||
let up = Card::face_up(10, Suit::Spades, Rank::Queen);
|
||||
assert_eq!(up.id, 10);
|
||||
assert_eq!(up.suit, Suit::Spades);
|
||||
assert_eq!(up.rank, Rank::Queen);
|
||||
assert!(up.face_up);
|
||||
|
||||
let down = Card::face_down(11, Suit::Diamonds, Rank::King);
|
||||
assert_eq!(down.id, 11);
|
||||
assert_eq!(down.suit, Suit::Diamonds);
|
||||
assert_eq!(down.rank, Rank::King);
|
||||
assert!(!down.face_up);
|
||||
}
|
||||
}
|
||||
|
||||
+107
-132
@@ -1,6 +1,11 @@
|
||||
use crate::card::{Card, Rank};
|
||||
use crate::card::Card;
|
||||
use crate::error::MoveError;
|
||||
use crate::klondike_adapter::{card_from_kl, compute_time_bonus as scoring_time_bonus, KlondikeAdapter, SavedInstruction};
|
||||
use crate::klondike_adapter::{
|
||||
KlondikeAdapter, SavedInstruction, card_from_kl, compute_time_bonus as scoring_time_bonus,
|
||||
foundation_from_slot as adapter_foundation_from_slot,
|
||||
skip_cards_from_count as adapter_skip_cards_from_count,
|
||||
tableau_from_index as adapter_tableau_from_index,
|
||||
};
|
||||
use card_game::{Game, Session, SessionConfig};
|
||||
use klondike::{
|
||||
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction,
|
||||
@@ -97,6 +102,7 @@ struct PersistedGameState {
|
||||
pub saved_moves: Vec<SavedInstruction>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
/// Test-only override state that shadows the real session pile data.
|
||||
///
|
||||
/// When `test_pile_state` on `GameState` is `Some`, every pile read method
|
||||
@@ -143,8 +149,8 @@ pub struct GameState {
|
||||
pub take_from_foundation: bool,
|
||||
/// Save-file schema version.
|
||||
pub schema_version: u32,
|
||||
pub adapter: KlondikeAdapter,
|
||||
pub(crate) session: Session<Klondike>,
|
||||
#[cfg(feature = "test-support")]
|
||||
/// Test pile overrides. Always `None` in production runtime code.
|
||||
pub test_pile_state: Option<TestPileState>,
|
||||
}
|
||||
@@ -165,9 +171,8 @@ impl PartialEq for GameState {
|
||||
&& self.schema_version == other.schema_version
|
||||
&& self.stock_cards() == other.stock_cards()
|
||||
&& self.waste_cards() == other.waste_cards()
|
||||
&& (0..4_u8).all(|slot| {
|
||||
self.foundation_cards(slot).ok() == other.foundation_cards(slot).ok()
|
||||
})
|
||||
&& (0..4_u8)
|
||||
.all(|slot| self.foundation_cards(slot).ok() == other.foundation_cards(slot).ok())
|
||||
&& (0..7_usize).all(|index| {
|
||||
let Ok(tableau) = Self::tableau_from_index(index) else {
|
||||
return false;
|
||||
@@ -221,15 +226,15 @@ impl<'de> Deserialize<'de> for GameState {
|
||||
recycle_count: persisted.recycle_count,
|
||||
take_from_foundation: persisted.take_from_foundation,
|
||||
schema_version: persisted.schema_version,
|
||||
adapter: KlondikeAdapter::new(persisted.draw_mode, persisted.take_from_foundation),
|
||||
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
||||
#[cfg(feature = "test-support")]
|
||||
test_pile_state: None,
|
||||
};
|
||||
|
||||
let replay_config = Self::replay_config(game.draw_mode);
|
||||
for saved in persisted.saved_moves {
|
||||
let instruction = KlondikeInstruction::try_from(saved)
|
||||
.map_err(serde::de::Error::custom)?;
|
||||
let instruction =
|
||||
KlondikeInstruction::try_from(saved).map_err(serde::de::Error::custom)?;
|
||||
if !game
|
||||
.session
|
||||
.state()
|
||||
@@ -271,8 +276,8 @@ impl GameState {
|
||||
recycle_count: 0,
|
||||
take_from_foundation: true,
|
||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||
adapter: KlondikeAdapter::new(draw_mode, true),
|
||||
session: Self::new_session(seed, draw_mode),
|
||||
#[cfg(feature = "test-support")]
|
||||
test_pile_state: None,
|
||||
}
|
||||
}
|
||||
@@ -290,15 +295,11 @@ impl GameState {
|
||||
}
|
||||
|
||||
fn replay_config(draw_mode: DrawMode) -> KlondikeConfig {
|
||||
KlondikeAdapter::new(draw_mode, true)
|
||||
.klondike_config()
|
||||
.clone()
|
||||
KlondikeAdapter::config_for(draw_mode, true)
|
||||
}
|
||||
|
||||
fn validation_config(&self) -> KlondikeConfig {
|
||||
KlondikeAdapter::new(self.draw_mode, self.take_from_foundation)
|
||||
.klondike_config()
|
||||
.clone()
|
||||
KlondikeAdapter::config_for(self.draw_mode, self.take_from_foundation)
|
||||
}
|
||||
|
||||
fn saved_moves(&self) -> Vec<SavedInstruction> {
|
||||
@@ -309,6 +310,14 @@ impl GameState {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the deterministic instruction history for the current deal.
|
||||
///
|
||||
/// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this
|
||||
/// sequence is sufficient to replay the game state exactly.
|
||||
pub fn instruction_history(&self) -> Vec<SavedInstruction> {
|
||||
self.saved_moves()
|
||||
}
|
||||
|
||||
fn u32_from_len(len: usize) -> u32 {
|
||||
if len > u32::MAX as usize {
|
||||
u32::MAX
|
||||
@@ -332,6 +341,7 @@ impl GameState {
|
||||
}
|
||||
|
||||
pub fn stock_cards(&self) -> Vec<Card> {
|
||||
#[cfg(feature = "test-support")]
|
||||
if let Some(ref state) = self.test_pile_state
|
||||
&& let Some(ref cards) = state.stock
|
||||
{
|
||||
@@ -342,6 +352,7 @@ impl GameState {
|
||||
}
|
||||
|
||||
pub fn waste_cards(&self) -> Vec<Card> {
|
||||
#[cfg(feature = "test-support")]
|
||||
if let Some(ref state) = self.test_pile_state
|
||||
&& let Some(ref cards) = state.waste
|
||||
{
|
||||
@@ -352,6 +363,7 @@ impl GameState {
|
||||
}
|
||||
|
||||
pub fn pile(&self, pile: KlondikePile) -> Vec<Card> {
|
||||
#[cfg(feature = "test-support")]
|
||||
if let Some(ref state) = self.test_pile_state {
|
||||
match pile {
|
||||
KlondikePile::Stock => {
|
||||
@@ -385,11 +397,17 @@ impl GameState {
|
||||
}
|
||||
KlondikePile::Tableau(tableau) => {
|
||||
let mut cards = Self::cards_with_face(
|
||||
state.tableau_face_down_cards(tableau).iter().map(card_from_kl),
|
||||
state
|
||||
.tableau_face_down_cards(tableau)
|
||||
.iter()
|
||||
.map(card_from_kl),
|
||||
false,
|
||||
);
|
||||
cards.extend(Self::cards_with_face(
|
||||
state.tableau_face_up_cards(tableau).iter().map(card_from_kl),
|
||||
state
|
||||
.tableau_face_up_cards(tableau)
|
||||
.iter()
|
||||
.map(card_from_kl),
|
||||
true,
|
||||
));
|
||||
cards
|
||||
@@ -398,26 +416,11 @@ impl GameState {
|
||||
}
|
||||
|
||||
pub fn tableau_from_index(index: usize) -> Result<Tableau, MoveError> {
|
||||
match index {
|
||||
0 => Ok(Tableau::Tableau1),
|
||||
1 => Ok(Tableau::Tableau2),
|
||||
2 => Ok(Tableau::Tableau3),
|
||||
3 => Ok(Tableau::Tableau4),
|
||||
4 => Ok(Tableau::Tableau5),
|
||||
5 => Ok(Tableau::Tableau6),
|
||||
6 => Ok(Tableau::Tableau7),
|
||||
_ => Err(MoveError::InvalidSource),
|
||||
}
|
||||
adapter_tableau_from_index(index).ok_or(MoveError::InvalidSource)
|
||||
}
|
||||
|
||||
pub fn foundation_from_slot(slot: u8) -> Result<Foundation, MoveError> {
|
||||
match slot {
|
||||
0 => Ok(Foundation::Foundation1),
|
||||
1 => Ok(Foundation::Foundation2),
|
||||
2 => Ok(Foundation::Foundation3),
|
||||
3 => Ok(Foundation::Foundation4),
|
||||
_ => Err(MoveError::InvalidDestination),
|
||||
}
|
||||
adapter_foundation_from_slot(slot).ok_or(MoveError::InvalidDestination)
|
||||
}
|
||||
|
||||
pub fn foundation_cards(&self, slot: u8) -> Result<Vec<Card>, MoveError> {
|
||||
@@ -425,39 +428,65 @@ impl GameState {
|
||||
Ok(self.pile(KlondikePile::Foundation(foundation)))
|
||||
}
|
||||
|
||||
/// Returns `true` when test-only pile overrides are active.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn has_test_pile_overrides(&self) -> bool {
|
||||
self.test_pile_state.is_some()
|
||||
}
|
||||
|
||||
/// Returns `false` in production builds where test pile overrides are absent.
|
||||
#[cfg(not(feature = "test-support"))]
|
||||
pub const fn has_test_pile_overrides(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Test-support helper: clear all pile overrides so reads come from the
|
||||
/// underlying klondike session again.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn clear_test_pile_overrides(&mut self) {
|
||||
self.test_pile_state = None;
|
||||
}
|
||||
|
||||
/// Test-support helper: override face-down stock cards returned by
|
||||
/// [`Self::stock_cards`].
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn set_test_stock_cards(&mut self, cards: Vec<Card>) {
|
||||
let state = self.test_pile_state.get_or_insert_with(TestPileState::default);
|
||||
let state = self
|
||||
.test_pile_state
|
||||
.get_or_insert_with(TestPileState::default);
|
||||
state.stock = Some(cards);
|
||||
}
|
||||
|
||||
/// Test-support helper: override face-up waste cards returned by
|
||||
/// [`Self::waste_cards`] / `pile(KlondikePile::Stock)`.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn set_test_waste_cards(&mut self, cards: Vec<Card>) {
|
||||
let state = self.test_pile_state.get_or_insert_with(TestPileState::default);
|
||||
let state = self
|
||||
.test_pile_state
|
||||
.get_or_insert_with(TestPileState::default);
|
||||
state.waste = Some(cards);
|
||||
}
|
||||
|
||||
/// Test-support helper: override cards for a specific tableau column.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn set_test_tableau_cards(&mut self, tableau: Tableau, cards: Vec<Card>) {
|
||||
let state = self.test_pile_state.get_or_insert_with(TestPileState::default);
|
||||
let state = self
|
||||
.test_pile_state
|
||||
.get_or_insert_with(TestPileState::default);
|
||||
state.tableau.insert(tableau, cards);
|
||||
}
|
||||
|
||||
/// Test-support helper: override cards for a specific foundation pile.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn set_test_foundation_cards(&mut self, foundation: Foundation, cards: Vec<Card>) {
|
||||
let state = self.test_pile_state.get_or_insert_with(TestPileState::default);
|
||||
let state = self
|
||||
.test_pile_state
|
||||
.get_or_insert_with(TestPileState::default);
|
||||
state.foundation.insert(foundation, cards);
|
||||
}
|
||||
|
||||
/// Test-support helper: override cards for a specific pile.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn set_test_pile_cards(&mut self, pile: KlondikePile, cards: Vec<Card>) {
|
||||
match pile {
|
||||
KlondikePile::Stock => {
|
||||
@@ -479,22 +508,8 @@ impl GameState {
|
||||
}
|
||||
|
||||
fn skip_cards_from_usize(skip: usize) -> Result<SkipCards, MoveError> {
|
||||
match skip {
|
||||
0 => Ok(SkipCards::Skip0),
|
||||
1 => Ok(SkipCards::Skip1),
|
||||
2 => Ok(SkipCards::Skip2),
|
||||
3 => Ok(SkipCards::Skip3),
|
||||
4 => Ok(SkipCards::Skip4),
|
||||
5 => Ok(SkipCards::Skip5),
|
||||
6 => Ok(SkipCards::Skip6),
|
||||
7 => Ok(SkipCards::Skip7),
|
||||
8 => Ok(SkipCards::Skip8),
|
||||
9 => Ok(SkipCards::Skip9),
|
||||
10 => Ok(SkipCards::Skip10),
|
||||
11 => Ok(SkipCards::Skip11),
|
||||
12 => Ok(SkipCards::Skip12),
|
||||
_ => Err(MoveError::RuleViolation("invalid tableau card count".into())),
|
||||
}
|
||||
adapter_skip_cards_from_count(skip)
|
||||
.ok_or_else(|| MoveError::RuleViolation("invalid tableau card count".into()))
|
||||
}
|
||||
|
||||
fn will_flip_tableau_source(&self, from: KlondikePile, count: usize) -> bool {
|
||||
@@ -539,9 +554,8 @@ impl GameState {
|
||||
}))
|
||||
}
|
||||
(KlondikePile::Foundation(_), KlondikePile::Foundation(_)) => Err(
|
||||
MoveError::RuleViolation(
|
||||
"cannot move between foundation slots".into(),
|
||||
)),
|
||||
MoveError::RuleViolation("cannot move between foundation slots".into()),
|
||||
),
|
||||
(KlondikePile::Stock, KlondikePile::Tableau(dst)) => {
|
||||
if count != 1 {
|
||||
return Err(MoveError::RuleViolation(
|
||||
@@ -595,7 +609,9 @@ impl GameState {
|
||||
) -> Option<(KlondikePile, KlondikePile, usize)> {
|
||||
let state = self.session.state().state().state();
|
||||
match instruction {
|
||||
KlondikeInstruction::RotateStock => None,
|
||||
KlondikeInstruction::RotateStock => {
|
||||
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
|
||||
}
|
||||
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
||||
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
|
||||
return None;
|
||||
@@ -605,12 +621,17 @@ impl GameState {
|
||||
KlondikePile::Stock => KlondikePile::Stock,
|
||||
KlondikePile::Foundation(_) => return None,
|
||||
};
|
||||
Some((source, KlondikePile::Foundation(dst_foundation.foundation), 1))
|
||||
Some((
|
||||
source,
|
||||
KlondikePile::Foundation(dst_foundation.foundation),
|
||||
1,
|
||||
))
|
||||
}
|
||||
KlondikeInstruction::DstTableau(dst_tableau) => {
|
||||
let (source, count) = match dst_tableau.src {
|
||||
KlondikePileStack::Tableau(tableau_stack) => {
|
||||
let face_up_count = state.tableau_face_up_cards(tableau_stack.tableau).len();
|
||||
let face_up_count =
|
||||
state.tableau_face_up_cards(tableau_stack.tableau).len();
|
||||
let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?;
|
||||
if count == 0 {
|
||||
return None;
|
||||
@@ -633,16 +654,15 @@ impl GameState {
|
||||
return Err(MoveError::GameAlreadyWon);
|
||||
}
|
||||
|
||||
let stock_empty = self
|
||||
.stock_cards()
|
||||
.is_empty();
|
||||
let stock_empty = self.stock_cards().is_empty();
|
||||
let waste_empty = self.waste_cards().is_empty();
|
||||
if stock_empty && waste_empty {
|
||||
return Err(MoveError::StockEmpty);
|
||||
}
|
||||
|
||||
let recycling = stock_empty && !waste_empty;
|
||||
self.session.process_instruction(KlondikeInstruction::RotateStock);
|
||||
self.session
|
||||
.process_instruction(KlondikeInstruction::RotateStock);
|
||||
|
||||
if recycling {
|
||||
self.recycle_count = self.recycle_count.saturating_add(1);
|
||||
@@ -692,9 +712,9 @@ impl GameState {
|
||||
return Err(MoveError::RuleViolation("move violates rules".into()));
|
||||
}
|
||||
|
||||
let score_delta = self.adapter.score_for_move_with_mode(&from, &to, self.mode);
|
||||
let score_delta = KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode);
|
||||
let flip_bonus = if self.will_flip_tableau_source(from, count) {
|
||||
self.adapter.score_for_flip_with_mode(self.mode)
|
||||
KlondikeAdapter::score_for_flip_with_mode(self.mode)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
@@ -743,8 +763,7 @@ impl GameState {
|
||||
return false;
|
||||
}
|
||||
let suit = pile[0].suit;
|
||||
pile
|
||||
.iter()
|
||||
pile.iter()
|
||||
.enumerate()
|
||||
.all(|(i, card)| card.suit == suit && card.rank.value() == i as u8 + 1)
|
||||
}
|
||||
@@ -779,18 +798,14 @@ impl GameState {
|
||||
self.session
|
||||
.state()
|
||||
.state()
|
||||
.possible_instructions(&config)
|
||||
.get_sorted_moves(&config)
|
||||
.into_iter()
|
||||
.filter_map(|instruction| self.instruction_to_move(instruction))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns `true` when `move_cards(from, to, count)` would currently succeed.
|
||||
pub fn can_move_cards(
|
||||
&self,
|
||||
from: &KlondikePile,
|
||||
to: &KlondikePile,
|
||||
count: usize,
|
||||
) -> bool {
|
||||
pub fn can_move_cards(&self, from: &KlondikePile, to: &KlondikePile, count: usize) -> bool {
|
||||
if self.is_won || from == to {
|
||||
return false;
|
||||
}
|
||||
@@ -838,62 +853,21 @@ impl GameState {
|
||||
return None;
|
||||
}
|
||||
|
||||
let waste = KlondikePile::Stock;
|
||||
if let Some(slot) = self
|
||||
.waste_cards()
|
||||
.last()
|
||||
.and_then(|card| self.foundation_slot_for(card))
|
||||
{
|
||||
return Some((waste, KlondikePile::Foundation(Self::foundation_from_slot(slot).ok()?)));
|
||||
}
|
||||
|
||||
for index in 0..7 {
|
||||
let tableau = KlondikePile::Tableau(Self::tableau_from_index(index).ok()?);
|
||||
if let Some(slot) = self
|
||||
.pile(tableau)
|
||||
.last()
|
||||
.and_then(|card| self.foundation_slot_for(card))
|
||||
{
|
||||
return Some((tableau, KlondikePile::Foundation(Self::foundation_from_slot(slot).ok()?)));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn can_place_on_foundation_slot(&self, card: &Card, slot: u8) -> bool {
|
||||
let Ok(pile) = self.foundation_cards(slot) else {
|
||||
return false;
|
||||
};
|
||||
match pile.last() {
|
||||
Some(top) => top.suit == card.suit && top.rank.checked_add(1) == Some(card.rank),
|
||||
None => card.rank == Rank::Ace,
|
||||
}
|
||||
}
|
||||
|
||||
fn foundation_slot_for(&self, card: &Card) -> Option<u8> {
|
||||
let mut candidate = None;
|
||||
let mut empty_slot = None;
|
||||
for slot in 0..4_u8 {
|
||||
let Ok(pile) = self.foundation_cards(slot) else {
|
||||
continue;
|
||||
};
|
||||
if pile.is_empty() {
|
||||
if empty_slot.is_none() {
|
||||
empty_slot = Some(slot);
|
||||
self.possible_instructions()
|
||||
.into_iter()
|
||||
.find_map(|(from, to, count)| {
|
||||
if count != 1 {
|
||||
return None;
|
||||
}
|
||||
} else if pile.first().map(|c| c.suit) == Some(card.suit) {
|
||||
candidate = Some(slot);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let target = candidate.or_else(|| {
|
||||
if card.rank == Rank::Ace {
|
||||
empty_slot
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
target.filter(|&slot| self.can_place_on_foundation_slot(card, slot))
|
||||
if matches!(from, KlondikePile::Foundation(_)) {
|
||||
return None;
|
||||
}
|
||||
if matches!(to, KlondikePile::Foundation(_)) {
|
||||
Some((from, to))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
|
||||
@@ -980,7 +954,8 @@ mod tests {
|
||||
assert!(
|
||||
game.possible_instructions()
|
||||
.iter()
|
||||
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_)) || !matches!(t, KlondikePile::Tableau(_)))
|
||||
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_))
|
||||
|| !matches!(t, KlondikePile::Tableau(_)))
|
||||
);
|
||||
assert!(game.move_cards(from, to, 1).is_err());
|
||||
}
|
||||
|
||||
@@ -49,18 +49,8 @@ mod tests {
|
||||
#[test]
|
||||
fn pile_top_returns_last_card() {
|
||||
let mut pile = Pile::new(KlondikePile::Stock);
|
||||
pile.cards.push(Card {
|
||||
id: 0,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
});
|
||||
pile.cards.push(Card {
|
||||
id: 1,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
|
||||
pile.cards.push(Card::face_up(1, Suit::Clubs, Rank::Two));
|
||||
assert_eq!(pile.top().unwrap().id, 1);
|
||||
}
|
||||
|
||||
@@ -79,30 +69,15 @@ mod tests {
|
||||
#[test]
|
||||
fn claimed_suit_is_none_for_non_foundation() {
|
||||
let mut pile = Pile::new(KlondikePile::Tableau(klondike::Tableau::Tableau1));
|
||||
pile.cards.push(Card {
|
||||
id: 0,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
});
|
||||
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
|
||||
assert!(pile.claimed_suit().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_suit_returns_bottom_card_suit() {
|
||||
let mut pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation3));
|
||||
pile.cards.push(Card {
|
||||
id: 0,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
});
|
||||
pile.cards.push(Card {
|
||||
id: 1,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
|
||||
pile.cards.push(Card::face_up(1, Suit::Hearts, Rank::Two));
|
||||
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
|
||||
}
|
||||
}
|
||||
|
||||
+71
-168
@@ -1,14 +1,13 @@
|
||||
//! Klondike solvability checker using deterministic DFS over [`GameState`].
|
||||
//! Klondike solvability checker using upstream `card_game::Session::solve()`.
|
||||
//!
|
||||
//! Used by the engine to back the **Settings → Gameplay → "Winnable deals only"**
|
||||
//! toggle and by the hint system when it wants the first move on a winning path.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use card_game::{Session, SessionConfig, SolveError, StateSnapshot};
|
||||
use klondike::{Klondike, KlondikeInstruction, KlondikePile, KlondikePileStack};
|
||||
|
||||
use klondike::{Foundation, KlondikePile, Tableau};
|
||||
|
||||
use crate::card::Card;
|
||||
use crate::game_state::{DifficultyLevel, DrawMode, GameMode, GameState};
|
||||
use crate::game_state::{DrawMode, GameState};
|
||||
use crate::klondike_adapter::KlondikeAdapter;
|
||||
|
||||
/// Verdict returned by [`try_solve`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -59,14 +58,6 @@ pub struct SolveOutcome {
|
||||
pub first_move: Option<SolverMove>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DfsFrame {
|
||||
state: GameState,
|
||||
moves: Vec<SolverMove>,
|
||||
next_index: usize,
|
||||
first_move: Option<SolverMove>,
|
||||
}
|
||||
|
||||
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`.
|
||||
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
|
||||
try_solve_with_first_move(seed, draw_mode, config).result
|
||||
@@ -105,6 +96,7 @@ fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome
|
||||
first_move: None,
|
||||
};
|
||||
}
|
||||
|
||||
// Preserve the historical payload contract: winnable verdicts always carry
|
||||
// a first move. An already-won state therefore returns no recommendation.
|
||||
if initial.is_won {
|
||||
@@ -114,174 +106,85 @@ fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome
|
||||
};
|
||||
}
|
||||
|
||||
let mut visited: HashSet<Vec<u32>> = HashSet::with_capacity(effective_state_budget.min(16_384));
|
||||
visited.insert(state_key(initial));
|
||||
let solver_config = SessionConfig {
|
||||
inner: KlondikeAdapter::config_for(initial.draw_mode, initial.take_from_foundation),
|
||||
undo_penalty: 0,
|
||||
solve_moves_budget: effective_move_budget,
|
||||
solve_states_budget: effective_state_budget as u64,
|
||||
};
|
||||
let solver_session = Session::new(initial.session.state().state().clone(), solver_config);
|
||||
|
||||
let mut states_visited: usize = 1;
|
||||
let mut moves_considered: u64 = 0;
|
||||
let mut saw_inconclusive = false;
|
||||
|
||||
let mut stack = vec![DfsFrame {
|
||||
state: initial.clone(),
|
||||
moves: candidate_moves(initial),
|
||||
next_index: 0,
|
||||
first_move: None,
|
||||
}];
|
||||
|
||||
while let Some(frame) = stack.last_mut() {
|
||||
if frame.state.is_won {
|
||||
if let Some(first_move) = frame.first_move.clone() {
|
||||
return SolveOutcome {
|
||||
match solver_session.solve() {
|
||||
Ok(Some(solution)) => {
|
||||
let first_move = solution
|
||||
.raw_solution()
|
||||
.iter()
|
||||
.find_map(snapshot_to_solver_move);
|
||||
if let Some(first_move) = first_move {
|
||||
SolveOutcome {
|
||||
result: SolverResult::Winnable,
|
||||
first_move: Some(first_move),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
SolveOutcome {
|
||||
result: SolverResult::Inconclusive,
|
||||
first_move: None,
|
||||
}
|
||||
}
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
|
||||
if frame.next_index >= frame.moves.len() {
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
|
||||
if moves_considered >= effective_move_budget {
|
||||
saw_inconclusive = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let next_move = frame.moves[frame.next_index].clone();
|
||||
frame.next_index += 1;
|
||||
moves_considered = moves_considered.saturating_add(1);
|
||||
|
||||
let Some(next_state) = apply_solver_move(&frame.state, &next_move) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let key = state_key(&next_state);
|
||||
if visited.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
if states_visited >= effective_state_budget {
|
||||
saw_inconclusive = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.insert(key);
|
||||
states_visited = states_visited.saturating_add(1);
|
||||
|
||||
let first_move = frame
|
||||
.first_move
|
||||
.clone()
|
||||
.or_else(|| Some(next_move.clone()));
|
||||
let child_moves = candidate_moves(&next_state);
|
||||
stack.push(DfsFrame {
|
||||
state: next_state,
|
||||
moves: child_moves,
|
||||
next_index: 0,
|
||||
first_move,
|
||||
});
|
||||
}
|
||||
|
||||
if saw_inconclusive {
|
||||
SolveOutcome {
|
||||
result: SolverResult::Inconclusive,
|
||||
first_move: None,
|
||||
}
|
||||
} else {
|
||||
SolveOutcome {
|
||||
Ok(None) => SolveOutcome {
|
||||
result: SolverResult::Unwinnable,
|
||||
first_move: None,
|
||||
}
|
||||
},
|
||||
Err(SolveError::MovesBudgetExceeded | SolveError::StatesBudgetExceeded) => SolveOutcome {
|
||||
result: SolverResult::Inconclusive,
|
||||
first_move: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_moves(game: &GameState) -> Vec<SolverMove> {
|
||||
let mut out: Vec<SolverMove> = game
|
||||
.possible_instructions()
|
||||
.into_iter()
|
||||
.map(|(source, dest, count)| SolverMove {
|
||||
source,
|
||||
dest,
|
||||
count,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !game.stock_cards().is_empty() || !game.waste_cards().is_empty() {
|
||||
out.push(SolverMove {
|
||||
fn snapshot_to_solver_move(snapshot: &StateSnapshot<Klondike>) -> Option<SolverMove> {
|
||||
let source_state = snapshot.state().state();
|
||||
match *snapshot.instruction() {
|
||||
KlondikeInstruction::RotateStock => Some(SolverMove {
|
||||
source: KlondikePile::Stock,
|
||||
dest: KlondikePile::Stock,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}),
|
||||
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
||||
let source = match dst_foundation.src {
|
||||
KlondikePile::Tableau(tableau) => KlondikePile::Tableau(tableau),
|
||||
KlondikePile::Stock => KlondikePile::Stock,
|
||||
KlondikePile::Foundation(_) => return None,
|
||||
};
|
||||
Some(SolverMove {
|
||||
source,
|
||||
dest: KlondikePile::Foundation(dst_foundation.foundation),
|
||||
count: 1,
|
||||
})
|
||||
}
|
||||
KlondikeInstruction::DstTableau(dst_tableau) => {
|
||||
let (source, count) = match dst_tableau.src {
|
||||
KlondikePileStack::Tableau(tableau_stack) => {
|
||||
let face_up_count = source_state.tableau_face_up_cards(tableau_stack.tableau).len();
|
||||
let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?;
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
(KlondikePile::Tableau(tableau_stack.tableau), count)
|
||||
}
|
||||
KlondikePileStack::Stock => (KlondikePile::Stock, 1),
|
||||
KlondikePileStack::Foundation(foundation) => {
|
||||
(KlondikePile::Foundation(foundation), 1)
|
||||
}
|
||||
};
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn apply_solver_move(game: &GameState, mv: &SolverMove) -> Option<GameState> {
|
||||
let mut next = game.clone();
|
||||
if mv.source == KlondikePile::Stock && mv.dest == KlondikePile::Stock {
|
||||
next.draw().ok()?;
|
||||
} else {
|
||||
next.move_cards(mv.source, mv.dest, mv.count).ok()?;
|
||||
}
|
||||
Some(next)
|
||||
}
|
||||
|
||||
fn state_key(game: &GameState) -> Vec<u32> {
|
||||
let mut key = Vec::with_capacity(96);
|
||||
|
||||
append_pile_key(&game.stock_cards(), &mut key);
|
||||
append_pile_key(&game.waste_cards(), &mut key);
|
||||
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
] {
|
||||
append_pile_key(&game.pile(KlondikePile::Foundation(foundation)), &mut key);
|
||||
}
|
||||
|
||||
for tableau in [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
] {
|
||||
append_pile_key(&game.pile(KlondikePile::Tableau(tableau)), &mut key);
|
||||
}
|
||||
|
||||
key.push(game.draw_mode as u32);
|
||||
key.push(mode_key(game.mode));
|
||||
key.push(u32::from(game.take_from_foundation));
|
||||
key
|
||||
}
|
||||
|
||||
fn append_pile_key(cards: &[Card], key: &mut Vec<u32>) {
|
||||
key.push(cards.len() as u32);
|
||||
for card in cards {
|
||||
key.push((card.id << 1) | u32::from(card.face_up));
|
||||
}
|
||||
}
|
||||
|
||||
fn mode_key(mode: GameMode) -> u32 {
|
||||
match mode {
|
||||
GameMode::Classic => 0,
|
||||
GameMode::Zen => 1,
|
||||
GameMode::Challenge => 2,
|
||||
GameMode::TimeAttack => 3,
|
||||
GameMode::Difficulty(level) => match level {
|
||||
DifficultyLevel::Easy => 10,
|
||||
DifficultyLevel::Medium => 11,
|
||||
DifficultyLevel::Hard => 12,
|
||||
DifficultyLevel::Expert => 13,
|
||||
DifficultyLevel::Grandmaster => 14,
|
||||
DifficultyLevel::Random => 15,
|
||||
},
|
||||
Some(SolverMove {
|
||||
source,
|
||||
dest: KlondikePile::Tableau(dst_tableau.tableau),
|
||||
count,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user