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:
+629
-12
@@ -24,7 +24,9 @@ use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::card::Suit;
|
||||
use solitaire_core::error::MoveError;
|
||||
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::*;
|
||||
|
||||
/// 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.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct StateSnapshot {
|
||||
pub step_idx: 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
|
||||
/// still set (so the renderer doesn't need separate "unknown" data),
|
||||
/// just hidden visually.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
pub struct CardSnapshot {
|
||||
pub id: u32,
|
||||
/// `"clubs" | "diamonds" | "hearts" | "spades"`.
|
||||
@@ -157,8 +159,9 @@ impl ReplayPlayer {
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> StateSnapshot {
|
||||
let pile_cards =
|
||||
|t: KlondikePile| -> Vec<CardSnapshot> { self.game.pile(t).iter().map(CardSnapshot::from).collect() };
|
||||
let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
|
||||
self.game.pile(t).iter().map(CardSnapshot::from).collect()
|
||||
};
|
||||
let foundations: [Vec<CardSnapshot>; 4] = [
|
||||
pile_cards(KlondikePile::Foundation(Foundation::Foundation1)),
|
||||
pile_cards(KlondikePile::Foundation(Foundation::Foundation2)),
|
||||
@@ -180,8 +183,18 @@ impl ReplayPlayer {
|
||||
score: self.game.score,
|
||||
move_count: self.game.move_count,
|
||||
is_won: self.game.is_won,
|
||||
stock: self.game.stock_cards().iter().map(CardSnapshot::from).collect(),
|
||||
waste: self.game.waste_cards().iter().map(CardSnapshot::from).collect(),
|
||||
stock: self
|
||||
.game
|
||||
.stock_cards()
|
||||
.iter()
|
||||
.map(CardSnapshot::from)
|
||||
.collect(),
|
||||
waste: self
|
||||
.game
|
||||
.waste_cards()
|
||||
.iter()
|
||||
.map(CardSnapshot::from)
|
||||
.collect(),
|
||||
foundations,
|
||||
tableaus,
|
||||
}
|
||||
@@ -252,7 +265,7 @@ impl ReplayPlayer {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Full snapshot of a live `SolitaireGame` for the JS renderer.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct GameSnapshot {
|
||||
pub score: i32,
|
||||
pub move_count: u32,
|
||||
@@ -279,6 +292,174 @@ pub struct ActionResult {
|
||||
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.
|
||||
///
|
||||
/// Construct with `new(seed, draw_three)`, then call `draw()`, `move_cards()`,
|
||||
@@ -291,8 +472,9 @@ pub struct SolitaireGame {
|
||||
|
||||
impl SolitaireGame {
|
||||
fn snap(&self) -> GameSnapshot {
|
||||
let cards =
|
||||
|t: KlondikePile| -> Vec<CardSnapshot> { self.game.pile(t).iter().map(CardSnapshot::from).collect() };
|
||||
let cards = |t: KlondikePile| -> Vec<CardSnapshot> {
|
||||
self.game.pile(t).iter().map(CardSnapshot::from).collect()
|
||||
};
|
||||
let has_moves = {
|
||||
let stock_empty = self.game.stock_cards().is_empty();
|
||||
let waste_empty = self.game.waste_cards().is_empty();
|
||||
@@ -306,8 +488,18 @@ impl SolitaireGame {
|
||||
has_moves,
|
||||
undo_count: self.game.undo_count,
|
||||
undo_stack_len: self.game.undo_stack_len(),
|
||||
stock: self.game.stock_cards().iter().map(CardSnapshot::from).collect(),
|
||||
waste: self.game.waste_cards().iter().map(CardSnapshot::from).collect(),
|
||||
stock: self
|
||||
.game
|
||||
.stock_cards()
|
||||
.iter()
|
||||
.map(CardSnapshot::from)
|
||||
.collect(),
|
||||
waste: self
|
||||
.game
|
||||
.waste_cards()
|
||||
.iter()
|
||||
.map(CardSnapshot::from)
|
||||
.collect(),
|
||||
foundations: [
|
||||
cards(KlondikePile::Foundation(Foundation::Foundation1)),
|
||||
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 {
|
||||
serde_wasm_bindgen::to_value(&ActionResult {
|
||||
ok: true,
|
||||
@@ -497,4 +821,297 @@ impl SolitaireGame {
|
||||
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