refactor(core): integrate card_game/klondike deps cleanly
Build and Deploy / build-and-push (push) Failing after 56s
Web E2E / web-e2e (push) Failing after 3m14s

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:
funman300
2026-06-08 10:46:29 -07:00
parent 8bd2fb89eb
commit 5e8735886f
7 changed files with 349 additions and 51 deletions
+44 -10
View File
@@ -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",
);
}
}