From 299f6bfea7a7b537fef192a8bb6cda10d0d930ce Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 7 Jul 2026 16:19:12 -0700 Subject: [PATCH] =?UTF-8?q?feat(core):=20winning=5Fline=20=E2=80=94=20full?= =?UTF-8?q?=20solver=20line=20via=20Solution::clean=5Fsolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- solitaire_core/src/game_state.rs | 142 +++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index fbcce7c..48c322d 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -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>, 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 = solution + .clean_solution() + .iter() + .map(|snapshot| *snapshot.instruction()) + .collect(); + let filtered: Vec = 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`. /// /// 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 = ::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)] mod tests { use super::*; @@ -1351,6 +1430,69 @@ mod tests { 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] fn budget_is_passed_through_not_clamped() { // This seed is Inconclusive at 1k states but Winnable at 5k — proving the