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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user