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:
funman300
2026-06-02 12:21:20 -07:00
parent 9ff0585454
commit baf524ec75
12 changed files with 936 additions and 345 deletions
+107 -132
View File
@@ -1,6 +1,11 @@
use crate::card::{Card, Rank};
use crate::card::Card;
use crate::error::MoveError;
use crate::klondike_adapter::{card_from_kl, compute_time_bonus as scoring_time_bonus, KlondikeAdapter, SavedInstruction};
use crate::klondike_adapter::{
KlondikeAdapter, SavedInstruction, card_from_kl, compute_time_bonus as scoring_time_bonus,
foundation_from_slot as adapter_foundation_from_slot,
skip_cards_from_count as adapter_skip_cards_from_count,
tableau_from_index as adapter_tableau_from_index,
};
use card_game::{Game, Session, SessionConfig};
use klondike::{
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction,
@@ -97,6 +102,7 @@ struct PersistedGameState {
pub saved_moves: Vec<SavedInstruction>,
}
#[cfg(feature = "test-support")]
/// Test-only override state that shadows the real session pile data.
///
/// When `test_pile_state` on `GameState` is `Some`, every pile read method
@@ -143,8 +149,8 @@ pub struct GameState {
pub take_from_foundation: bool,
/// Save-file schema version.
pub schema_version: u32,
pub adapter: KlondikeAdapter,
pub(crate) session: Session<Klondike>,
#[cfg(feature = "test-support")]
/// Test pile overrides. Always `None` in production runtime code.
pub test_pile_state: Option<TestPileState>,
}
@@ -165,9 +171,8 @@ impl PartialEq for GameState {
&& self.schema_version == other.schema_version
&& self.stock_cards() == other.stock_cards()
&& self.waste_cards() == other.waste_cards()
&& (0..4_u8).all(|slot| {
self.foundation_cards(slot).ok() == other.foundation_cards(slot).ok()
})
&& (0..4_u8)
.all(|slot| self.foundation_cards(slot).ok() == other.foundation_cards(slot).ok())
&& (0..7_usize).all(|index| {
let Ok(tableau) = Self::tableau_from_index(index) else {
return false;
@@ -221,15 +226,15 @@ impl<'de> Deserialize<'de> for GameState {
recycle_count: persisted.recycle_count,
take_from_foundation: persisted.take_from_foundation,
schema_version: persisted.schema_version,
adapter: KlondikeAdapter::new(persisted.draw_mode, persisted.take_from_foundation),
session: Self::new_session(persisted.seed, persisted.draw_mode),
#[cfg(feature = "test-support")]
test_pile_state: None,
};
let replay_config = Self::replay_config(game.draw_mode);
for saved in persisted.saved_moves {
let instruction = KlondikeInstruction::try_from(saved)
.map_err(serde::de::Error::custom)?;
let instruction =
KlondikeInstruction::try_from(saved).map_err(serde::de::Error::custom)?;
if !game
.session
.state()
@@ -271,8 +276,8 @@ impl GameState {
recycle_count: 0,
take_from_foundation: true,
schema_version: GAME_STATE_SCHEMA_VERSION,
adapter: KlondikeAdapter::new(draw_mode, true),
session: Self::new_session(seed, draw_mode),
#[cfg(feature = "test-support")]
test_pile_state: None,
}
}
@@ -290,15 +295,11 @@ impl GameState {
}
fn replay_config(draw_mode: DrawMode) -> KlondikeConfig {
KlondikeAdapter::new(draw_mode, true)
.klondike_config()
.clone()
KlondikeAdapter::config_for(draw_mode, true)
}
fn validation_config(&self) -> KlondikeConfig {
KlondikeAdapter::new(self.draw_mode, self.take_from_foundation)
.klondike_config()
.clone()
KlondikeAdapter::config_for(self.draw_mode, self.take_from_foundation)
}
fn saved_moves(&self) -> Vec<SavedInstruction> {
@@ -309,6 +310,14 @@ impl GameState {
.collect()
}
/// Returns the deterministic instruction history for the current deal.
///
/// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this
/// sequence is sufficient to replay the game state exactly.
pub fn instruction_history(&self) -> Vec<SavedInstruction> {
self.saved_moves()
}
fn u32_from_len(len: usize) -> u32 {
if len > u32::MAX as usize {
u32::MAX
@@ -332,6 +341,7 @@ impl GameState {
}
pub fn stock_cards(&self) -> Vec<Card> {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state
&& let Some(ref cards) = state.stock
{
@@ -342,6 +352,7 @@ impl GameState {
}
pub fn waste_cards(&self) -> Vec<Card> {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state
&& let Some(ref cards) = state.waste
{
@@ -352,6 +363,7 @@ impl GameState {
}
pub fn pile(&self, pile: KlondikePile) -> Vec<Card> {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state {
match pile {
KlondikePile::Stock => {
@@ -385,11 +397,17 @@ impl GameState {
}
KlondikePile::Tableau(tableau) => {
let mut cards = Self::cards_with_face(
state.tableau_face_down_cards(tableau).iter().map(card_from_kl),
state
.tableau_face_down_cards(tableau)
.iter()
.map(card_from_kl),
false,
);
cards.extend(Self::cards_with_face(
state.tableau_face_up_cards(tableau).iter().map(card_from_kl),
state
.tableau_face_up_cards(tableau)
.iter()
.map(card_from_kl),
true,
));
cards
@@ -398,26 +416,11 @@ impl GameState {
}
pub fn tableau_from_index(index: usize) -> Result<Tableau, MoveError> {
match index {
0 => Ok(Tableau::Tableau1),
1 => Ok(Tableau::Tableau2),
2 => Ok(Tableau::Tableau3),
3 => Ok(Tableau::Tableau4),
4 => Ok(Tableau::Tableau5),
5 => Ok(Tableau::Tableau6),
6 => Ok(Tableau::Tableau7),
_ => Err(MoveError::InvalidSource),
}
adapter_tableau_from_index(index).ok_or(MoveError::InvalidSource)
}
pub fn foundation_from_slot(slot: u8) -> Result<Foundation, MoveError> {
match slot {
0 => Ok(Foundation::Foundation1),
1 => Ok(Foundation::Foundation2),
2 => Ok(Foundation::Foundation3),
3 => Ok(Foundation::Foundation4),
_ => Err(MoveError::InvalidDestination),
}
adapter_foundation_from_slot(slot).ok_or(MoveError::InvalidDestination)
}
pub fn foundation_cards(&self, slot: u8) -> Result<Vec<Card>, MoveError> {
@@ -425,39 +428,65 @@ impl GameState {
Ok(self.pile(KlondikePile::Foundation(foundation)))
}
/// Returns `true` when test-only pile overrides are active.
#[cfg(feature = "test-support")]
pub fn has_test_pile_overrides(&self) -> bool {
self.test_pile_state.is_some()
}
/// Returns `false` in production builds where test pile overrides are absent.
#[cfg(not(feature = "test-support"))]
pub const fn has_test_pile_overrides(&self) -> bool {
false
}
/// Test-support helper: clear all pile overrides so reads come from the
/// underlying klondike session again.
#[cfg(feature = "test-support")]
pub fn clear_test_pile_overrides(&mut self) {
self.test_pile_state = None;
}
/// Test-support helper: override face-down stock cards returned by
/// [`Self::stock_cards`].
#[cfg(feature = "test-support")]
pub fn set_test_stock_cards(&mut self, cards: Vec<Card>) {
let state = self.test_pile_state.get_or_insert_with(TestPileState::default);
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.stock = Some(cards);
}
/// Test-support helper: override face-up waste cards returned by
/// [`Self::waste_cards`] / `pile(KlondikePile::Stock)`.
#[cfg(feature = "test-support")]
pub fn set_test_waste_cards(&mut self, cards: Vec<Card>) {
let state = self.test_pile_state.get_or_insert_with(TestPileState::default);
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.waste = Some(cards);
}
/// Test-support helper: override cards for a specific tableau column.
#[cfg(feature = "test-support")]
pub fn set_test_tableau_cards(&mut self, tableau: Tableau, cards: Vec<Card>) {
let state = self.test_pile_state.get_or_insert_with(TestPileState::default);
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.tableau.insert(tableau, cards);
}
/// Test-support helper: override cards for a specific foundation pile.
#[cfg(feature = "test-support")]
pub fn set_test_foundation_cards(&mut self, foundation: Foundation, cards: Vec<Card>) {
let state = self.test_pile_state.get_or_insert_with(TestPileState::default);
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.foundation.insert(foundation, cards);
}
/// Test-support helper: override cards for a specific pile.
#[cfg(feature = "test-support")]
pub fn set_test_pile_cards(&mut self, pile: KlondikePile, cards: Vec<Card>) {
match pile {
KlondikePile::Stock => {
@@ -479,22 +508,8 @@ impl GameState {
}
fn skip_cards_from_usize(skip: usize) -> Result<SkipCards, MoveError> {
match skip {
0 => Ok(SkipCards::Skip0),
1 => Ok(SkipCards::Skip1),
2 => Ok(SkipCards::Skip2),
3 => Ok(SkipCards::Skip3),
4 => Ok(SkipCards::Skip4),
5 => Ok(SkipCards::Skip5),
6 => Ok(SkipCards::Skip6),
7 => Ok(SkipCards::Skip7),
8 => Ok(SkipCards::Skip8),
9 => Ok(SkipCards::Skip9),
10 => Ok(SkipCards::Skip10),
11 => Ok(SkipCards::Skip11),
12 => Ok(SkipCards::Skip12),
_ => Err(MoveError::RuleViolation("invalid tableau card count".into())),
}
adapter_skip_cards_from_count(skip)
.ok_or_else(|| MoveError::RuleViolation("invalid tableau card count".into()))
}
fn will_flip_tableau_source(&self, from: KlondikePile, count: usize) -> bool {
@@ -539,9 +554,8 @@ impl GameState {
}))
}
(KlondikePile::Foundation(_), KlondikePile::Foundation(_)) => Err(
MoveError::RuleViolation(
"cannot move between foundation slots".into(),
)),
MoveError::RuleViolation("cannot move between foundation slots".into()),
),
(KlondikePile::Stock, KlondikePile::Tableau(dst)) => {
if count != 1 {
return Err(MoveError::RuleViolation(
@@ -595,7 +609,9 @@ impl GameState {
) -> Option<(KlondikePile, KlondikePile, usize)> {
let state = self.session.state().state().state();
match instruction {
KlondikeInstruction::RotateStock => None,
KlondikeInstruction::RotateStock => {
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
}
KlondikeInstruction::DstFoundation(dst_foundation) => {
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
return None;
@@ -605,12 +621,17 @@ impl GameState {
KlondikePile::Stock => KlondikePile::Stock,
KlondikePile::Foundation(_) => return None,
};
Some((source, KlondikePile::Foundation(dst_foundation.foundation), 1))
Some((
source,
KlondikePile::Foundation(dst_foundation.foundation),
1,
))
}
KlondikeInstruction::DstTableau(dst_tableau) => {
let (source, count) = match dst_tableau.src {
KlondikePileStack::Tableau(tableau_stack) => {
let face_up_count = state.tableau_face_up_cards(tableau_stack.tableau).len();
let face_up_count =
state.tableau_face_up_cards(tableau_stack.tableau).len();
let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?;
if count == 0 {
return None;
@@ -633,16 +654,15 @@ impl GameState {
return Err(MoveError::GameAlreadyWon);
}
let stock_empty = self
.stock_cards()
.is_empty();
let stock_empty = self.stock_cards().is_empty();
let waste_empty = self.waste_cards().is_empty();
if stock_empty && waste_empty {
return Err(MoveError::StockEmpty);
}
let recycling = stock_empty && !waste_empty;
self.session.process_instruction(KlondikeInstruction::RotateStock);
self.session
.process_instruction(KlondikeInstruction::RotateStock);
if recycling {
self.recycle_count = self.recycle_count.saturating_add(1);
@@ -692,9 +712,9 @@ impl GameState {
return Err(MoveError::RuleViolation("move violates rules".into()));
}
let score_delta = self.adapter.score_for_move_with_mode(&from, &to, self.mode);
let score_delta = KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode);
let flip_bonus = if self.will_flip_tableau_source(from, count) {
self.adapter.score_for_flip_with_mode(self.mode)
KlondikeAdapter::score_for_flip_with_mode(self.mode)
} else {
0
};
@@ -743,8 +763,7 @@ impl GameState {
return false;
}
let suit = pile[0].suit;
pile
.iter()
pile.iter()
.enumerate()
.all(|(i, card)| card.suit == suit && card.rank.value() == i as u8 + 1)
}
@@ -779,18 +798,14 @@ impl GameState {
self.session
.state()
.state()
.possible_instructions(&config)
.get_sorted_moves(&config)
.into_iter()
.filter_map(|instruction| self.instruction_to_move(instruction))
.collect()
}
/// Returns `true` when `move_cards(from, to, count)` would currently succeed.
pub fn can_move_cards(
&self,
from: &KlondikePile,
to: &KlondikePile,
count: usize,
) -> bool {
pub fn can_move_cards(&self, from: &KlondikePile, to: &KlondikePile, count: usize) -> bool {
if self.is_won || from == to {
return false;
}
@@ -838,62 +853,21 @@ impl GameState {
return None;
}
let waste = KlondikePile::Stock;
if let Some(slot) = self
.waste_cards()
.last()
.and_then(|card| self.foundation_slot_for(card))
{
return Some((waste, KlondikePile::Foundation(Self::foundation_from_slot(slot).ok()?)));
}
for index in 0..7 {
let tableau = KlondikePile::Tableau(Self::tableau_from_index(index).ok()?);
if let Some(slot) = self
.pile(tableau)
.last()
.and_then(|card| self.foundation_slot_for(card))
{
return Some((tableau, KlondikePile::Foundation(Self::foundation_from_slot(slot).ok()?)));
}
}
None
}
fn can_place_on_foundation_slot(&self, card: &Card, slot: u8) -> bool {
let Ok(pile) = self.foundation_cards(slot) else {
return false;
};
match pile.last() {
Some(top) => top.suit == card.suit && top.rank.checked_add(1) == Some(card.rank),
None => card.rank == Rank::Ace,
}
}
fn foundation_slot_for(&self, card: &Card) -> Option<u8> {
let mut candidate = None;
let mut empty_slot = None;
for slot in 0..4_u8 {
let Ok(pile) = self.foundation_cards(slot) else {
continue;
};
if pile.is_empty() {
if empty_slot.is_none() {
empty_slot = Some(slot);
self.possible_instructions()
.into_iter()
.find_map(|(from, to, count)| {
if count != 1 {
return None;
}
} else if pile.first().map(|c| c.suit) == Some(card.suit) {
candidate = Some(slot);
break;
}
}
let target = candidate.or_else(|| {
if card.rank == Rank::Ace {
empty_slot
} else {
None
}
});
target.filter(|&slot| self.can_place_on_foundation_slot(card, slot))
if matches!(from, KlondikePile::Foundation(_)) {
return None;
}
if matches!(to, KlondikePile::Foundation(_)) {
Some((from, to))
} else {
None
}
})
}
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
@@ -980,7 +954,8 @@ mod tests {
assert!(
game.possible_instructions()
.iter()
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_)) || !matches!(t, KlondikePile::Tableau(_)))
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_))
|| !matches!(t, KlondikePile::Tableau(_)))
);
assert!(game.move_cards(from, to, 1).is_err());
}