refactor(core): integrate card_game/klondike deps cleanly
Wire card_game 0.4.0 and klondike 0.3.0 as workspace deps in solitaire_core and clean the integration seam across five areas: - Move From<card_game::Suit/Rank> bridge impls out of card.rs and into klondike_adapter.rs so the product-type module is upstream-dep-free - Add `use crate::card` alias to adapter; rename card_from_kl parameter to avoid shadowing; correct score_for_undo doc (it is Ferrous policy, not an upstream default — the solver explicitly passes undo_penalty=0) - Mark Pile as a read-only projection / data-transfer type in its doc comment so game logic isn't accidentally routed through it - Add GameState::session() read accessor exposing the underlying Session<Klondike> for replay history and solver use by external crates; update solver.rs to use the accessor instead of the pub(crate) field - Re-export Foundation, Klondike, KlondikePile, Session, Tableau from solitaire_core::lib so downstream crates (engine, wasm) can import from one place without a direct klondike/card_game dep - Add proptest property tests: card conservation (52 unique IDs always present), deal determinism, undo pile-layout invariant, legal moves always succeed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user