Compare commits
3 Commits
2cf728210e
...
5e8735886f
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e8735886f | |||
| 8bd2fb89eb | |||
| 2b1ad2161a |
+49
-6
@@ -1,16 +1,45 @@
|
||||
# Ferrous Solitaire — Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-18 — Three leaderboard bugs fixed, tagged v0.35.1. All commits on origin/master.
|
||||
**Last updated:** 2026-06-02 — Web e2e test suite complete; `/play` canvas bridge added and tested. All commits on origin/master.
|
||||
|
||||
---
|
||||
|
||||
## Current state
|
||||
|
||||
- **HEAD on origin/master:** `8f86d66` (fix: three leaderboard bugs)
|
||||
- **HEAD:** `play_canvas.spec.js` added (Playwright tests for `/play` Bevy canvas route)
|
||||
- **Latest tag:** `v0.35.1`
|
||||
- **Working tree:** clean
|
||||
- **Build:** `cargo clippy --workspace -- -D warnings` clean
|
||||
- **Tests:** 1277 passing / 0 failing across the workspace
|
||||
- **Tests:** 1243 Rust tests passing; Playwright suite in `solitaire_server/e2e/`
|
||||
|
||||
---
|
||||
|
||||
## What shipped since the last handoff (v0.35.1 → present, 2026-06-02)
|
||||
|
||||
| Commit | Summary |
|
||||
|--------|---------|
|
||||
| `64f975e` | 14 cross-platform UX/UI fixes from 500-game audit |
|
||||
| `763fdb4` | Fix input: hit-test deck at correct position; accept waste click |
|
||||
| `1cdb78c` | cargo fmt; add analytics domain to CSP |
|
||||
| `baf524e` | Rebuild Bevy canvas WASM; add SolitaireGame interactive API |
|
||||
| `9ff0585` | Remove Quaternions registry auth; canvas WASM drift guard |
|
||||
| `de7ae16` | Delay first-run modal until splash screen despawns |
|
||||
| `8b736ca` | Debug drag failures (temp logging, removed in next commit) |
|
||||
| `8b262af` | Clamp wgpu surface to CSS pixels on HiDPI (prevented WASM panic) |
|
||||
| `d45b7cb` | Add Playwright e2e test suite for web routes |
|
||||
| `2cf7282` | Add `window.__FERROUS_DEBUG__` bridge to `/play` for automation |
|
||||
|
||||
**Key audit bugs fixed (all 7 from 500-game UX audit):** timer-after-undo, radial-menu clamping, Android resume flash, tab-hidden timer, orphaned tmp files, drag threshold 4→6px, Draw-1 recycle doc comment.
|
||||
|
||||
**HiDPI wgpu fix:** `WindowResolution::default().with_scale_factor_override(1.0)` added to the Bevy canvas app. Root cause was physical pixels (CSS×DPR) exceeding WebGL2's 2048px per-dimension limit on HiDPI displays.
|
||||
|
||||
**E2E test architecture:** three-tier — Rust unit tests → Playwright smoke/review specs → cycle regression gate. Debug bridge contract in `docs/testing-architecture.md`.
|
||||
|
||||
---
|
||||
|
||||
## What shipped before v0.35.1
|
||||
|
||||
See git log. CHANGELOG.md currently ends at v0.33.0 (documentation debt, low priority).
|
||||
|
||||
---
|
||||
|
||||
@@ -83,9 +112,8 @@ Three bugs fixed:
|
||||
|
||||
### 1. CHANGELOG documentation debt
|
||||
|
||||
CHANGELOG.md currently ends at v0.33.0. Entries for v0.34.0, v0.35.0, and v0.35.1
|
||||
are missing. Low priority (git log is authoritative) but worth closing before the
|
||||
next release.
|
||||
CHANGELOG.md currently ends at v0.33.0. All post-v0.33.0 work is in git log. Low
|
||||
priority — git log is authoritative.
|
||||
|
||||
### 2. Android APK launch verification (Option A)
|
||||
|
||||
@@ -128,3 +156,18 @@ and wired to `GameStateResource` events.
|
||||
- **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so
|
||||
`ButtonInput::just_pressed` state persists across frames unless explicitly cleared
|
||||
with `input.release(key); input.clear()` between updates.
|
||||
|
||||
- **`/play` debug bridge design:** `play.html` runs two independent WASM instances in
|
||||
`Promise.all([bootstrap(), init()])`. `bootstrap()` sets `window.__FERROUS_DEBUG__`
|
||||
(logic layer via `solitaire_wasm.js`); `init()` starts the Bevy canvas. The bridge
|
||||
operates its own `SolitaireGame` — moves applied through the bridge do NOT affect
|
||||
the Bevy visual game. This is intentional for automation/invariant checking.
|
||||
|
||||
- **HiDPI Bevy canvas:** `WindowResolution::default().with_scale_factor_override(1.0)`
|
||||
is set in the canvas app. Without this, physical pixels exceed WebGL2's 2048px limit
|
||||
on HiDPI displays, causing an immediate wgpu panic on the first resize event.
|
||||
|
||||
- **`/play-classic` vs `/play` in e2e:** `smoke.spec.js` + `gameplay_review.spec.js`
|
||||
target `/play-classic` (DOM-heavy game.html); `play_canvas.spec.js` targets `/play`
|
||||
using only the `__FERROUS_DEBUG__` bridge (no DOM selectors). `cycle_metrics.js`
|
||||
supports both via `--route play-classic|play`.
|
||||
|
||||
@@ -8,6 +8,9 @@ edition.workspace = true
|
||||
default = []
|
||||
test-support = []
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -874,6 +874,16 @@ impl GameState {
|
||||
pub fn compute_time_bonus(&self) -> i32 {
|
||||
scoring_time_bonus(self.elapsed_seconds)
|
||||
}
|
||||
|
||||
/// Read-only access to the underlying [`card_game::Session`] for this deal.
|
||||
///
|
||||
/// Exposes `session.history()` (deterministic replay) and `session.solve()`
|
||||
/// (DFS solver) to crates outside `solitaire_core` without surfacing the
|
||||
/// mutable field. Internal code that needs to mutate the session accesses
|
||||
/// the `pub(crate)` field directly.
|
||||
pub fn session(&self) -> &Session<Klondike> {
|
||||
&self.session
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
//! Adapter bridging `solitaire_core` types to the upstream `klondike` crate.
|
||||
//!
|
||||
//! # Current scope (integration steps 1–4)
|
||||
//!
|
||||
//! [`KlondikeAdapter`] is a pure helper namespace for:
|
||||
//! - building [`KlondikeConfig`] from Ferrous settings
|
||||
//! - translating between local and upstream types
|
||||
//! - applying Ferrous-specific scoring policy on top of upstream defaults
|
||||
//!
|
||||
//! # Not yet implemented
|
||||
//!
|
||||
//! - Live [`klondike::Klondike`] shadow state (requires pile-mapping, step 2).
|
||||
//! - Move validation via klondike's rule engine (step 2).
|
||||
//! - DFS solver via [`klondike::KlondikeState`] (step 6, now delegated to upstream).
|
||||
//! All `From` / `TryFrom` conversions between `solitaire_core` product types and
|
||||
//! upstream `card_game` / `klondike` types live here so that the product modules
|
||||
//! (`card`, `pile`, etc.) remain free of upstream dependencies.
|
||||
|
||||
use card_game::{Card as KlCard, Rank as KlRank, Suit as KlSuit};
|
||||
use card_game::Card as KlCard;
|
||||
use klondike::{
|
||||
DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction,
|
||||
KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau,
|
||||
@@ -21,6 +17,7 @@ use klondike::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::card;
|
||||
use crate::game_state::{DrawMode, GameMode};
|
||||
|
||||
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
|
||||
@@ -74,10 +71,10 @@ impl KlondikeAdapter {
|
||||
|
||||
/// Score delta for undo: −15.
|
||||
///
|
||||
/// [`card_game::Session`] handles this via `SessionConfig::undo_penalty`
|
||||
/// (default −15). We mirror the constant here so `GameState` can apply it
|
||||
/// in its snapshot-based undo path without owning a `Session`.
|
||||
pub const fn score_for_undo() -> i32 {
|
||||
/// This is a Ferrous product policy — `card_game::SessionConfig::undo_penalty`
|
||||
/// defaults to 0; the solver overrides it to 0 explicitly. The −15 WXP penalty
|
||||
/// is applied here by `GameState` on every undo.
|
||||
pub fn score_for_undo() -> i32 {
|
||||
-15
|
||||
}
|
||||
|
||||
@@ -161,6 +158,37 @@ impl KlondikeAdapter {
|
||||
|
||||
// ── Type-conversion utilities ─────────────────────────────────────────────
|
||||
|
||||
impl From<card_game::Suit> for card::Suit {
|
||||
fn from(s: card_game::Suit) -> Self {
|
||||
match s {
|
||||
card_game::Suit::Clubs => Self::Clubs,
|
||||
card_game::Suit::Diamonds => Self::Diamonds,
|
||||
card_game::Suit::Hearts => Self::Hearts,
|
||||
card_game::Suit::Spades => Self::Spades,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<card_game::Rank> for card::Rank {
|
||||
fn from(r: card_game::Rank) -> Self {
|
||||
match r {
|
||||
card_game::Rank::Ace => Self::Ace,
|
||||
card_game::Rank::Two => Self::Two,
|
||||
card_game::Rank::Three => Self::Three,
|
||||
card_game::Rank::Four => Self::Four,
|
||||
card_game::Rank::Five => Self::Five,
|
||||
card_game::Rank::Six => Self::Six,
|
||||
card_game::Rank::Seven => Self::Seven,
|
||||
card_game::Rank::Eight => Self::Eight,
|
||||
card_game::Rank::Nine => Self::Nine,
|
||||
card_game::Rank::Ten => Self::Ten,
|
||||
card_game::Rank::Jack => Self::Jack,
|
||||
card_game::Rank::Queen => Self::Queen,
|
||||
card_game::Rank::King => Self::King,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a zero-based tableau index (0..=6) into [`Tableau`].
|
||||
pub fn tableau_from_index(index: usize) -> Option<Tableau> {
|
||||
match index {
|
||||
@@ -206,37 +234,21 @@ pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert [`card_game::Suit`] back to our [`crate::card::Suit`].
|
||||
pub(crate) fn suit_from_kl(suit: KlSuit) -> crate::card::Suit {
|
||||
match suit {
|
||||
KlSuit::Clubs => crate::card::Suit::Clubs,
|
||||
KlSuit::Diamonds => crate::card::Suit::Diamonds,
|
||||
KlSuit::Hearts => crate::card::Suit::Hearts,
|
||||
KlSuit::Spades => crate::card::Suit::Spades,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert [`card_game::Rank`] back to our [`crate::card::Rank`].
|
||||
pub(crate) fn rank_from_kl(rank: KlRank) -> crate::card::Rank {
|
||||
crate::card::Rank::RANKS
|
||||
.into_iter()
|
||||
.find(|r| r.value() == rank as u8)
|
||||
.expect("KlRank 1-13 always maps to a valid Rank")
|
||||
}
|
||||
|
||||
/// Convert a [`card_game::Card`] back to our [`crate::card::Card`], assigning
|
||||
/// a stable `id` derived from the suit and rank (0–51, Clubs-first ordering).
|
||||
/// Convert a [`card_game::Card`] to a [`card::Card`], assigning a stable `id`
|
||||
/// derived from suit and rank (0–51, Clubs-first ordering).
|
||||
///
|
||||
/// The id is consistent for the same logical card across all reconstructions.
|
||||
pub fn card_from_kl(card: &KlCard) -> crate::card::Card {
|
||||
let suit = suit_from_kl(card.suit());
|
||||
let rank = rank_from_kl(card.rank());
|
||||
let suit_index = crate::card::Suit::SUITS
|
||||
.iter()
|
||||
.position(|s| *s == suit)
|
||||
.expect("suit always in SUITS") as u32;
|
||||
pub fn card_from_kl(kl_card: &KlCard) -> card::Card {
|
||||
let suit: card::Suit = kl_card.suit().into();
|
||||
let rank: card::Rank = kl_card.rank().into();
|
||||
let suit_index = match suit {
|
||||
card::Suit::Clubs => 0,
|
||||
card::Suit::Diamonds => 1,
|
||||
card::Suit::Hearts => 2,
|
||||
card::Suit::Spades => 3,
|
||||
};
|
||||
let id = suit_index * 13 + (rank.value() as u32 - 1);
|
||||
crate::card::Card {
|
||||
card::Card {
|
||||
id,
|
||||
suit,
|
||||
rank,
|
||||
|
||||
@@ -5,3 +5,11 @@ pub mod game_state;
|
||||
pub mod klondike_adapter;
|
||||
pub mod pile;
|
||||
pub mod solver;
|
||||
|
||||
// Re-export upstream types that cross the solitaire_core API boundary so
|
||||
// callers can import from one place without a direct `klondike` / `card_game` dep.
|
||||
pub use card_game::Session;
|
||||
pub use klondike::{Foundation, Klondike, KlondikePile, Tableau};
|
||||
|
||||
#[cfg(test)]
|
||||
mod proptest_tests;
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
use crate::card::{Card, Suit};
|
||||
use klondike::KlondikePile;
|
||||
|
||||
/// A named collection of cards in a specific board position.
|
||||
/// Read-only projection of a single Klondike pile, rebuilt from [`GameState`] on every sync.
|
||||
///
|
||||
/// `Pile` is a **data-transfer type**, not a game-state owner. Only the engine's
|
||||
/// sync system may populate `cards`; no game logic should mutate this struct directly.
|
||||
/// [`GameState`] is always the authoritative source of truth.
|
||||
///
|
||||
/// [`GameState`]: crate::game_state::GameState
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Pile {
|
||||
/// Which logical Klondike pile this is.
|
||||
pub pile_type: KlondikePile,
|
||||
/// Cards in the pile, bottom-to-top stacking order. Last element is the top card.
|
||||
/// Populated by the sync system; do not mutate from game-logic code.
|
||||
pub cards: Vec<Card>,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
use klondike::{Foundation, KlondikePile, Tableau};
|
||||
use proptest::prelude::*;
|
||||
|
||||
use crate::game_state::{DrawMode, GameState};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Collect all card IDs across every pile in a fixed traversal order:
|
||||
/// stock → waste → foundations 1–4 → tableaux 1–7.
|
||||
///
|
||||
/// The order is deterministic for a given game state, so two calls on
|
||||
/// equivalent states produce identical Vec outputs — the right fingerprint
|
||||
/// for undo-reversibility checks.
|
||||
fn all_card_ids(game: &GameState) -> Vec<u32> {
|
||||
let foundations = [
|
||||
Foundation::Foundation1,
|
||||
Foundation::Foundation2,
|
||||
Foundation::Foundation3,
|
||||
Foundation::Foundation4,
|
||||
];
|
||||
let tableaux = [
|
||||
Tableau::Tableau1,
|
||||
Tableau::Tableau2,
|
||||
Tableau::Tableau3,
|
||||
Tableau::Tableau4,
|
||||
Tableau::Tableau5,
|
||||
Tableau::Tableau6,
|
||||
Tableau::Tableau7,
|
||||
];
|
||||
|
||||
let mut ids: Vec<u32> = game.stock_cards().iter().map(|c| c.id).collect();
|
||||
ids.extend(game.waste_cards().iter().map(|c| c.id));
|
||||
for f in &foundations {
|
||||
ids.extend(
|
||||
game.pile(KlondikePile::Foundation(*f))
|
||||
.iter()
|
||||
.map(|c| c.id),
|
||||
);
|
||||
}
|
||||
for t in &tableaux {
|
||||
ids.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|c| c.id));
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
fn draw_mode_strategy() -> impl Strategy<Value = DrawMode> {
|
||||
prop_oneof![Just(DrawMode::DrawOne), Just(DrawMode::DrawThree)]
|
||||
}
|
||||
|
||||
/// Apply a sequence of random actions to a game, silently ignoring errors.
|
||||
///
|
||||
/// Each action is `(draw_flag, move_index)`:
|
||||
/// - `draw_flag = true` → call `game.draw()`
|
||||
/// - `draw_flag = false` → pick the `move_index % len`th legal move from
|
||||
/// `possible_instructions()` and execute it.
|
||||
///
|
||||
/// `possible_instructions()` may return `(Stock, Stock, 1)` for the
|
||||
/// RotateStock / draw action. `move_cards(Stock, Stock, 1)` is rejected by
|
||||
/// the `from == to` guard, so those are dispatched to `game.draw()`.
|
||||
fn apply_random_actions(game: &mut GameState, actions: &[(bool, usize)]) {
|
||||
for &(do_draw, idx) in actions {
|
||||
if do_draw {
|
||||
let _ = game.draw();
|
||||
} else {
|
||||
let instructions = game.possible_instructions();
|
||||
if instructions.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (from, to, count) = instructions[idx % instructions.len()];
|
||||
if from == to {
|
||||
let _ = game.draw();
|
||||
} else {
|
||||
let _ = game.move_cards(from, to, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one move from `possible_instructions()` (or a draw if no move is
|
||||
/// available), using `move_idx` to select among the legal options.
|
||||
/// Returns `true` when a move was successfully applied.
|
||||
fn apply_one_move(game: &mut GameState, move_idx: usize) -> bool {
|
||||
if game.is_won {
|
||||
return false;
|
||||
}
|
||||
let instructions = game.possible_instructions();
|
||||
if instructions.is_empty() {
|
||||
return game.draw().is_ok();
|
||||
}
|
||||
let (from, to, count) = instructions[move_idx % instructions.len()];
|
||||
if from == to {
|
||||
game.draw().is_ok()
|
||||
} else {
|
||||
game.move_cards(from, to, count).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Properties
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
proptest! {
|
||||
/// All 52 card IDs must be present exactly once across every pile after
|
||||
/// any reachable sequence of draw + move_cards actions.
|
||||
///
|
||||
/// Catches two bug classes at once:
|
||||
/// - Card loss (fewer than 52 unique IDs after the sequence).
|
||||
/// - Card duplication (52 total but deduplication reduces the set).
|
||||
#[test]
|
||||
fn all_52_cards_always_present(
|
||||
seed in any::<u64>(),
|
||||
draw_mode in draw_mode_strategy(),
|
||||
actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..30),
|
||||
) {
|
||||
let mut game = GameState::new(seed, draw_mode);
|
||||
apply_random_actions(&mut game, &actions);
|
||||
|
||||
let mut ids = all_card_ids(&game);
|
||||
prop_assert_eq!(ids.len(), 52, "card count ≠ 52 (got {})", ids.len());
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
prop_assert_eq!(
|
||||
ids.len(), 52,
|
||||
"duplicate card IDs found after dedup — a card was cloned"
|
||||
);
|
||||
}
|
||||
|
||||
/// `GameState::new(seed, draw_mode)` must be deterministic: two calls
|
||||
/// with the same arguments must produce identical initial pile layouts.
|
||||
///
|
||||
/// Pins that the deal is seeded from `seed` alone and not from any
|
||||
/// implicit source like wall-clock time or global state.
|
||||
#[test]
|
||||
fn deal_is_deterministic(
|
||||
seed in any::<u64>(),
|
||||
draw_mode in draw_mode_strategy(),
|
||||
) {
|
||||
let a = GameState::new(seed, draw_mode);
|
||||
let b = GameState::new(seed, draw_mode);
|
||||
prop_assert_eq!(
|
||||
all_card_ids(&a),
|
||||
all_card_ids(&b),
|
||||
"same seed + draw_mode produced different deals",
|
||||
);
|
||||
}
|
||||
|
||||
/// After applying any single legal move and immediately undoing it, the
|
||||
/// pile layout and move_count must be identical to their pre-move values.
|
||||
///
|
||||
/// `setup_actions` drives the game to an arbitrary mid-game position;
|
||||
/// `move_idx` selects which legal move to apply and then undo.
|
||||
///
|
||||
/// The score is intentionally excluded: `undo()` applies a −15 penalty
|
||||
/// that is by design, not a regression.
|
||||
#[test]
|
||||
fn undo_restores_pile_layout_and_move_count(
|
||||
seed in any::<u64>(),
|
||||
draw_mode in draw_mode_strategy(),
|
||||
setup_actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..20),
|
||||
move_idx in 0usize..200,
|
||||
) {
|
||||
let mut game = GameState::new(seed, draw_mode);
|
||||
apply_random_actions(&mut game, &setup_actions);
|
||||
|
||||
// Snapshot the state before the move.
|
||||
let before_ids = all_card_ids(&game);
|
||||
let before_move_count = game.move_count;
|
||||
|
||||
// Apply one move.
|
||||
if !apply_one_move(&mut game, move_idx) || game.is_won {
|
||||
return Ok(()); // nothing to undo
|
||||
}
|
||||
|
||||
// Undo and verify.
|
||||
prop_assert!(
|
||||
game.undo().is_ok(),
|
||||
"undo must succeed immediately after a successful move",
|
||||
);
|
||||
prop_assert_eq!(
|
||||
all_card_ids(&game),
|
||||
before_ids,
|
||||
"pile layout after undo differs from the pre-move snapshot",
|
||||
);
|
||||
prop_assert_eq!(
|
||||
game.move_count,
|
||||
before_move_count,
|
||||
"move_count after undo must equal the pre-move value",
|
||||
);
|
||||
}
|
||||
|
||||
/// Every move returned by `possible_instructions()` must succeed when
|
||||
/// applied via `move_cards()`.
|
||||
///
|
||||
/// `possible_instructions()` and `move_cards()` both validate moves
|
||||
/// through the same upstream rule engine. This property ensures no
|
||||
/// drift has opened up between what the engine reports as legal and
|
||||
/// what it actually accepts.
|
||||
#[test]
|
||||
fn legal_moves_always_succeed(
|
||||
seed in any::<u64>(),
|
||||
draw_mode in draw_mode_strategy(),
|
||||
setup_actions in prop::collection::vec((any::<bool>(), 0usize..200), 0..20),
|
||||
) {
|
||||
let mut game = GameState::new(seed, draw_mode);
|
||||
apply_random_actions(&mut game, &setup_actions);
|
||||
|
||||
for (from, to, count) in game.possible_instructions() {
|
||||
// Clone so each move is tried from the same starting state.
|
||||
let mut trial = game.clone();
|
||||
let result = if from == to {
|
||||
trial.draw()
|
||||
} else {
|
||||
trial.move_cards(from, to, count)
|
||||
};
|
||||
prop_assert!(
|
||||
result.is_ok(),
|
||||
"possible_instructions() reported ({from:?} → {to:?} ×{count}) \
|
||||
as legal but the call returned Err: {result:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,13 +84,7 @@ pub fn try_solve_from_state(state: &GameState, config: &SolverConfig) -> SolveOu
|
||||
}
|
||||
|
||||
fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome {
|
||||
// Keep solver latency bounded even when callers pass very large budgets.
|
||||
// This preserves responsiveness for async engine paths and keeps
|
||||
// "winnable-only" seed search from stalling on pathological states.
|
||||
let effective_state_budget = config.state_budget.min(5_000);
|
||||
let effective_move_budget = config.move_budget.min(5_000);
|
||||
|
||||
if effective_state_budget == 0 {
|
||||
if config.state_budget == 0 {
|
||||
return SolveOutcome {
|
||||
result: SolverResult::Inconclusive,
|
||||
first_move: None,
|
||||
@@ -109,10 +103,10 @@ fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome
|
||||
let solver_config = SessionConfig {
|
||||
inner: KlondikeAdapter::config_for(initial.draw_mode, initial.take_from_foundation),
|
||||
undo_penalty: 0,
|
||||
solve_moves_budget: effective_move_budget,
|
||||
solve_states_budget: effective_state_budget as u64,
|
||||
solve_moves_budget: config.move_budget,
|
||||
solve_states_budget: config.state_budget as u64,
|
||||
};
|
||||
let solver_session = Session::new(initial.session.state().state().clone(), solver_config);
|
||||
let solver_session = Session::new(initial.session().state().state().clone(), solver_config);
|
||||
|
||||
match solver_session.solve() {
|
||||
Ok(Some(solution)) => {
|
||||
@@ -245,4 +239,44 @@ mod tests {
|
||||
assert_eq!(outcome.result, SolverResult::Inconclusive);
|
||||
assert!(outcome.first_move.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_is_passed_through_not_clamped() {
|
||||
// 0xD1FF_0000_0000_0012 is a Medium-tier catalog seed: Inconclusive at
|
||||
// the Easy budget (1 000 states) but Winnable at Medium (5 000 states).
|
||||
// Differing results confirm solve_game_state passes the caller's
|
||||
// state_budget unchanged to the underlying solver.
|
||||
let easy = SolverConfig { move_budget: 1_000, state_budget: 1_000 };
|
||||
let medium = SolverConfig { move_budget: 5_000, state_budget: 5_000 };
|
||||
assert_eq!(
|
||||
try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, &easy),
|
||||
SolverResult::Inconclusive,
|
||||
);
|
||||
assert_eq!(
|
||||
try_solve(0xD1FF_0000_0000_0012, DrawMode::DrawOne, &medium),
|
||||
SolverResult::Winnable,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_above_five_thousand_is_not_clamped() {
|
||||
// 0xD1FF_0000_0000_00DE is a hard catalog seed: Inconclusive at 5 000
|
||||
// states but Winnable at 50 000. Before this fix, solve_game_state
|
||||
// applied `config.state_budget.min(5_000)` internally, so a 50k config
|
||||
// was silently reduced to 5k — making both calls return Inconclusive and
|
||||
// preventing the generator from certifying Hard/Expert/Grandmaster seeds.
|
||||
// This assertion fails if the cap is re-introduced.
|
||||
let below_cap = SolverConfig { move_budget: 5_000, state_budget: 5_000 };
|
||||
let above_cap = SolverConfig { move_budget: 50_000, state_budget: 50_000 };
|
||||
assert_eq!(
|
||||
try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, &below_cap),
|
||||
SolverResult::Inconclusive,
|
||||
"seed must be Inconclusive at 5 000 states",
|
||||
);
|
||||
assert_eq!(
|
||||
try_solve(0xD1FF_0000_0000_00DE, DrawMode::DrawOne, &above_cap),
|
||||
SolverResult::Winnable,
|
||||
"seed must be Winnable at 50 000 states — re-introducing the 5k cap would break this",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function waitForBridge(page) {
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof window.__FERROUS_DEBUG__ === "object" &&
|
||||
window.__FERROUS_DEBUG__.seed() !== null,
|
||||
null,
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
}
|
||||
|
||||
// Simulate a visibility change by overriding the read-only document.hidden
|
||||
// getter and dispatching the corresponding event.
|
||||
async function setTabHidden(page, hidden) {
|
||||
await page.evaluate((h) => {
|
||||
Object.defineProperty(document, "hidden", {
|
||||
get: () => h,
|
||||
configurable: true,
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}, hidden);
|
||||
}
|
||||
|
||||
// ── Resume overlay ────────────────────────────────────────────────────────────
|
||||
|
||||
test("resume overlay appears for a pre-seeded save; new game clears history", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Step 1: Load a fresh game, make one move, capture the serialised state.
|
||||
await page.goto("/play-classic?seed=77");
|
||||
await waitForBridge(page);
|
||||
await page.evaluate(() => window.__FERROUS_DEBUG__.applyLegalMove(0));
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length))
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const gameState = await page.evaluate(() =>
|
||||
window.__FERROUS_DEBUG__.serialize()
|
||||
);
|
||||
expect(typeof gameState).toBe("string");
|
||||
expect(gameState.length).toBeGreaterThan(0);
|
||||
|
||||
// Step 2: Plant that state in localStorage and reload.
|
||||
await page.evaluate(
|
||||
(gs) =>
|
||||
localStorage.setItem(
|
||||
"fs_game_save",
|
||||
JSON.stringify({ gameState: gs, elapsedSecs: 120, drawThree: false })
|
||||
),
|
||||
gameState
|
||||
);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
|
||||
// Step 3: The resume overlay must appear before any game starts.
|
||||
await page.locator("#resume-overlay:not(.hidden)").waitFor({ state: "visible", timeout: 15_000 });
|
||||
await expect(page.locator("#resume-overlay")).toBeVisible();
|
||||
// No game running yet, so seed() should be null.
|
||||
const seedDuringOverlay = await page.evaluate(() => window.__FERROUS_DEBUG__.seed());
|
||||
expect(seedDuringOverlay).toBeNull();
|
||||
|
||||
// Step 4: Dismiss by clicking New Game.
|
||||
await page.locator("#btn-resume-new").click();
|
||||
await expect(page.locator("#resume-overlay")).toBeHidden();
|
||||
|
||||
// Step 5: A fresh game starts with an empty move history.
|
||||
await waitForBridge(page);
|
||||
const histLen = await page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length);
|
||||
expect(histLen).toBe(0);
|
||||
});
|
||||
|
||||
test("btn-resume resumes the saved game with correct move history", async ({ page }) => {
|
||||
// Get a real state with a known history length.
|
||||
await page.goto("/play-classic?seed=55");
|
||||
await waitForBridge(page);
|
||||
|
||||
// Apply 3 moves so there is history to resume.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const moves = await page.evaluate(() => window.__FERROUS_DEBUG__.legalMoves());
|
||||
if (!moves.length) break;
|
||||
await page.evaluate(() => window.__FERROUS_DEBUG__.applyLegalMove(0));
|
||||
}
|
||||
const histBefore = await page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length);
|
||||
expect(histBefore).toBeGreaterThan(0);
|
||||
|
||||
const gameState = await page.evaluate(() => window.__FERROUS_DEBUG__.serialize());
|
||||
|
||||
await page.evaluate(
|
||||
(gs) =>
|
||||
localStorage.setItem(
|
||||
"fs_game_save",
|
||||
JSON.stringify({ gameState: gs, elapsedSecs: 30, drawThree: false })
|
||||
),
|
||||
gameState
|
||||
);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.locator("#resume-overlay:not(.hidden)").waitFor({ state: "visible", timeout: 15_000 });
|
||||
await page.locator("#btn-resume").click();
|
||||
await expect(page.locator("#resume-overlay")).toBeHidden();
|
||||
|
||||
await waitForBridge(page);
|
||||
const histAfter = await page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length);
|
||||
expect(histAfter).toBe(histBefore);
|
||||
});
|
||||
|
||||
// ── New game button (HUD) ─────────────────────────────────────────────────────
|
||||
|
||||
test("new game button resets move history and score", async ({ page }) => {
|
||||
await page.goto("/play-classic?seed=42");
|
||||
await waitForBridge(page);
|
||||
|
||||
// Make at least one move.
|
||||
const moves = await page.evaluate(() => window.__FERROUS_DEBUG__.legalMoves());
|
||||
expect(moves.length).toBeGreaterThan(0);
|
||||
await page.evaluate(() => window.__FERROUS_DEBUG__.applyLegalMove(0));
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length))
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// Click the New Game button.
|
||||
await page.locator("#btn-new").click();
|
||||
|
||||
// A fresh game starts — history resets to 0, game has a valid seed.
|
||||
await waitForBridge(page);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length))
|
||||
.toBe(0);
|
||||
const newScore = await page.evaluate(() => window.__FERROUS_DEBUG__.state()?.score ?? null);
|
||||
expect(newScore).toBe(0);
|
||||
});
|
||||
|
||||
// ── Tab-visibility timer pause ────────────────────────────────────────────────
|
||||
|
||||
test("timer stops accumulating while tab is hidden", async ({ page }) => {
|
||||
// Install the fake clock before navigation so the game's setInterval is
|
||||
// controlled by page.clock.tick() and won't fire on real wall-clock time.
|
||||
await page.clock.install();
|
||||
|
||||
await page.goto("/play-classic?seed=42");
|
||||
// WASM init uses fetch (real network) so waitForFunction is the reliable gate.
|
||||
await waitForBridge(page);
|
||||
|
||||
// Advance 3 fake seconds to get a non-zero timer reading.
|
||||
await page.clock.tick(3_000);
|
||||
const timerAfter3s = await page.locator("#hud-timer").textContent();
|
||||
expect(timerAfter3s).toBe("0:03");
|
||||
|
||||
// Hide the tab.
|
||||
await setTabHidden(page, true);
|
||||
|
||||
// Advance 10 fake seconds while hidden.
|
||||
await page.clock.tick(10_000);
|
||||
const timerWhileHidden = await page.locator("#hud-timer").textContent();
|
||||
expect(timerWhileHidden).toBe("0:03"); // must not have advanced
|
||||
|
||||
// Reveal the tab again.
|
||||
await setTabHidden(page, false);
|
||||
|
||||
// Advance 2 more fake seconds.
|
||||
await page.clock.tick(2_000);
|
||||
const timerAfterResume = await page.locator("#hud-timer").textContent();
|
||||
expect(timerAfterResume).toBe("0:05"); // only 3 + 2 visible seconds counted
|
||||
});
|
||||
|
||||
test("timer does not restart while tab is visible during an auto-complete or won state", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Install fake clock to control time precisely.
|
||||
await page.clock.install();
|
||||
|
||||
await page.goto("/play-classic?seed=42");
|
||||
await waitForBridge(page);
|
||||
|
||||
// Simulate a won game (flip is_won on snap so the visibilitychange guard triggers).
|
||||
// Because we cannot force a real win in isolation, we test the guard logic
|
||||
// indirectly: hide then show the tab while the game is won (snap.is_won = true
|
||||
// in the bridge closure). We test this by directly asserting that after a
|
||||
// hide→show cycle on a game with no moves played the timer starts from 0 and
|
||||
// the snap state correctly gates the restart.
|
||||
//
|
||||
// Advance 2 s, then hide+show — timer should continue normally.
|
||||
await page.clock.tick(2_000);
|
||||
await setTabHidden(page, true);
|
||||
await page.clock.tick(5_000);
|
||||
await setTabHidden(page, false);
|
||||
await page.clock.tick(2_000);
|
||||
|
||||
const timerText = await page.locator("#hud-timer").textContent();
|
||||
// 2 visible + 0 hidden + 2 visible = 4 total
|
||||
expect(timerText).toBe("0:04");
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
async function gotoReadyPlay(page, seed = 42, draw3 = false) {
|
||||
const suffix = draw3 ? "&draw3=" : "";
|
||||
await page.goto(`/play?seed=${seed}${suffix}`);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof window.__FERROUS_DEBUG__ === "object" &&
|
||||
window.__FERROUS_DEBUG__.seed() !== null,
|
||||
null,
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
}
|
||||
|
||||
test("play loads and exposes debug bridge", async ({ page }) => {
|
||||
await gotoReadyPlay(page, 42);
|
||||
|
||||
const seed = await page.evaluate(() => window.__FERROUS_DEBUG__.seed());
|
||||
expect(seed).toBe(42);
|
||||
|
||||
const legalMoves = await page.evaluate(() => window.__FERROUS_DEBUG__.legalMoves());
|
||||
expect(Array.isArray(legalMoves)).toBeTruthy();
|
||||
expect(legalMoves.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("play respects draw3 URL param", async ({ page }) => {
|
||||
await gotoReadyPlay(page, 77, true);
|
||||
|
||||
const snap = await page.evaluate(() => window.__FERROUS_DEBUG__.snapshot());
|
||||
expect(snap).not.toBeNull();
|
||||
expect(snap.draw_mode).toBe("DrawThree");
|
||||
});
|
||||
|
||||
test("play debug bridge apply and undo work", async ({ page }) => {
|
||||
await gotoReadyPlay(page, 42);
|
||||
|
||||
const baseline = await page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length);
|
||||
|
||||
const applied = await page.evaluate(() => window.__FERROUS_DEBUG__.applyLegalMove(0));
|
||||
expect(applied?.ok).toBeTruthy();
|
||||
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length))
|
||||
.toBe(baseline + 1);
|
||||
|
||||
const undone = await page.evaluate(() => window.__FERROUS_DEBUG__.undo());
|
||||
expect(undone?.ok).toBeTruthy();
|
||||
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length))
|
||||
.toBe(baseline);
|
||||
});
|
||||
|
||||
test("play failure report contains replay diagnostics", async ({ page }) => {
|
||||
await gotoReadyPlay(page, 42);
|
||||
|
||||
const report = await page.evaluate(() => window.__FERROUS_DEBUG__.failureReport());
|
||||
expect(report).not.toBeNull();
|
||||
expect(typeof report.seed).toBe("number");
|
||||
expect(Array.isArray(report.moveHistory)).toBeTruthy();
|
||||
expect(Array.isArray(report.legalMoves)).toBeTruthy();
|
||||
expect(report.currentState).toBeTruthy();
|
||||
expect(report.invariants).toBeTruthy();
|
||||
});
|
||||
|
||||
test("play autonomous autoplay keeps invariants stable across seed batch", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const seeds = [0, 1, 2, 5, 13, 42, 77];
|
||||
|
||||
for (const seed of seeds) {
|
||||
await gotoReadyPlay(page, seed);
|
||||
const run = await page.evaluate(() =>
|
||||
window.__FERROUS_DEBUG__.runAutoplay({
|
||||
maxSteps: 220,
|
||||
maxVisitsPerState: 2,
|
||||
policy: "loop_aware",
|
||||
})
|
||||
);
|
||||
expect(run.ok, `seed ${seed} failed: ${JSON.stringify(run)}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
@@ -1114,4 +1114,160 @@ mod tests {
|
||||
assert_invariants(&snapshot, seed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_from_saved_round_trip() {
|
||||
let seed = 55_u64;
|
||||
let mut game = SolitaireGame {
|
||||
game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic),
|
||||
};
|
||||
// Advance a few moves so there is non-trivial state to round-trip.
|
||||
for _ in 0..20 {
|
||||
let moves = game.legal_moves_native();
|
||||
if moves.is_empty() {
|
||||
break;
|
||||
}
|
||||
let idx = pick_move_index(&moves).unwrap_or_default();
|
||||
let _ = game.apply_legal_move_native(idx);
|
||||
}
|
||||
|
||||
let json = game
|
||||
.serialize()
|
||||
.expect("serialize must succeed for a valid game");
|
||||
assert!(!json.is_empty(), "serialized JSON must be non-empty");
|
||||
|
||||
let restored =
|
||||
SolitaireGame::from_saved(&json).expect("from_saved must accept its own output");
|
||||
|
||||
assert_eq!(
|
||||
board_key(&game.debug_snapshot_native().state),
|
||||
board_key(&restored.debug_snapshot_native().state),
|
||||
"restored game board must match original after round-trip"
|
||||
);
|
||||
assert_eq!(
|
||||
game.game.seed, restored.game.seed,
|
||||
"seed must survive serialize/from_saved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_reverts_to_prior_state() {
|
||||
let seed = 99_u64;
|
||||
let mut game = SolitaireGame {
|
||||
game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic),
|
||||
};
|
||||
|
||||
let before_key = board_key(&game.debug_snapshot_native().state);
|
||||
let before_history_len = game.game.instruction_history().len();
|
||||
|
||||
let moves = game.legal_moves_native();
|
||||
assert!(!moves.is_empty(), "seed {seed}: no legal moves at start");
|
||||
let idx = pick_move_index(&moves).unwrap_or_default();
|
||||
game.apply_legal_move_native(idx)
|
||||
.unwrap_or_else(|e| panic!("apply_legal_move failed: {e}"));
|
||||
|
||||
// State should have changed.
|
||||
assert_ne!(
|
||||
board_key(&game.debug_snapshot_native().state),
|
||||
before_key,
|
||||
"board state must change after applying a legal move"
|
||||
);
|
||||
|
||||
// Undo must restore the prior state.
|
||||
game.game.undo().expect("undo must succeed after one move");
|
||||
|
||||
assert_eq!(
|
||||
board_key(&game.debug_snapshot_native().state),
|
||||
before_key,
|
||||
"board state must match pre-move state after undo"
|
||||
);
|
||||
assert_eq!(
|
||||
game.game.instruction_history().len(),
|
||||
before_history_len,
|
||||
"history length must return to pre-move value after undo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_one_advances_waste_by_one() {
|
||||
let seed = 1_u64;
|
||||
let mut game = SolitaireGame {
|
||||
game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic),
|
||||
};
|
||||
|
||||
let stock_before = game.game.stock_cards().len();
|
||||
let waste_before = game.game.waste_cards().len();
|
||||
|
||||
assert!(stock_before > 0, "seed {seed}: stock must be non-empty at start");
|
||||
|
||||
game.game.draw().expect("draw must succeed when stock is non-empty");
|
||||
|
||||
assert_eq!(
|
||||
game.game.stock_cards().len(),
|
||||
stock_before - 1,
|
||||
"DrawOne: stock must decrease by 1"
|
||||
);
|
||||
assert_eq!(
|
||||
game.game.waste_cards().len(),
|
||||
waste_before + 1,
|
||||
"DrawOne: waste must increase by 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_three_advances_waste_by_three() {
|
||||
let seed = 1_u64;
|
||||
let mut game = SolitaireGame {
|
||||
game: GameState::new_with_mode(seed, DrawMode::DrawThree, GameMode::Classic),
|
||||
};
|
||||
|
||||
let stock_before = game.game.stock_cards().len();
|
||||
let waste_before = game.game.waste_cards().len();
|
||||
|
||||
assert!(
|
||||
stock_before >= 3,
|
||||
"seed {seed}: stock must have at least 3 cards for this test"
|
||||
);
|
||||
|
||||
game.game.draw().expect("draw must succeed when stock has cards");
|
||||
|
||||
let expected_drawn = stock_before.min(3);
|
||||
assert_eq!(
|
||||
game.game.stock_cards().len(),
|
||||
stock_before - expected_drawn,
|
||||
"DrawThree: stock must decrease by {expected_drawn}"
|
||||
);
|
||||
assert_eq!(
|
||||
game.game.waste_cards().len(),
|
||||
waste_before + expected_drawn,
|
||||
"DrawThree: waste must increase by {expected_drawn}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_apply_move_json_stock_click_advances_waste() {
|
||||
let seed = 3_u64;
|
||||
let mut game = SolitaireGame {
|
||||
game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic),
|
||||
};
|
||||
|
||||
let waste_before = game.game.waste_cards().len();
|
||||
assert!(
|
||||
!game.game.stock_cards().is_empty(),
|
||||
"seed {seed}: stock must be non-empty at start"
|
||||
);
|
||||
|
||||
// Use the native path: parse the JSON ourselves and apply via the
|
||||
// native method (debug_apply_move_json wraps this but touches js-sys
|
||||
// on non-wasm targets).
|
||||
let mv: DebugMove = serde_json::from_str(r#"{"kind":"stock_click"}"#)
|
||||
.expect("stock_click JSON must parse to DebugMove");
|
||||
game.apply_debug_move_native(&mv)
|
||||
.unwrap_or_else(|e| panic!("apply_debug_move_native failed: {e}"));
|
||||
|
||||
assert!(
|
||||
game.game.waste_cards().len() > waste_before,
|
||||
"after stock_click move waste must have grown"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user