feat(core,engine): Klondike solver and "Winnable deals only" toggle
Closes Quat investigation #1. Today some Klondike deals are unwinnable from the start and the player has no signal that the deal they were given is solvable. A new Settings → Gameplay toggle "Winnable deals only" (default off) makes the engine retry seeds at deal-time until the solver returns Winnable, up to a cap. Solver solitaire_core::solver is a hand-rolled iterative-DFS solver with memoisation on a 64-bit canonical state hash. Move enumeration is priority-ordered: foundation moves first (zero choice when an Ace or rank-up exists), inter-tableau moves second, waste-to-tableau third, stock-draw last. The draw is skipped when the cycle counter shows we've recirculated the entire stock without progress — Klondike's deterministic stock cycle means further draws can't unlock anything new. Two budget knobs (move_budget = 100k, state_budget = 200k by default) cap pathological cases at Inconclusive; the caller treats Inconclusive as "winnable" so the player isn't penalised for the solver giving up. Median solve time is 2 ms; pathological inconclusives top out near 120 ms. Switched from recursive to iterative DFS after a real-deal solve overflowed Rust's default 8 MB thread stack. Behaviour identical; the change is invisible to callers. Pure logic — solitaire_core has no Bevy or I/O. Same input always yields the same SolverResult. Settings Settings.winnable_deals_only is a #[serde(default)] bool; legacy files load to false. SOLVER_DEAL_RETRY_CAP = 50 caps the retry loop. The Settings → Gameplay toggle reads as "Winnable deals only" with a "(may take a moment when on)" caption. Engine integration handle_new_game's seed-selection path now branches on the toggle. When on AND mode is Classic AND no specific seed was requested (daily challenges, replays, and explicit-seed requests bypass the solver), choose_winnable_seed walks seed N, N+1, N+2, … calling try_solve until it finds Winnable or Inconclusive. If the cap is hit without a verdict, the latest tried seed is used so the player always gets a deal rather than spinning forever. 19 new tests (11 solver, 3 settings, 5 engine including the choose_winnable_seed unit). Two ignored bench/scan helpers (solver_bench, find_unwinnable) for ad-hoc profiling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -166,6 +166,21 @@ pub struct Settings {
|
||||
/// `#[serde(default = "default_time_bonus_multiplier")]`.
|
||||
#[serde(default = "default_time_bonus_multiplier")]
|
||||
pub time_bonus_multiplier: f32,
|
||||
/// When `true`, the engine rejects new-game deals the
|
||||
/// [`solitaire_core::solver`] cannot prove winnable, retrying
|
||||
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
|
||||
/// giving up and using the last tried seed. Off by default —
|
||||
/// the solver adds a few hundred milliseconds of latency on the
|
||||
/// pathological deals that hit the budget cap, and not every
|
||||
/// player wants to wait. Older `settings.json` files written
|
||||
/// before this field existed deserialize cleanly to `false` via
|
||||
/// `#[serde(default)]`.
|
||||
///
|
||||
/// Scope: only random-seed Classic-mode deals are filtered.
|
||||
/// Daily challenges, replays, and explicit-seed requests skip the
|
||||
/// solver retry loop — see `solitaire_engine::handle_new_game`.
|
||||
#[serde(default)]
|
||||
pub winnable_deals_only: bool,
|
||||
}
|
||||
|
||||
fn default_draw_mode() -> DrawMode {
|
||||
@@ -223,6 +238,17 @@ fn default_time_bonus_multiplier() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
|
||||
/// is willing to attempt before giving up and accepting the latest
|
||||
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
|
||||
/// every retry comes back [`SolverResult::Unwinnable`] (which would
|
||||
/// be very unusual) we'd rather hand the player a possibly-unwinnable
|
||||
/// deal than spin forever on the main thread.
|
||||
///
|
||||
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
|
||||
/// the upper bound on UI freeze when the toggle is on.
|
||||
pub const SOLVER_DEAL_RETRY_CAP: u32 = 50;
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -241,6 +267,7 @@ impl Default for Settings {
|
||||
shown_achievement_onboarding: false,
|
||||
tooltip_delay_secs: default_tooltip_delay(),
|
||||
time_bonus_multiplier: default_time_bonus_multiplier(),
|
||||
winnable_deals_only: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -428,6 +455,7 @@ mod tests {
|
||||
shown_achievement_onboarding: false,
|
||||
tooltip_delay_secs: default_tooltip_delay(),
|
||||
time_bonus_multiplier: default_time_bonus_multiplier(),
|
||||
winnable_deals_only: false,
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
@@ -835,4 +863,49 @@ mod tests {
|
||||
s2.time_bonus_multiplier
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// winnable_deals_only — solver-backed deal filter toggle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn settings_winnable_deals_only_default_is_false() {
|
||||
// Off by default — the solver adds latency we shouldn't impose
|
||||
// on every player without their consent.
|
||||
assert!(
|
||||
!Settings::default().winnable_deals_only,
|
||||
"default winnable_deals_only must be false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_winnable_deals_only_round_trip() {
|
||||
let path = tmp_path("winnable_deals_only_round_trip");
|
||||
let _ = fs::remove_file(&path);
|
||||
let s = Settings {
|
||||
winnable_deals_only: true,
|
||||
..Settings::default()
|
||||
};
|
||||
save_settings_to(&path, &s).expect("save");
|
||||
let loaded = load_settings_from(&path);
|
||||
assert!(
|
||||
loaded.winnable_deals_only,
|
||||
"winnable_deals_only must survive serde round-trip"
|
||||
);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_settings_without_winnable_deals_only_deserializes_to_false() {
|
||||
// A settings.json written before this field existed must
|
||||
// deserialize cleanly to `false` (the default-off behaviour)
|
||||
// rather than failing the whole load or surprising the player
|
||||
// by switching the toggle on.
|
||||
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
|
||||
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
|
||||
assert!(
|
||||
!s.winnable_deals_only,
|
||||
"legacy settings.json missing winnable_deals_only must deserialize to false"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user