9bbb57134f
Pile-position types (Tableau, Foundation, KlondikePile, KlondikePileStack) are runtime-only and have no serde upstream. Per Rhys's guidance, the persistence layer now stores the moves (KlondikeInstruction) rather than board coordinates, decoding back to runtime pile positions on demand. Core / data: - game_state: instruction_history() -> Vec<KlondikeInstruction>; add instruction_to_piles() and apply_instruction(); drop AnyInstruction. - klondike_adapter: delete the entire Saved* serde mirror section (SavedTableau/Foundation/SkipCards/KlondikePile/TableauStack/ KlondikePileStack/DstFoundation/DstTableau/SavedInstruction). - replay: drop the bespoke ReplayMove serde mirror; Replay.moves is now Vec<KlondikeInstruction>; REPLAY_SCHEMA_VERSION 2 -> 3. - storage: game_state save format v3 rejected (v4/v5 only). Engine / wasm consumers: - record via KlondikeInstruction (stock click = RotateStock). - playback decodes each instruction to (from, to, count) against the live state via instruction_to_piles, then fires the canonical event; undecodable instructions are skipped with a warning, never panic. - remove all use solitaire_data::ReplayMove and Saved* imports. Workspace check, clippy -D warnings, and the full test suite all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
260 lines
9.6 KiB
Rust
260 lines
9.6 KiB
Rust
use card_game::{Card, Game};
|
||
use klondike::{DrawStockConfig, Foundation, KlondikePile, Tableau};
|
||
use proptest::prelude::*;
|
||
|
||
use crate::game_state::GameState;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shared helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Collect all cards 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_cards(game: &GameState) -> Vec<Card> {
|
||
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 cards: Vec<Card> = game.stock_cards().iter().map(|(c, _)| c.clone()).collect();
|
||
cards.extend(game.waste_cards().iter().map(|(c, _)| c.clone()));
|
||
for f in &foundations {
|
||
cards.extend(
|
||
game.pile(KlondikePile::Foundation(*f))
|
||
.iter()
|
||
.map(|(c, _)| c.clone()),
|
||
);
|
||
}
|
||
for t in &tableaux {
|
||
cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone()));
|
||
}
|
||
cards
|
||
}
|
||
|
||
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
|
||
prop_oneof![Just(DrawStockConfig::DrawOne), Just(DrawStockConfig::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 instruction
|
||
/// from `possible_instructions()` and apply it via `apply_instruction()`.
|
||
///
|
||
/// `possible_instructions()` may return `RotateStock`, which
|
||
/// `apply_instruction()` dispatches to `game.draw()`; ordinary instructions
|
||
/// are equivalent to `move_cards(from, to, count)`.
|
||
fn apply_random_actions(game: &mut GameState, actions: &[(bool, usize)]) {
|
||
for &(do_draw, idx) in actions {
|
||
if do_draw {
|
||
let _ = game.draw();
|
||
} else {
|
||
let moves = game.possible_instructions();
|
||
if moves.is_empty() {
|
||
continue;
|
||
}
|
||
let instruction = moves[idx % moves.len()];
|
||
let _ = game.apply_instruction(instruction);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 moves = game.possible_instructions();
|
||
if moves.is_empty() {
|
||
return game.draw().is_ok();
|
||
}
|
||
let instruction = moves[move_idx % moves.len()];
|
||
game.apply_instruction(instruction).is_ok()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Properties
|
||
// ---------------------------------------------------------------------------
|
||
|
||
proptest! {
|
||
/// `check_auto_complete()` and `is_win_trivial()` must agree on every
|
||
/// reachable game state.
|
||
///
|
||
/// The upstream `Klondike::is_win_trivial()` checks that the stock pile
|
||
/// (both face-down and face-up halves) is completely empty AND that all
|
||
/// tableau columns have no face-down cards. Ferrous `check_auto_complete()`
|
||
/// checks the same three conditions individually (stock empty, waste empty,
|
||
/// all tableau cards face-up). This property guards against any semantic
|
||
/// drift between the two implementations so that delegating to upstream is
|
||
/// safe.
|
||
///
|
||
/// If this property ever fails, `check_auto_complete()` must NOT be fully
|
||
/// replaced — the Ferrous conditions must be preserved and `is_win_trivial()`
|
||
/// used only as a supplementary guard.
|
||
#[test]
|
||
fn check_auto_complete_agrees_with_is_win_trivial(
|
||
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);
|
||
prop_assert_eq!(
|
||
game.check_auto_complete(),
|
||
game.session().state().state().is_win_trivial(),
|
||
"check_auto_complete() disagreed with is_win_trivial() after {:?} actions",
|
||
actions.len(),
|
||
);
|
||
}
|
||
|
||
/// `check_win()` and `is_win()` must agree on every reachable game state.
|
||
#[test]
|
||
fn check_win_agrees_with_is_win(
|
||
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);
|
||
prop_assert_eq!(
|
||
game.check_win(),
|
||
game.session().state().state().is_win(),
|
||
"check_win() disagreed with is_win()",
|
||
);
|
||
}
|
||
|
||
/// 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 cards = all_cards(&game);
|
||
prop_assert_eq!(cards.len(), 52, "card count ≠ 52 (got {})", cards.len());
|
||
let unique: std::collections::HashSet<Card> = cards.iter().cloned().collect();
|
||
prop_assert_eq!(
|
||
unique.len(), 52,
|
||
"duplicate cards 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_cards(&a),
|
||
all_cards(&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_cards(&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_cards(&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 instruction in game.possible_instructions() {
|
||
// Clone so each move is tried from the same starting state.
|
||
let mut trial = game.clone();
|
||
let result = trial.apply_instruction(instruction);
|
||
prop_assert!(
|
||
result.is_ok(),
|
||
"possible_instructions() reported {instruction:?} \
|
||
as legal but the call returned Err: {result:?}",
|
||
);
|
||
}
|
||
}
|
||
|
||
}
|