feat(core): winning_line — full solver line via Solution::clean_solution

New GameState::winning_line(moves_budget, states_budget) returns the
complete instruction sequence to a win (Ok(None) when unwinnable or
already won, Err on budget exhaustion), compacting the raw DFS trace
with the previously unused card_game Solution::clean_solution and
stripping foundation→foundation no-ops when the stripped line still
replays to a win — an internal replay check guarantees the returned
sequence always applies cleanly via apply_instruction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-07 16:19:12 -07:00
parent 6f27e775f2
commit 299f6bfea7
+142
View File
@@ -1109,6 +1109,66 @@ impl GameState {
}) })
} }
/// Full winning line from the current position:
///
/// * `Ok(Some(line))` — winnable; applying every instruction in order via
/// [`GameState::apply_instruction`] reaches a won game.
/// * `Ok(None)` — provably unwinnable, or already won (no moves to show).
/// * `Err(SolveError)` — inconclusive; budget exhausted before a verdict.
///
/// Delegates to upstream [`card_game::Session::solve`] on a solve-budgeted
/// copy of the board like [`GameState::solve_first_move`], but returns the
/// whole path instead of the first move, compacted with
/// [`card_game::Solution::clean_solution`] (drops move ranges that loop
/// back to an already-seen state — the raw DFS trace is full of them).
///
/// Foundation→foundation shuffles ([`KlondikeInstruction::is_useless`])
/// are additionally stripped when the remaining sequence still replays to
/// a win; if stripping one would break a later move's preconditions the
/// unstripped cleaned line is returned instead, so the replay contract
/// above always holds. `clean_solution` is quadratic-ish in line length —
/// callers should run this off the UI thread with modest budgets.
pub fn winning_line(
&self,
moves_budget: u64,
states_budget: u64,
) -> Result<Option<Vec<KlondikeInstruction>>, SolveError> {
if self.is_won() {
return Ok(None);
}
let inner = KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation);
let config = SessionConfig {
inner: inner.clone(),
undo_penalty: 0,
solve_moves_budget: moves_budget,
solve_states_budget: states_budget,
};
let start = self.session.state().state().clone();
let session = Session::new(start.clone(), config);
let Some(solution) = session.solve()? else {
return Ok(None);
};
let cleaned: Vec<KlondikeInstruction> = solution
.clean_solution()
.iter()
.map(|snapshot| *snapshot.instruction())
.collect();
let filtered: Vec<KlondikeInstruction> = cleaned
.iter()
.copied()
.filter(|instruction| !instruction.is_useless())
.collect();
if line_replays_to_win(start, &inner, &filtered) {
Ok(Some(filtered))
} else {
Ok(Some(cleaned))
}
}
/// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`. /// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`.
/// ///
/// Fresh-deal solving models standard Klondike rules, so the non-standard /// Fresh-deal solving models standard Klondike rules, so the non-standard
@@ -1126,6 +1186,25 @@ impl GameState {
} }
} }
/// `true` when applying `line` in order from `start` is legal at every step
/// and ends in a won game. Pure replay check backing
/// [`GameState::winning_line`]'s "the returned sequence always replays to a
/// win" contract.
fn line_replays_to_win(
mut state: Klondike,
config: &KlondikeConfig,
line: &[KlondikeInstruction],
) -> bool {
let mut stats = <Klondike as card_game::Game>::Stats::default();
for &instruction in line {
if !state.is_instruction_valid(config, instruction) {
return false;
}
state.process_instruction(&mut stats, config, instruction);
}
state.is_win()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1351,6 +1430,69 @@ mod tests {
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded))); assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
} }
// ── Full winning line (winning_line) ──────────────────────────────────
#[test]
fn winning_line_replays_to_a_won_game() {
// Seed 0xD1FF_0000_0000_0012 / DrawOne is proven Winnable at 5k
// budgets by `budget_is_passed_through_not_clamped`. Standard rules
// (take-from-foundation off) keep the search space identical to
// that baseline. The returned line must apply cleanly through the
// normal instruction pipeline and end in a win — the whole point
// of the API contract.
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
game.take_from_foundation = false;
let line = game
.winning_line(5_000, 5_000)
.expect("this seed must not exhaust a 5k budget")
.expect("this seed must be winnable");
assert!(!line.is_empty(), "a winnable unfinished game needs moves");
for (i, instruction) in line.iter().enumerate() {
game.apply_instruction(*instruction)
.unwrap_or_else(|e| panic!("move {i} of the line must be legal: {e}"));
}
assert!(game.is_won(), "line must end in a won game");
}
#[test]
fn winning_line_contains_no_useless_moves() {
// The foundation→foundation strip must survive the replay check on
// this seed; a line shown to the player should never shuffle
// between foundations. Same proven-winnable seed and standard
// rules as `winning_line_replays_to_a_won_game`.
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
game.take_from_foundation = false;
let line = game
.winning_line(5_000, 5_000)
.expect("budget")
.expect("winnable");
assert!(
!line.iter().any(KlondikeInstruction::is_useless),
"filtered line must not contain foundation→foundation moves"
);
}
#[test]
fn winning_line_is_inconclusive_when_budget_exhausted() {
let game = GameState::new(7, DrawStockConfig::DrawOne);
let outcome = game.winning_line(5_000, 0);
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
}
#[test]
fn winning_line_matches_first_move_verdict() {
// The two solver entry points must agree on winnability for the
// same position and budgets (both are deterministic DFS).
let game = GameState::new(42, DrawStockConfig::DrawOne);
let first = game.solve_first_move(5_000, 5_000);
let line = game.winning_line(5_000, 5_000);
assert_eq!(
matches!(first, Ok(Some(_))),
matches!(line, Ok(Some(_))),
"solve_first_move and winning_line disagree on winnability"
);
}
#[test] #[test]
fn budget_is_passed_through_not_clamped() { fn budget_is_passed_through_not_clamped() {
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the // This seed is Inconclusive at 1k states but Winnable at 5k — proving the