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:
funman300
2026-05-05 23:02:22 +00:00
parent bf660df971
commit 8a5fa8751c
6 changed files with 1236 additions and 6 deletions
+51
View File
@@ -132,6 +132,11 @@ struct TooltipDelayText;
#[derive(Component, Debug)]
struct TimeBonusMultiplierText;
/// Marks the `Text` node showing the current "Winnable deals only"
/// state ("ON" / "OFF") in the Gameplay section.
#[derive(Component, Debug)]
struct WinnableDealsOnlyText;
/// Marks the scrollable inner card so the mouse-wheel system can target it.
#[derive(Component, Debug)]
struct SettingsPanelScrollable;
@@ -176,6 +181,11 @@ enum SettingsButton {
TimeBonusUp,
ToggleTheme,
ToggleColorBlind,
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
/// random Classic-mode deals are filtered through
/// [`solitaire_core::solver::try_solve`] until one is provably
/// winnable (or the retry cap is hit). Off by default.
ToggleWinnableDealsOnly,
SyncNow,
Done,
/// Select a specific card-back by index from the picker row.
@@ -203,6 +213,7 @@ impl SettingsButton {
SettingsButton::MusicUp => 21,
// Gameplay section
SettingsButton::ToggleDrawMode => 30,
SettingsButton::ToggleWinnableDealsOnly => 35,
SettingsButton::CycleAnimSpeed => 40,
SettingsButton::TooltipDelayDown => 45,
SettingsButton::TooltipDelayUp => 46,
@@ -299,6 +310,7 @@ impl Plugin for SettingsPlugin {
update_color_blind_text,
update_tooltip_delay_text,
update_time_bonus_multiplier_text,
update_winnable_deals_only_text,
attach_focusable_to_settings_buttons,
scroll_focus_into_view,
),
@@ -549,6 +561,21 @@ fn update_color_blind_text(
}
}
/// Refreshes the live "Winnable deals only" toggle value in the
/// Gameplay section whenever `SettingsResource` changes (button click,
/// hand-edited `settings.json` reload, etc.).
fn update_winnable_deals_only_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<WinnableDealsOnlyText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = winnable_deals_only_label(settings.0.winnable_deals_only);
}
}
/// Refreshes the live tooltip-delay value in the Gameplay section
/// whenever `SettingsResource` changes (slider buttons, hand-edited
/// settings.json reload, etc.).
@@ -758,6 +785,13 @@ fn handle_settings_buttons(
**t = color_blind_label(settings.0.color_blind_mode);
}
}
SettingsButton::ToggleWinnableDealsOnly => {
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// The Text node is refreshed by `update_winnable_deals_only_text`
// on the next frame via `settings.is_changed()`.
}
SettingsButton::SelectCardBack(idx) => {
settings.0.selected_card_back = *idx;
persist(&path, &settings.0);
@@ -812,6 +846,13 @@ fn color_blind_label(enabled: bool) -> String {
if enabled { "ON".into() } else { "OFF".into() }
}
/// Display string for the "Winnable deals only" toggle. Mirrors
/// [`color_blind_label`] — "ON" / "OFF" — so the layout is uniform
/// with the rest of the Gameplay-section toggles.
fn winnable_deals_only_label(enabled: bool) -> String {
if enabled { "ON".into() } else { "OFF".into() }
}
/// Formats the tooltip-hover delay for display in the Settings panel.
/// `0.0` reads as `"Instant"` so the zero-delay case has a name; any
/// other value prints as `"{n:.1} s"` (e.g. `"0.5 s"`, `"1.2 s"`).
@@ -1158,6 +1199,16 @@ fn spawn_settings_panel(
"Switch between Draw 1 and Draw 3. Takes effect next deal.",
font_res,
);
toggle_row(
body,
"Winnable deals only",
WinnableDealsOnlyText,
winnable_deals_only_label(settings.winnable_deals_only),
SettingsButton::ToggleWinnableDealsOnly,
"When on, fresh Classic deals are filtered through a solver \
(may take a moment when on).",
font_res,
);
toggle_row(
body,
"Anim Speed",