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:
+5
-1
@@ -65,7 +65,11 @@ wasm-bindgen \
|
|||||||
# wasm-opt passes are skipped silently when the tool is not installed.
|
# wasm-opt passes are skipped silently when the tool is not installed.
|
||||||
if command -v wasm-opt &> /dev/null; then
|
if command -v wasm-opt &> /dev/null; then
|
||||||
echo "Running wasm-opt on canvas_bg.wasm..."
|
echo "Running wasm-opt on canvas_bg.wasm..."
|
||||||
wasm-opt -Oz \
|
# Use -O2 (not -Oz): Bevy's render pipeline uses deep call stacks and
|
||||||
|
# complex memory patterns that wasm-opt -Oz can miscompile, resulting
|
||||||
|
# in a grey screen on first load. -O2 is speed-optimised and avoids
|
||||||
|
# the size-focused transforms that trigger the regression.
|
||||||
|
wasm-opt -O2 \
|
||||||
-o "$OUT_DIR/canvas_bg.wasm" \
|
-o "$OUT_DIR/canvas_bg.wasm" \
|
||||||
"$OUT_DIR/canvas_bg.wasm"
|
"$OUT_DIR/canvas_bg.wasm"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ version.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
test-support = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|||||||
@@ -111,6 +111,28 @@ pub struct Card {
|
|||||||
pub face_up: bool,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -166,4 +188,19 @@ mod tests {
|
|||||||
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
|
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
|
||||||
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
|
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::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 card_game::{Game, Session, SessionConfig};
|
||||||
use klondike::{
|
use klondike::{
|
||||||
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction,
|
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction,
|
||||||
@@ -97,6 +102,7 @@ struct PersistedGameState {
|
|||||||
pub saved_moves: Vec<SavedInstruction>,
|
pub saved_moves: Vec<SavedInstruction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
/// Test-only override state that shadows the real session pile data.
|
/// Test-only override state that shadows the real session pile data.
|
||||||
///
|
///
|
||||||
/// When `test_pile_state` on `GameState` is `Some`, every pile read method
|
/// When `test_pile_state` on `GameState` is `Some`, every pile read method
|
||||||
@@ -143,8 +149,8 @@ pub struct GameState {
|
|||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
/// Save-file schema version.
|
/// Save-file schema version.
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub adapter: KlondikeAdapter,
|
|
||||||
pub(crate) session: Session<Klondike>,
|
pub(crate) session: Session<Klondike>,
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
/// Test pile overrides. Always `None` in production runtime code.
|
/// Test pile overrides. Always `None` in production runtime code.
|
||||||
pub test_pile_state: Option<TestPileState>,
|
pub test_pile_state: Option<TestPileState>,
|
||||||
}
|
}
|
||||||
@@ -165,9 +171,8 @@ impl PartialEq for GameState {
|
|||||||
&& self.schema_version == other.schema_version
|
&& self.schema_version == other.schema_version
|
||||||
&& self.stock_cards() == other.stock_cards()
|
&& self.stock_cards() == other.stock_cards()
|
||||||
&& self.waste_cards() == other.waste_cards()
|
&& self.waste_cards() == other.waste_cards()
|
||||||
&& (0..4_u8).all(|slot| {
|
&& (0..4_u8)
|
||||||
self.foundation_cards(slot).ok() == other.foundation_cards(slot).ok()
|
.all(|slot| self.foundation_cards(slot).ok() == other.foundation_cards(slot).ok())
|
||||||
})
|
|
||||||
&& (0..7_usize).all(|index| {
|
&& (0..7_usize).all(|index| {
|
||||||
let Ok(tableau) = Self::tableau_from_index(index) else {
|
let Ok(tableau) = Self::tableau_from_index(index) else {
|
||||||
return false;
|
return false;
|
||||||
@@ -221,15 +226,15 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
recycle_count: persisted.recycle_count,
|
recycle_count: persisted.recycle_count,
|
||||||
take_from_foundation: persisted.take_from_foundation,
|
take_from_foundation: persisted.take_from_foundation,
|
||||||
schema_version: persisted.schema_version,
|
schema_version: persisted.schema_version,
|
||||||
adapter: KlondikeAdapter::new(persisted.draw_mode, persisted.take_from_foundation),
|
|
||||||
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
test_pile_state: None,
|
test_pile_state: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let replay_config = Self::replay_config(game.draw_mode);
|
let replay_config = Self::replay_config(game.draw_mode);
|
||||||
for saved in persisted.saved_moves {
|
for saved in persisted.saved_moves {
|
||||||
let instruction = KlondikeInstruction::try_from(saved)
|
let instruction =
|
||||||
.map_err(serde::de::Error::custom)?;
|
KlondikeInstruction::try_from(saved).map_err(serde::de::Error::custom)?;
|
||||||
if !game
|
if !game
|
||||||
.session
|
.session
|
||||||
.state()
|
.state()
|
||||||
@@ -271,8 +276,8 @@ impl GameState {
|
|||||||
recycle_count: 0,
|
recycle_count: 0,
|
||||||
take_from_foundation: true,
|
take_from_foundation: true,
|
||||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||||
adapter: KlondikeAdapter::new(draw_mode, true),
|
|
||||||
session: Self::new_session(seed, draw_mode),
|
session: Self::new_session(seed, draw_mode),
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
test_pile_state: None,
|
test_pile_state: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,15 +295,11 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn replay_config(draw_mode: DrawMode) -> KlondikeConfig {
|
fn replay_config(draw_mode: DrawMode) -> KlondikeConfig {
|
||||||
KlondikeAdapter::new(draw_mode, true)
|
KlondikeAdapter::config_for(draw_mode, true)
|
||||||
.klondike_config()
|
|
||||||
.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validation_config(&self) -> KlondikeConfig {
|
fn validation_config(&self) -> KlondikeConfig {
|
||||||
KlondikeAdapter::new(self.draw_mode, self.take_from_foundation)
|
KlondikeAdapter::config_for(self.draw_mode, self.take_from_foundation)
|
||||||
.klondike_config()
|
|
||||||
.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn saved_moves(&self) -> Vec<SavedInstruction> {
|
fn saved_moves(&self) -> Vec<SavedInstruction> {
|
||||||
@@ -309,6 +310,14 @@ impl GameState {
|
|||||||
.collect()
|
.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 {
|
fn u32_from_len(len: usize) -> u32 {
|
||||||
if len > u32::MAX as usize {
|
if len > u32::MAX as usize {
|
||||||
u32::MAX
|
u32::MAX
|
||||||
@@ -332,6 +341,7 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn stock_cards(&self) -> Vec<Card> {
|
pub fn stock_cards(&self) -> Vec<Card> {
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
if let Some(ref state) = self.test_pile_state
|
if let Some(ref state) = self.test_pile_state
|
||||||
&& let Some(ref cards) = state.stock
|
&& let Some(ref cards) = state.stock
|
||||||
{
|
{
|
||||||
@@ -342,6 +352,7 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn waste_cards(&self) -> Vec<Card> {
|
pub fn waste_cards(&self) -> Vec<Card> {
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
if let Some(ref state) = self.test_pile_state
|
if let Some(ref state) = self.test_pile_state
|
||||||
&& let Some(ref cards) = state.waste
|
&& let Some(ref cards) = state.waste
|
||||||
{
|
{
|
||||||
@@ -352,6 +363,7 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn pile(&self, pile: KlondikePile) -> Vec<Card> {
|
pub fn pile(&self, pile: KlondikePile) -> Vec<Card> {
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
if let Some(ref state) = self.test_pile_state {
|
if let Some(ref state) = self.test_pile_state {
|
||||||
match pile {
|
match pile {
|
||||||
KlondikePile::Stock => {
|
KlondikePile::Stock => {
|
||||||
@@ -385,11 +397,17 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
KlondikePile::Tableau(tableau) => {
|
KlondikePile::Tableau(tableau) => {
|
||||||
let mut cards = Self::cards_with_face(
|
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,
|
false,
|
||||||
);
|
);
|
||||||
cards.extend(Self::cards_with_face(
|
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,
|
true,
|
||||||
));
|
));
|
||||||
cards
|
cards
|
||||||
@@ -398,26 +416,11 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn tableau_from_index(index: usize) -> Result<Tableau, MoveError> {
|
pub fn tableau_from_index(index: usize) -> Result<Tableau, MoveError> {
|
||||||
match index {
|
adapter_tableau_from_index(index).ok_or(MoveError::InvalidSource)
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn foundation_from_slot(slot: u8) -> Result<Foundation, MoveError> {
|
pub fn foundation_from_slot(slot: u8) -> Result<Foundation, MoveError> {
|
||||||
match slot {
|
adapter_foundation_from_slot(slot).ok_or(MoveError::InvalidDestination)
|
||||||
0 => Ok(Foundation::Foundation1),
|
|
||||||
1 => Ok(Foundation::Foundation2),
|
|
||||||
2 => Ok(Foundation::Foundation3),
|
|
||||||
3 => Ok(Foundation::Foundation4),
|
|
||||||
_ => Err(MoveError::InvalidDestination),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn foundation_cards(&self, slot: u8) -> Result<Vec<Card>, MoveError> {
|
pub fn foundation_cards(&self, slot: u8) -> Result<Vec<Card>, MoveError> {
|
||||||
@@ -425,39 +428,65 @@ impl GameState {
|
|||||||
Ok(self.pile(KlondikePile::Foundation(foundation)))
|
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
|
/// Test-support helper: clear all pile overrides so reads come from the
|
||||||
/// underlying klondike session again.
|
/// underlying klondike session again.
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
pub fn clear_test_pile_overrides(&mut self) {
|
pub fn clear_test_pile_overrides(&mut self) {
|
||||||
self.test_pile_state = None;
|
self.test_pile_state = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test-support helper: override face-down stock cards returned by
|
/// Test-support helper: override face-down stock cards returned by
|
||||||
/// [`Self::stock_cards`].
|
/// [`Self::stock_cards`].
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
pub fn set_test_stock_cards(&mut self, cards: Vec<Card>) {
|
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);
|
state.stock = Some(cards);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test-support helper: override face-up waste cards returned by
|
/// Test-support helper: override face-up waste cards returned by
|
||||||
/// [`Self::waste_cards`] / `pile(KlondikePile::Stock)`.
|
/// [`Self::waste_cards`] / `pile(KlondikePile::Stock)`.
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
pub fn set_test_waste_cards(&mut self, cards: Vec<Card>) {
|
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);
|
state.waste = Some(cards);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test-support helper: override cards for a specific tableau column.
|
/// 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>) {
|
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);
|
state.tableau.insert(tableau, cards);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test-support helper: override cards for a specific foundation pile.
|
/// 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>) {
|
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);
|
state.foundation.insert(foundation, cards);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test-support helper: override cards for a specific pile.
|
/// 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>) {
|
pub fn set_test_pile_cards(&mut self, pile: KlondikePile, cards: Vec<Card>) {
|
||||||
match pile {
|
match pile {
|
||||||
KlondikePile::Stock => {
|
KlondikePile::Stock => {
|
||||||
@@ -479,22 +508,8 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn skip_cards_from_usize(skip: usize) -> Result<SkipCards, MoveError> {
|
fn skip_cards_from_usize(skip: usize) -> Result<SkipCards, MoveError> {
|
||||||
match skip {
|
adapter_skip_cards_from_count(skip)
|
||||||
0 => Ok(SkipCards::Skip0),
|
.ok_or_else(|| MoveError::RuleViolation("invalid tableau card count".into()))
|
||||||
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())),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn will_flip_tableau_source(&self, from: KlondikePile, count: usize) -> bool {
|
fn will_flip_tableau_source(&self, from: KlondikePile, count: usize) -> bool {
|
||||||
@@ -539,9 +554,8 @@ impl GameState {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
(KlondikePile::Foundation(_), KlondikePile::Foundation(_)) => Err(
|
(KlondikePile::Foundation(_), KlondikePile::Foundation(_)) => Err(
|
||||||
MoveError::RuleViolation(
|
MoveError::RuleViolation("cannot move between foundation slots".into()),
|
||||||
"cannot move between foundation slots".into(),
|
),
|
||||||
)),
|
|
||||||
(KlondikePile::Stock, KlondikePile::Tableau(dst)) => {
|
(KlondikePile::Stock, KlondikePile::Tableau(dst)) => {
|
||||||
if count != 1 {
|
if count != 1 {
|
||||||
return Err(MoveError::RuleViolation(
|
return Err(MoveError::RuleViolation(
|
||||||
@@ -595,7 +609,9 @@ impl GameState {
|
|||||||
) -> Option<(KlondikePile, KlondikePile, usize)> {
|
) -> Option<(KlondikePile, KlondikePile, usize)> {
|
||||||
let state = self.session.state().state().state();
|
let state = self.session.state().state().state();
|
||||||
match instruction {
|
match instruction {
|
||||||
KlondikeInstruction::RotateStock => None,
|
KlondikeInstruction::RotateStock => {
|
||||||
|
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
|
||||||
|
}
|
||||||
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
||||||
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
|
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
|
||||||
return None;
|
return None;
|
||||||
@@ -605,12 +621,17 @@ impl GameState {
|
|||||||
KlondikePile::Stock => KlondikePile::Stock,
|
KlondikePile::Stock => KlondikePile::Stock,
|
||||||
KlondikePile::Foundation(_) => return None,
|
KlondikePile::Foundation(_) => return None,
|
||||||
};
|
};
|
||||||
Some((source, KlondikePile::Foundation(dst_foundation.foundation), 1))
|
Some((
|
||||||
|
source,
|
||||||
|
KlondikePile::Foundation(dst_foundation.foundation),
|
||||||
|
1,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
KlondikeInstruction::DstTableau(dst_tableau) => {
|
KlondikeInstruction::DstTableau(dst_tableau) => {
|
||||||
let (source, count) = match dst_tableau.src {
|
let (source, count) = match dst_tableau.src {
|
||||||
KlondikePileStack::Tableau(tableau_stack) => {
|
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)?;
|
let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?;
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return None;
|
return None;
|
||||||
@@ -633,16 +654,15 @@ impl GameState {
|
|||||||
return Err(MoveError::GameAlreadyWon);
|
return Err(MoveError::GameAlreadyWon);
|
||||||
}
|
}
|
||||||
|
|
||||||
let stock_empty = self
|
let stock_empty = self.stock_cards().is_empty();
|
||||||
.stock_cards()
|
|
||||||
.is_empty();
|
|
||||||
let waste_empty = self.waste_cards().is_empty();
|
let waste_empty = self.waste_cards().is_empty();
|
||||||
if stock_empty && waste_empty {
|
if stock_empty && waste_empty {
|
||||||
return Err(MoveError::StockEmpty);
|
return Err(MoveError::StockEmpty);
|
||||||
}
|
}
|
||||||
|
|
||||||
let recycling = stock_empty && !waste_empty;
|
let recycling = stock_empty && !waste_empty;
|
||||||
self.session.process_instruction(KlondikeInstruction::RotateStock);
|
self.session
|
||||||
|
.process_instruction(KlondikeInstruction::RotateStock);
|
||||||
|
|
||||||
if recycling {
|
if recycling {
|
||||||
self.recycle_count = self.recycle_count.saturating_add(1);
|
self.recycle_count = self.recycle_count.saturating_add(1);
|
||||||
@@ -692,9 +712,9 @@ impl GameState {
|
|||||||
return Err(MoveError::RuleViolation("move violates rules".into()));
|
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) {
|
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 {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
@@ -743,8 +763,7 @@ impl GameState {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let suit = pile[0].suit;
|
let suit = pile[0].suit;
|
||||||
pile
|
pile.iter()
|
||||||
.iter()
|
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.all(|(i, card)| card.suit == suit && card.rank.value() == i as u8 + 1)
|
.all(|(i, card)| card.suit == suit && card.rank.value() == i as u8 + 1)
|
||||||
}
|
}
|
||||||
@@ -779,18 +798,14 @@ impl GameState {
|
|||||||
self.session
|
self.session
|
||||||
.state()
|
.state()
|
||||||
.state()
|
.state()
|
||||||
.possible_instructions(&config)
|
.get_sorted_moves(&config)
|
||||||
|
.into_iter()
|
||||||
.filter_map(|instruction| self.instruction_to_move(instruction))
|
.filter_map(|instruction| self.instruction_to_move(instruction))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` when `move_cards(from, to, count)` would currently succeed.
|
/// Returns `true` when `move_cards(from, to, count)` would currently succeed.
|
||||||
pub fn can_move_cards(
|
pub fn can_move_cards(&self, from: &KlondikePile, to: &KlondikePile, count: usize) -> bool {
|
||||||
&self,
|
|
||||||
from: &KlondikePile,
|
|
||||||
to: &KlondikePile,
|
|
||||||
count: usize,
|
|
||||||
) -> bool {
|
|
||||||
if self.is_won || from == to {
|
if self.is_won || from == to {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -838,62 +853,21 @@ impl GameState {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let waste = KlondikePile::Stock;
|
self.possible_instructions()
|
||||||
if let Some(slot) = self
|
.into_iter()
|
||||||
.waste_cards()
|
.find_map(|(from, to, count)| {
|
||||||
.last()
|
if count != 1 {
|
||||||
.and_then(|card| self.foundation_slot_for(card))
|
return None;
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
} else if pile.first().map(|c| c.suit) == Some(card.suit) {
|
if matches!(from, KlondikePile::Foundation(_)) {
|
||||||
candidate = Some(slot);
|
return None;
|
||||||
break;
|
}
|
||||||
}
|
if matches!(to, KlondikePile::Foundation(_)) {
|
||||||
}
|
Some((from, to))
|
||||||
let target = candidate.or_else(|| {
|
} else {
|
||||||
if card.rank == Rank::Ace {
|
None
|
||||||
empty_slot
|
}
|
||||||
} else {
|
})
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
target.filter(|&slot| self.can_place_on_foundation_slot(card, slot))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
|
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
|
||||||
@@ -980,7 +954,8 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
game.possible_instructions()
|
game.possible_instructions()
|
||||||
.iter()
|
.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());
|
assert!(game.move_cards(from, to, 1).is_err());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,18 +49,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pile_top_returns_last_card() {
|
fn pile_top_returns_last_card() {
|
||||||
let mut pile = Pile::new(KlondikePile::Stock);
|
let mut pile = Pile::new(KlondikePile::Stock);
|
||||||
pile.cards.push(Card {
|
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
|
||||||
id: 0,
|
pile.cards.push(Card::face_up(1, Suit::Clubs, Rank::Two));
|
||||||
suit: Suit::Hearts,
|
|
||||||
rank: Rank::Ace,
|
|
||||||
face_up: true,
|
|
||||||
});
|
|
||||||
pile.cards.push(Card {
|
|
||||||
id: 1,
|
|
||||||
suit: Suit::Clubs,
|
|
||||||
rank: Rank::Two,
|
|
||||||
face_up: true,
|
|
||||||
});
|
|
||||||
assert_eq!(pile.top().unwrap().id, 1);
|
assert_eq!(pile.top().unwrap().id, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,30 +69,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn claimed_suit_is_none_for_non_foundation() {
|
fn claimed_suit_is_none_for_non_foundation() {
|
||||||
let mut pile = Pile::new(KlondikePile::Tableau(klondike::Tableau::Tableau1));
|
let mut pile = Pile::new(KlondikePile::Tableau(klondike::Tableau::Tableau1));
|
||||||
pile.cards.push(Card {
|
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
|
||||||
id: 0,
|
|
||||||
suit: Suit::Hearts,
|
|
||||||
rank: Rank::Ace,
|
|
||||||
face_up: true,
|
|
||||||
});
|
|
||||||
assert!(pile.claimed_suit().is_none());
|
assert!(pile.claimed_suit().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claimed_suit_returns_bottom_card_suit() {
|
fn claimed_suit_returns_bottom_card_suit() {
|
||||||
let mut pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation3));
|
let mut pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation3));
|
||||||
pile.cards.push(Card {
|
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
|
||||||
id: 0,
|
pile.cards.push(Card::face_up(1, Suit::Hearts, Rank::Two));
|
||||||
suit: Suit::Hearts,
|
|
||||||
rank: Rank::Ace,
|
|
||||||
face_up: true,
|
|
||||||
});
|
|
||||||
pile.cards.push(Card {
|
|
||||||
id: 1,
|
|
||||||
suit: Suit::Hearts,
|
|
||||||
rank: Rank::Two,
|
|
||||||
face_up: true,
|
|
||||||
});
|
|
||||||
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
|
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"**
|
//! 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.
|
//! 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::game_state::{DrawMode, GameState};
|
||||||
|
use crate::klondike_adapter::KlondikeAdapter;
|
||||||
use crate::card::Card;
|
|
||||||
use crate::game_state::{DifficultyLevel, DrawMode, GameMode, GameState};
|
|
||||||
|
|
||||||
/// Verdict returned by [`try_solve`].
|
/// Verdict returned by [`try_solve`].
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -59,14 +58,6 @@ pub struct SolveOutcome {
|
|||||||
pub first_move: Option<SolverMove>,
|
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`.
|
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`.
|
||||||
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
|
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
|
||||||
try_solve_with_first_move(seed, draw_mode, config).result
|
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,
|
first_move: None,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve the historical payload contract: winnable verdicts always carry
|
// Preserve the historical payload contract: winnable verdicts always carry
|
||||||
// a first move. An already-won state therefore returns no recommendation.
|
// a first move. An already-won state therefore returns no recommendation.
|
||||||
if initial.is_won {
|
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));
|
let solver_config = SessionConfig {
|
||||||
visited.insert(state_key(initial));
|
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;
|
match solver_session.solve() {
|
||||||
let mut moves_considered: u64 = 0;
|
Ok(Some(solution)) => {
|
||||||
let mut saw_inconclusive = false;
|
let first_move = solution
|
||||||
|
.raw_solution()
|
||||||
let mut stack = vec![DfsFrame {
|
.iter()
|
||||||
state: initial.clone(),
|
.find_map(snapshot_to_solver_move);
|
||||||
moves: candidate_moves(initial),
|
if let Some(first_move) = first_move {
|
||||||
next_index: 0,
|
SolveOutcome {
|
||||||
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 {
|
|
||||||
result: SolverResult::Winnable,
|
result: SolverResult::Winnable,
|
||||||
first_move: Some(first_move),
|
first_move: Some(first_move),
|
||||||
};
|
}
|
||||||
|
} else {
|
||||||
|
SolveOutcome {
|
||||||
|
result: SolverResult::Inconclusive,
|
||||||
|
first_move: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
stack.pop();
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
Ok(None) => SolveOutcome {
|
||||||
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 {
|
|
||||||
result: SolverResult::Unwinnable,
|
result: SolverResult::Unwinnable,
|
||||||
first_move: None,
|
first_move: None,
|
||||||
}
|
},
|
||||||
|
Err(SolveError::MovesBudgetExceeded | SolveError::StatesBudgetExceeded) => SolveOutcome {
|
||||||
|
result: SolverResult::Inconclusive,
|
||||||
|
first_move: None,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn candidate_moves(game: &GameState) -> Vec<SolverMove> {
|
fn snapshot_to_solver_move(snapshot: &StateSnapshot<Klondike>) -> Option<SolverMove> {
|
||||||
let mut out: Vec<SolverMove> = game
|
let source_state = snapshot.state().state();
|
||||||
.possible_instructions()
|
match *snapshot.instruction() {
|
||||||
.into_iter()
|
KlondikeInstruction::RotateStock => Some(SolverMove {
|
||||||
.map(|(source, dest, count)| SolverMove {
|
|
||||||
source,
|
|
||||||
dest,
|
|
||||||
count,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if !game.stock_cards().is_empty() || !game.waste_cards().is_empty() {
|
|
||||||
out.push(SolverMove {
|
|
||||||
source: KlondikePile::Stock,
|
source: KlondikePile::Stock,
|
||||||
dest: KlondikePile::Stock,
|
dest: KlondikePile::Stock,
|
||||||
count: 1,
|
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
|
Some(SolverMove {
|
||||||
}
|
source,
|
||||||
|
dest: KlondikePile::Tableau(dst_tableau.tableau),
|
||||||
fn apply_solver_move(game: &GameState, mv: &SolverMove) -> Option<GameState> {
|
count,
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power
|
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power
|
||||||
//! loss during a write never corrupts the saved data.
|
//! loss during a write never corrupts the saved data.
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use chrono::Utc;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState};
|
use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState};
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
//! without matching on [`SyncBackend`] anywhere else in the codebase.
|
//! without matching on [`SyncBackend`] anywhere else in the codebase.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use solitaire_sync::{SyncPayload, SyncResponse};
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use solitaire_sync::{ChallengeGoal, LeaderboardEntry};
|
use solitaire_sync::{ChallengeGoal, LeaderboardEntry};
|
||||||
|
use solitaire_sync::{SyncPayload, SyncResponse};
|
||||||
|
|
||||||
use crate::{SyncError, SyncProvider};
|
use crate::{SyncError, SyncProvider};
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -122,6 +122,67 @@ export class SolitaireGame {
|
|||||||
const ret = wasm.solitairegame_auto_complete_step(this.__wbg_ptr);
|
const ret = wasm.solitairegame_auto_complete_step(this.__wbg_ptr);
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Applies the legal move currently at `index` from `debug_legal_moves()`.
|
||||||
|
* @param {number} index
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
debug_apply_legal_move(index) {
|
||||||
|
const ret = wasm.solitairegame_debug_apply_legal_move(this.__wbg_ptr, index);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Applies one debug move encoded as JSON.
|
||||||
|
*
|
||||||
|
* JSON must match [`DebugMove`], for example:
|
||||||
|
* `{"kind":"move","from":"tableau-0","to":"foundation-1","count":1}` or
|
||||||
|
* `{"kind":"stock_click"}`.
|
||||||
|
* @param {string} move_json
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
debug_apply_move_json(move_json) {
|
||||||
|
const ptr0 = passStringToWasm0(move_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.solitairegame_debug_apply_move_json(this.__wbg_ptr, ptr0, len0);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Returns all currently-legal debug moves as a JS array.
|
||||||
|
*
|
||||||
|
* Includes [`DebugMove::StockClick`] when stock interaction is legal.
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
debug_legal_moves() {
|
||||||
|
const ret = wasm.solitairegame_debug_legal_moves(this.__wbg_ptr);
|
||||||
|
if (ret[2]) {
|
||||||
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
|
}
|
||||||
|
return takeFromExternrefTable0(ret[0]);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Returns deterministic instruction history for the current game.
|
||||||
|
*
|
||||||
|
* Together with `seed()` and `draw_mode`, this history is replayable.
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
debug_move_history() {
|
||||||
|
const ret = wasm.solitairegame_debug_move_history(this.__wbg_ptr);
|
||||||
|
if (ret[2]) {
|
||||||
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
|
}
|
||||||
|
return takeFromExternrefTable0(ret[0]);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Returns a comprehensive debug snapshot for automated verification.
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
debug_snapshot() {
|
||||||
|
const ret = wasm.solitairegame_debug_snapshot(this.__wbg_ptr);
|
||||||
|
if (ret[2]) {
|
||||||
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
|
}
|
||||||
|
return takeFromExternrefTable0(ret[0]);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Draw from stock to waste (or recycle waste → stock when stock is empty).
|
* Draw from stock to waste (or recycle waste → stock when stock is empty).
|
||||||
* Returns `{ok, error?, snapshot?}`.
|
* Returns `{ok, error?, snapshot?}`.
|
||||||
@@ -182,6 +243,21 @@ export class SolitaireGame {
|
|||||||
SolitaireGameFinalization.register(this, this.__wbg_ptr, this);
|
SolitaireGameFinalization.register(this, this.__wbg_ptr, this);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Returns replay moves encoded in the `solitaire_data::Replay` wire format.
|
||||||
|
*
|
||||||
|
* This derives move counts from the deterministic instruction history and
|
||||||
|
* validates that the resulting move stream replays cleanly from the current
|
||||||
|
* game's seed/draw mode.
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
replay_moves() {
|
||||||
|
const ret = wasm.solitairegame_replay_moves(this.__wbg_ptr);
|
||||||
|
if (ret[2]) {
|
||||||
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
|
}
|
||||||
|
return takeFromExternrefTable0(ret[0]);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* The seed used to deal this game.
|
* The seed used to deal this game.
|
||||||
* @returns {number}
|
* @returns {number}
|
||||||
|
|||||||
Binary file not shown.
+629
-12
@@ -24,7 +24,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
use solitaire_core::card::Suit;
|
use solitaire_core::card::Suit;
|
||||||
use solitaire_core::error::MoveError;
|
use solitaire_core::error::MoveError;
|
||||||
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
||||||
use solitaire_core::klondike_adapter::SavedKlondikePile;
|
use solitaire_core::klondike_adapter::{
|
||||||
|
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, tableau_from_index,
|
||||||
|
};
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
/// Mirrors the variants of `solitaire_data::ReplayMove` v2 (atomic
|
/// Mirrors the variants of `solitaire_data::ReplayMove` v2 (atomic
|
||||||
@@ -55,7 +57,7 @@ pub struct Replay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// JS-friendly snapshot of a `GameState` at a particular replay step.
|
/// JS-friendly snapshot of a `GameState` at a particular replay step.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
pub struct StateSnapshot {
|
pub struct StateSnapshot {
|
||||||
pub step_idx: usize,
|
pub step_idx: usize,
|
||||||
pub total_steps: usize,
|
pub total_steps: usize,
|
||||||
@@ -75,7 +77,7 @@ pub struct StateSnapshot {
|
|||||||
/// means the card back is drawn; in that case `suit` and `rank` are
|
/// means the card back is drawn; in that case `suit` and `rank` are
|
||||||
/// still set (so the renderer doesn't need separate "unknown" data),
|
/// still set (so the renderer doesn't need separate "unknown" data),
|
||||||
/// just hidden visually.
|
/// just hidden visually.
|
||||||
#[derive(Debug, Clone, Copy, Serialize)]
|
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||||
pub struct CardSnapshot {
|
pub struct CardSnapshot {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
/// `"clubs" | "diamonds" | "hearts" | "spades"`.
|
/// `"clubs" | "diamonds" | "hearts" | "spades"`.
|
||||||
@@ -157,8 +159,9 @@ impl ReplayPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn snapshot(&self) -> StateSnapshot {
|
fn snapshot(&self) -> StateSnapshot {
|
||||||
let pile_cards =
|
let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
|
||||||
|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] = [
|
||||||
pile_cards(KlondikePile::Foundation(Foundation::Foundation1)),
|
pile_cards(KlondikePile::Foundation(Foundation::Foundation1)),
|
||||||
pile_cards(KlondikePile::Foundation(Foundation::Foundation2)),
|
pile_cards(KlondikePile::Foundation(Foundation::Foundation2)),
|
||||||
@@ -180,8 +183,18 @@ impl ReplayPlayer {
|
|||||||
score: self.game.score,
|
score: self.game.score,
|
||||||
move_count: self.game.move_count,
|
move_count: self.game.move_count,
|
||||||
is_won: self.game.is_won,
|
is_won: self.game.is_won,
|
||||||
stock: self.game.stock_cards().iter().map(CardSnapshot::from).collect(),
|
stock: self
|
||||||
waste: self.game.waste_cards().iter().map(CardSnapshot::from).collect(),
|
.game
|
||||||
|
.stock_cards()
|
||||||
|
.iter()
|
||||||
|
.map(CardSnapshot::from)
|
||||||
|
.collect(),
|
||||||
|
waste: self
|
||||||
|
.game
|
||||||
|
.waste_cards()
|
||||||
|
.iter()
|
||||||
|
.map(CardSnapshot::from)
|
||||||
|
.collect(),
|
||||||
foundations,
|
foundations,
|
||||||
tableaus,
|
tableaus,
|
||||||
}
|
}
|
||||||
@@ -252,7 +265,7 @@ impl ReplayPlayer {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Full snapshot of a live `SolitaireGame` for the JS renderer.
|
/// Full snapshot of a live `SolitaireGame` for the JS renderer.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
pub struct GameSnapshot {
|
pub struct GameSnapshot {
|
||||||
pub score: i32,
|
pub score: i32,
|
||||||
pub move_count: u32,
|
pub move_count: u32,
|
||||||
@@ -279,6 +292,174 @@ pub struct ActionResult {
|
|||||||
pub snapshot: Option<GameSnapshot>,
|
pub snapshot: Option<GameSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Debug action understood by the automation-oriented debug API.
|
||||||
|
///
|
||||||
|
/// This mirrors legal player inputs and is intentionally independent from DOM
|
||||||
|
/// or pointer coordinates so test runners can drive the engine directly.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum DebugMove {
|
||||||
|
Move {
|
||||||
|
from: String,
|
||||||
|
to: String,
|
||||||
|
count: usize,
|
||||||
|
},
|
||||||
|
StockClick,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invariant report returned by the debug API after each step.
|
||||||
|
///
|
||||||
|
/// `state_ok` is `true` when no structural violations were detected.
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
pub struct DebugInvariantReport {
|
||||||
|
pub state_ok: bool,
|
||||||
|
pub total_cards_seen: usize,
|
||||||
|
pub duplicate_card_ids: Vec<u32>,
|
||||||
|
pub missing_card_ids: Vec<u32>,
|
||||||
|
pub out_of_range_card_ids: Vec<u32>,
|
||||||
|
pub stock_has_face_up_cards: bool,
|
||||||
|
pub waste_has_face_down_cards: bool,
|
||||||
|
pub foundation_has_face_down_cards: bool,
|
||||||
|
pub tableau_visibility_violation: bool,
|
||||||
|
pub soft_lock: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full debug snapshot for engine-integration and browser automation tests.
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
pub struct DebugSnapshot {
|
||||||
|
pub seed: u64,
|
||||||
|
pub draw_mode: DrawMode,
|
||||||
|
pub mode: GameMode,
|
||||||
|
pub state: GameSnapshot,
|
||||||
|
pub legal_moves: Vec<DebugMove>,
|
||||||
|
pub move_history: Vec<SavedInstruction>,
|
||||||
|
pub invariants: DebugInvariantReport,
|
||||||
|
pub state_json: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pile_name(pile: KlondikePile) -> String {
|
||||||
|
match pile {
|
||||||
|
KlondikePile::Stock => "stock".to_string(),
|
||||||
|
KlondikePile::Foundation(f) => format!("foundation-{}", f as u8),
|
||||||
|
KlondikePile::Tableau(t) => format!("tableau-{}", t as u8),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_stock_click(game: &GameState) -> bool {
|
||||||
|
!(game.is_won || game.stock_cards().is_empty() && game.waste_cards().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legal_moves_for_game(game: &GameState) -> Vec<DebugMove> {
|
||||||
|
let mut moves: Vec<DebugMove> = game
|
||||||
|
.possible_instructions()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(from, to, count)| DebugMove::Move {
|
||||||
|
from: pile_name(from),
|
||||||
|
to: pile_name(to),
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if can_stock_click(game) {
|
||||||
|
moves.push(DebugMove::StockClick);
|
||||||
|
}
|
||||||
|
moves
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport {
|
||||||
|
let stock = game.stock_cards();
|
||||||
|
let waste = game.waste_cards();
|
||||||
|
let foundations = [
|
||||||
|
game.pile(KlondikePile::Foundation(Foundation::Foundation1)),
|
||||||
|
game.pile(KlondikePile::Foundation(Foundation::Foundation2)),
|
||||||
|
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 = [false; 52];
|
||||||
|
let mut duplicate_card_ids = Vec::new();
|
||||||
|
let mut out_of_range_card_ids = Vec::new();
|
||||||
|
let mut total_cards_seen = 0_usize;
|
||||||
|
|
||||||
|
let mut feed = |cards: &[solitaire_core::card::Card]| {
|
||||||
|
for card in cards {
|
||||||
|
total_cards_seen += 1;
|
||||||
|
if card.id >= 52 {
|
||||||
|
out_of_range_card_ids.push(card.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let idx = card.id as usize;
|
||||||
|
if seen[idx] {
|
||||||
|
duplicate_card_ids.push(card.id);
|
||||||
|
} else {
|
||||||
|
seen[idx] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
feed(&stock);
|
||||||
|
feed(&waste);
|
||||||
|
for pile in &foundations {
|
||||||
|
feed(pile);
|
||||||
|
}
|
||||||
|
for pile in &tableaus {
|
||||||
|
feed(pile);
|
||||||
|
}
|
||||||
|
|
||||||
|
let missing_card_ids = (0_u32..52_u32)
|
||||||
|
.filter(|id| !seen[*id as usize])
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let stock_has_face_up_cards = stock.iter().any(|c| c.face_up);
|
||||||
|
let waste_has_face_down_cards = waste.iter().any(|c| !c.face_up);
|
||||||
|
let foundation_has_face_down_cards = foundations
|
||||||
|
.iter()
|
||||||
|
.any(|pile| pile.iter().any(|c| !c.face_up));
|
||||||
|
|
||||||
|
let tableau_visibility_violation = tableaus.iter().any(|pile| {
|
||||||
|
let mut seen_face_up = false;
|
||||||
|
for card in pile {
|
||||||
|
if card.face_up {
|
||||||
|
seen_face_up = true;
|
||||||
|
} else if seen_face_up {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
});
|
||||||
|
|
||||||
|
let soft_lock = !game.is_won && stock.is_empty() && waste.is_empty() && legal_moves.is_empty();
|
||||||
|
|
||||||
|
let state_ok = duplicate_card_ids.is_empty()
|
||||||
|
&& missing_card_ids.is_empty()
|
||||||
|
&& out_of_range_card_ids.is_empty()
|
||||||
|
&& !stock_has_face_up_cards
|
||||||
|
&& !waste_has_face_down_cards
|
||||||
|
&& !foundation_has_face_down_cards
|
||||||
|
&& !tableau_visibility_violation;
|
||||||
|
|
||||||
|
DebugInvariantReport {
|
||||||
|
state_ok,
|
||||||
|
total_cards_seen,
|
||||||
|
duplicate_card_ids,
|
||||||
|
missing_card_ids,
|
||||||
|
out_of_range_card_ids,
|
||||||
|
stock_has_face_up_cards,
|
||||||
|
waste_has_face_down_cards,
|
||||||
|
foundation_has_face_down_cards,
|
||||||
|
tableau_visibility_violation,
|
||||||
|
soft_lock,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Interactive Klondike game backed by the real `solitaire_core` rules engine.
|
/// Interactive Klondike game backed by the real `solitaire_core` rules engine.
|
||||||
///
|
///
|
||||||
/// Construct with `new(seed, draw_three)`, then call `draw()`, `move_cards()`,
|
/// Construct with `new(seed, draw_three)`, then call `draw()`, `move_cards()`,
|
||||||
@@ -291,8 +472,9 @@ pub struct SolitaireGame {
|
|||||||
|
|
||||||
impl SolitaireGame {
|
impl SolitaireGame {
|
||||||
fn snap(&self) -> GameSnapshot {
|
fn snap(&self) -> GameSnapshot {
|
||||||
let cards =
|
let cards = |t: KlondikePile| -> Vec<CardSnapshot> {
|
||||||
|t: KlondikePile| -> Vec<CardSnapshot> { self.game.pile(t).iter().map(CardSnapshot::from).collect() };
|
self.game.pile(t).iter().map(CardSnapshot::from).collect()
|
||||||
|
};
|
||||||
let has_moves = {
|
let has_moves = {
|
||||||
let stock_empty = self.game.stock_cards().is_empty();
|
let stock_empty = self.game.stock_cards().is_empty();
|
||||||
let waste_empty = self.game.waste_cards().is_empty();
|
let waste_empty = self.game.waste_cards().is_empty();
|
||||||
@@ -306,8 +488,18 @@ impl SolitaireGame {
|
|||||||
has_moves,
|
has_moves,
|
||||||
undo_count: self.game.undo_count,
|
undo_count: self.game.undo_count,
|
||||||
undo_stack_len: self.game.undo_stack_len(),
|
undo_stack_len: self.game.undo_stack_len(),
|
||||||
stock: self.game.stock_cards().iter().map(CardSnapshot::from).collect(),
|
stock: self
|
||||||
waste: self.game.waste_cards().iter().map(CardSnapshot::from).collect(),
|
.game
|
||||||
|
.stock_cards()
|
||||||
|
.iter()
|
||||||
|
.map(CardSnapshot::from)
|
||||||
|
.collect(),
|
||||||
|
waste: self
|
||||||
|
.game
|
||||||
|
.waste_cards()
|
||||||
|
.iter()
|
||||||
|
.map(CardSnapshot::from)
|
||||||
|
.collect(),
|
||||||
foundations: [
|
foundations: [
|
||||||
cards(KlondikePile::Foundation(Foundation::Foundation1)),
|
cards(KlondikePile::Foundation(Foundation::Foundation1)),
|
||||||
cards(KlondikePile::Foundation(Foundation::Foundation2)),
|
cards(KlondikePile::Foundation(Foundation::Foundation2)),
|
||||||
@@ -366,6 +558,138 @@ impl SolitaireGame {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn legal_moves_native(&self) -> Vec<DebugMove> {
|
||||||
|
legal_moves_for_game(&self.game)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_history_native(&self) -> Vec<SavedInstruction> {
|
||||||
|
self.game.instruction_history()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replay_moves_native(&self) -> Result<Vec<ReplayMove>, String> {
|
||||||
|
let mut replay_game =
|
||||||
|
GameState::new_with_mode(self.game.seed, self.game.draw_mode, self.game.mode);
|
||||||
|
let mut replay_moves = Vec::new();
|
||||||
|
|
||||||
|
for instruction in self.game.instruction_history() {
|
||||||
|
let replay_move = match instruction {
|
||||||
|
SavedInstruction::RotateStock => ReplayMove::StockClick,
|
||||||
|
SavedInstruction::DstFoundation(dst) => ReplayMove::Move {
|
||||||
|
from: dst.src,
|
||||||
|
to: SavedKlondikePile::Foundation(dst.foundation),
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
SavedInstruction::DstTableau(dst) => {
|
||||||
|
let (from, count) = match dst.src {
|
||||||
|
SavedKlondikePileStack::Stock => (SavedKlondikePile::Stock, 1),
|
||||||
|
SavedKlondikePileStack::Foundation(foundation) => {
|
||||||
|
(SavedKlondikePile::Foundation(foundation), 1)
|
||||||
|
}
|
||||||
|
SavedKlondikePileStack::Tableau(tableau_stack) => {
|
||||||
|
let tableau =
|
||||||
|
tableau_from_index(tableau_stack.tableau.0 as usize).ok_or_else(
|
||||||
|
|| {
|
||||||
|
format!(
|
||||||
|
"invalid tableau index in move history: {}",
|
||||||
|
tableau_stack.tableau.0
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let face_up_count = replay_game
|
||||||
|
.pile(KlondikePile::Tableau(tableau))
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take_while(|card| card.face_up)
|
||||||
|
.count();
|
||||||
|
let skip = tableau_stack.skip_cards.0 as usize;
|
||||||
|
let count = face_up_count.checked_sub(skip).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"invalid tableau skip in move history: face_up={face_up_count}, skip={skip}"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if count == 0 {
|
||||||
|
return Err(
|
||||||
|
"invalid tableau move in move history: zero-card move".into()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(SavedKlondikePile::Tableau(tableau_stack.tableau), count)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ReplayMove::Move {
|
||||||
|
from,
|
||||||
|
to: SavedKlondikePile::Tableau(dst.tableau),
|
||||||
|
count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match &replay_move {
|
||||||
|
ReplayMove::StockClick => replay_game
|
||||||
|
.draw()
|
||||||
|
.map_err(|e| format!("failed to apply stock click while exporting replay: {e}"))?,
|
||||||
|
ReplayMove::Move { from, to, count } => {
|
||||||
|
let src: KlondikePile = (*from)
|
||||||
|
.try_into()
|
||||||
|
.map_err(|e| format!("invalid replay source pile: {e}"))?;
|
||||||
|
let dst: KlondikePile = (*to)
|
||||||
|
.try_into()
|
||||||
|
.map_err(|e| format!("invalid replay destination pile: {e}"))?;
|
||||||
|
replay_game.move_cards(src, dst, *count).map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"failed to apply move while exporting replay ({from:?} -> {to:?}, count={count}): {e}"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replay_moves.push(replay_move);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(replay_moves)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn debug_snapshot_native(&self) -> DebugSnapshot {
|
||||||
|
let legal_moves = self.legal_moves_native();
|
||||||
|
let invariants = invariant_report_for_game(&self.game, &legal_moves);
|
||||||
|
let state_json = serde_json::to_string(&self.game).unwrap_or_default();
|
||||||
|
DebugSnapshot {
|
||||||
|
seed: self.game.seed,
|
||||||
|
draw_mode: self.game.draw_mode,
|
||||||
|
mode: self.game.mode,
|
||||||
|
state: self.snap(),
|
||||||
|
legal_moves,
|
||||||
|
move_history: self.move_history_native(),
|
||||||
|
invariants,
|
||||||
|
state_json,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_debug_move_native(&mut self, mv: &DebugMove) -> Result<(), String> {
|
||||||
|
match mv {
|
||||||
|
DebugMove::StockClick => self.game.draw().map_err(|e| e.to_string()),
|
||||||
|
DebugMove::Move { from, to, count } => {
|
||||||
|
let from_pile = Self::pile_from_str(from)?;
|
||||||
|
let to_pile = Self::pile_from_str(to)?;
|
||||||
|
if from_pile == KlondikePile::Stock && to_pile == KlondikePile::Stock {
|
||||||
|
self.game.draw().map_err(|e| e.to_string())
|
||||||
|
} else {
|
||||||
|
self.game
|
||||||
|
.move_cards(from_pile, to_pile, *count)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_legal_move_native(&mut self, index: usize) -> Result<(), String> {
|
||||||
|
let legal_moves = self.legal_moves_native();
|
||||||
|
let mv = legal_moves
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| format!("legal move index out of range: {index}"))?
|
||||||
|
.clone();
|
||||||
|
self.apply_debug_move_native(&mv)
|
||||||
|
}
|
||||||
|
|
||||||
fn ok_js(&self) -> JsValue {
|
fn ok_js(&self) -> JsValue {
|
||||||
serde_wasm_bindgen::to_value(&ActionResult {
|
serde_wasm_bindgen::to_value(&ActionResult {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -497,4 +821,297 @@ impl SolitaireGame {
|
|||||||
Err(_) => JsValue::NULL,
|
Err(_) => JsValue::NULL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns replay moves encoded in the `solitaire_data::Replay` wire format.
|
||||||
|
///
|
||||||
|
/// This derives move counts from the deterministic instruction history and
|
||||||
|
/// validates that the resulting move stream replays cleanly from the current
|
||||||
|
/// game's seed/draw mode.
|
||||||
|
pub fn replay_moves(&self) -> Result<JsValue, JsValue> {
|
||||||
|
let moves = self
|
||||||
|
.replay_moves_native()
|
||||||
|
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||||
|
serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all currently-legal debug moves as a JS array.
|
||||||
|
///
|
||||||
|
/// Includes [`DebugMove::StockClick`] when stock interaction is legal.
|
||||||
|
pub fn debug_legal_moves(&self) -> Result<JsValue, JsValue> {
|
||||||
|
serde_wasm_bindgen::to_value(&self.legal_moves_native())
|
||||||
|
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns deterministic instruction history for the current game.
|
||||||
|
///
|
||||||
|
/// Together with `seed()` and `draw_mode`, this history is replayable.
|
||||||
|
pub fn debug_move_history(&self) -> Result<JsValue, JsValue> {
|
||||||
|
serde_wasm_bindgen::to_value(&self.move_history_native())
|
||||||
|
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a comprehensive debug snapshot for automated verification.
|
||||||
|
pub fn debug_snapshot(&self) -> Result<JsValue, JsValue> {
|
||||||
|
serde_wasm_bindgen::to_value(&self.debug_snapshot_native())
|
||||||
|
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the legal move currently at `index` from `debug_legal_moves()`.
|
||||||
|
pub fn debug_apply_legal_move(&mut self, index: usize) -> JsValue {
|
||||||
|
match self.apply_legal_move_native(index) {
|
||||||
|
Ok(()) => self.ok_js(),
|
||||||
|
Err(e) => Self::err_js(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies one debug move encoded as JSON.
|
||||||
|
///
|
||||||
|
/// JSON must match [`DebugMove`], for example:
|
||||||
|
/// `{"kind":"move","from":"tableau-0","to":"foundation-1","count":1}` or
|
||||||
|
/// `{"kind":"stock_click"}`.
|
||||||
|
pub fn debug_apply_move_json(&mut self, move_json: &str) -> JsValue {
|
||||||
|
let parsed = match serde_json::from_str::<DebugMove>(move_json) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(e) => return Self::err_js(format!("invalid debug move JSON: {e}")),
|
||||||
|
};
|
||||||
|
match self.apply_debug_move_native(&parsed) {
|
||||||
|
Ok(()) => self.ok_js(),
|
||||||
|
Err(e) => Self::err_js(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
fn pick_move_index(moves: &[DebugMove]) -> Option<usize> {
|
||||||
|
if moves.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some((idx, _)) = moves.iter().enumerate().find(|(_, m)| {
|
||||||
|
matches!(
|
||||||
|
m,
|
||||||
|
DebugMove::Move {
|
||||||
|
to,
|
||||||
|
count: 1,
|
||||||
|
..
|
||||||
|
} if to.starts_with("foundation-")
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
return Some(idx);
|
||||||
|
}
|
||||||
|
if let Some((idx, _)) = moves
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find(|(_, m)| matches!(m, DebugMove::Move { .. }))
|
||||||
|
{
|
||||||
|
return Some(idx);
|
||||||
|
}
|
||||||
|
Some(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_invariants(snapshot: &DebugSnapshot, seed: u64) {
|
||||||
|
assert!(
|
||||||
|
snapshot.invariants.state_ok,
|
||||||
|
"state invariant failure (seed={seed}): {:?}",
|
||||||
|
snapshot.invariants
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn board_key(state: &GameSnapshot) -> String {
|
||||||
|
let mut key = String::new();
|
||||||
|
let mut push_cards = |cards: &[CardSnapshot]| {
|
||||||
|
for card in cards {
|
||||||
|
let _ = write!(
|
||||||
|
key,
|
||||||
|
"{}:{}:{},",
|
||||||
|
card.id,
|
||||||
|
card.rank,
|
||||||
|
if card.face_up { 1 } else { 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
key.push('|');
|
||||||
|
};
|
||||||
|
push_cards(&state.stock);
|
||||||
|
push_cards(&state.waste);
|
||||||
|
for pile in &state.foundations {
|
||||||
|
push_cards(pile);
|
||||||
|
}
|
||||||
|
for pile in &state.tableaus {
|
||||||
|
push_cards(pile);
|
||||||
|
}
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_autonomous(seed: u64, draw_mode: DrawMode, max_steps: usize) -> DebugSnapshot {
|
||||||
|
let mut game = SolitaireGame {
|
||||||
|
game: GameState::new_with_mode(seed, draw_mode, GameMode::Classic),
|
||||||
|
};
|
||||||
|
let mut last_snapshot = game.debug_snapshot_native();
|
||||||
|
let mut seen_states = HashSet::new();
|
||||||
|
seen_states.insert(board_key(&last_snapshot.state));
|
||||||
|
assert_invariants(&last_snapshot, seed);
|
||||||
|
|
||||||
|
for step in 0..max_steps {
|
||||||
|
if last_snapshot.state.is_won || last_snapshot.legal_moves.is_empty() {
|
||||||
|
return last_snapshot;
|
||||||
|
}
|
||||||
|
let idx = pick_move_index(&last_snapshot.legal_moves).unwrap_or_default();
|
||||||
|
if let Err(e) = game.apply_legal_move_native(idx) {
|
||||||
|
panic!("failed to apply legal move (seed={seed}, step={step}, idx={idx}): {e}");
|
||||||
|
}
|
||||||
|
last_snapshot = game.debug_snapshot_native();
|
||||||
|
if !seen_states.insert(board_key(&last_snapshot.state)) {
|
||||||
|
// Deterministic autoplay returned to an earlier state.
|
||||||
|
// Treat as a terminal non-winning run, not a harness failure.
|
||||||
|
return last_snapshot;
|
||||||
|
}
|
||||||
|
assert_invariants(&last_snapshot, seed);
|
||||||
|
}
|
||||||
|
panic!("autonomous run exceeded step budget (seed={seed}, max_steps={max_steps})");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debug_snapshot_exposes_replayable_seed_and_history() {
|
||||||
|
let seed = 42_u64;
|
||||||
|
let final_snapshot = run_autonomous(seed, DrawMode::DrawOne, 1500);
|
||||||
|
assert_eq!(final_snapshot.seed, seed);
|
||||||
|
assert!(
|
||||||
|
!final_snapshot.state_json.is_empty(),
|
||||||
|
"debug snapshot must include serialised current state"
|
||||||
|
);
|
||||||
|
let restored = match SolitaireGame::from_saved(&final_snapshot.state_json) {
|
||||||
|
Ok(game) => game,
|
||||||
|
Err(err) => panic!("failed to restore debug snapshot state: {err:?}"),
|
||||||
|
};
|
||||||
|
let restored_snapshot = restored.debug_snapshot_native();
|
||||||
|
assert_eq!(restored_snapshot.state, final_snapshot.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_moves_export_is_json_compatible_and_replayable() {
|
||||||
|
let seed = 7_u64;
|
||||||
|
let draw_mode = DrawMode::DrawThree;
|
||||||
|
let mut game = SolitaireGame {
|
||||||
|
game: GameState::new_with_mode(seed, draw_mode, GameMode::Classic),
|
||||||
|
};
|
||||||
|
|
||||||
|
for step in 0..64 {
|
||||||
|
let legal_moves = game.legal_moves_native();
|
||||||
|
if legal_moves.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let idx = pick_move_index(&legal_moves).unwrap_or_default();
|
||||||
|
if let Err(e) = game.apply_legal_move_native(idx) {
|
||||||
|
panic!("failed to advance game before replay export (seed={seed}, step={step}, idx={idx}): {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let exported_moves = match game.replay_moves_native() {
|
||||||
|
Ok(moves) => moves,
|
||||||
|
Err(err) => panic!("replay export failed: {err}"),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
!exported_moves.is_empty(),
|
||||||
|
"progressed game must export a non-empty replay move list"
|
||||||
|
);
|
||||||
|
|
||||||
|
let moves_json = match serde_json::to_value(&exported_moves) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => panic!("failed to serialise exported replay moves: {err}"),
|
||||||
|
};
|
||||||
|
let array = match moves_json.as_array() {
|
||||||
|
Some(values) => values,
|
||||||
|
None => panic!("exported replay moves must serialise as a JSON array"),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
array.iter().all(|entry| {
|
||||||
|
entry.as_str() == Some("StockClick") || entry.get("Move").is_some()
|
||||||
|
}),
|
||||||
|
"replay move JSON must match ReplayMove wire shape"
|
||||||
|
);
|
||||||
|
|
||||||
|
let parsed_back: Vec<ReplayMove> = match serde_json::from_value(moves_json) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(err) => panic!("failed to parse replay move JSON as ReplayMove list: {err}"),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
parsed_back, exported_moves,
|
||||||
|
"replay move JSON must round-trip through ReplayMove"
|
||||||
|
);
|
||||||
|
|
||||||
|
let recorded_at = match NaiveDate::from_ymd_opt(2026, 6, 1) {
|
||||||
|
Some(date) => date,
|
||||||
|
None => panic!("invalid recorded_at date in test"),
|
||||||
|
};
|
||||||
|
let replay = Replay {
|
||||||
|
schema_version: 2,
|
||||||
|
seed,
|
||||||
|
draw_mode,
|
||||||
|
mode: GameMode::Classic,
|
||||||
|
time_seconds: 120,
|
||||||
|
final_score: game.game.score,
|
||||||
|
recorded_at,
|
||||||
|
moves: exported_moves,
|
||||||
|
};
|
||||||
|
let replay_json = match serde_json::to_string(&replay) {
|
||||||
|
Ok(json) => json,
|
||||||
|
Err(err) => panic!("failed to serialise replay JSON: {err}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut player = match ReplayPlayer::from_json(&replay_json) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => panic!("failed to construct replay player: {err}"),
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
match player.step_native() {
|
||||||
|
Ok(Some(_)) => {}
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(err) => panic!("replay player desynced while applying exported moves: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let original_state = match serde_json::to_string(&game.game) {
|
||||||
|
Ok(json) => json,
|
||||||
|
Err(err) => panic!("failed to serialise original game state: {err}"),
|
||||||
|
};
|
||||||
|
let replayed_state = match serde_json::to_string(&player.game) {
|
||||||
|
Ok(json) => json,
|
||||||
|
Err(err) => panic!("failed to serialise replayed game state: {err}"),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
replayed_state, original_state,
|
||||||
|
"replayed state must match the live state the moves were exported from"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debug_api_autonomous_seed_batch_smoke() {
|
||||||
|
for seed in 0_u64..128_u64 {
|
||||||
|
let draw_mode = if seed % 2 == 0 {
|
||||||
|
DrawMode::DrawOne
|
||||||
|
} else {
|
||||||
|
DrawMode::DrawThree
|
||||||
|
};
|
||||||
|
let snapshot = run_autonomous(seed, draw_mode, 2000);
|
||||||
|
assert_invariants(&snapshot, seed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "long-running soak for unattended CI pipelines"]
|
||||||
|
fn debug_api_autonomous_thousands_seed_soak() {
|
||||||
|
for seed in 10_000_u64..12_000_u64 {
|
||||||
|
let draw_mode = if seed % 2 == 0 {
|
||||||
|
DrawMode::DrawOne
|
||||||
|
} else {
|
||||||
|
DrawMode::DrawThree
|
||||||
|
};
|
||||||
|
let snapshot = run_autonomous(seed, draw_mode, 3000);
|
||||||
|
assert_invariants(&snapshot, seed);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user