From 299f6bfea7a7b537fef192a8bb6cda10d0d930ce Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 7 Jul 2026 16:19:12 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(core):=20winning=5Fline=20=E2=80=94=20?= =?UTF-8?q?full=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 -- 2.47.3 From ea1014285d6b26bcaf906cb6d9cfe9e690a73a4d Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 7 Jul 2026 16:33:49 -0700 Subject: [PATCH 2/2] =?UTF-8?q?feat(engine):=20Show=20solution=20=E2=80=94?= =?UTF-8?q?=20auto-play=20the=20winning=20line=20from=20the=20pause=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New SolutionPlaybackPlugin: the pause modal's 'Show solution' button resumes the game and requests a solve of the live position via GameState::winning_line on AsyncComputeTaskPool (never blocks the main thread; stale results discarded via a move_count snapshot). The line then plays back one instruction per 0.45 s through the normal MoveRequestEvent / DrawRequestEvent pipeline — animations, scoring, undo history and win detection behave as if the player made the moves. Tableau run counts are decoded against the live state at step time. Playback cancels on Esc, pause, undo / new-game requests, and any rejected move (the signature of the player diverging the board). Toasts cover search start, line found, unwinnable, and budget- exhausted outcomes. Co-Authored-By: Claude Fable 5 --- solitaire_engine/src/core_game_plugin.rs | 8 +- solitaire_engine/src/events.rs | 6 + solitaire_engine/src/lib.rs | 2 + solitaire_engine/src/pause_plugin.rs | 32 ++ .../src/solution_playback_plugin.rs | 414 ++++++++++++++++++ 5 files changed, 459 insertions(+), 3 deletions(-) create mode 100644 solitaire_engine/src/solution_playback_plugin.rs diff --git a/solitaire_engine/src/core_game_plugin.rs b/solitaire_engine/src/core_game_plugin.rs index 18aaa98..5e9f7d1 100644 --- a/solitaire_engine/src/core_game_plugin.rs +++ b/solitaire_engine/src/core_game_plugin.rs @@ -18,9 +18,10 @@ use crate::{ DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, - SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider, - TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, - UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, + SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin, + StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, + TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, + WinSummaryPlugin, }; #[cfg(not(target_arch = "wasm32"))] use crate::{ @@ -93,6 +94,7 @@ impl Plugin for CoreGamePlugin { .add_plugins(FeedbackAnimPlugin) .add_plugins(CardAnimationPlugin) .add_plugins(AutoCompletePlugin) + .add_plugins(SolutionPlaybackPlugin) .add_plugins(ReplayPlaybackPlugin) .add_plugins(ReplayOverlayPlugin) .add_plugins(StatsPlugin::default()) diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index 79eb0ea..95c3563 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -159,6 +159,12 @@ pub struct DeleteAccountRequestEvent; #[derive(Message, Debug, Clone, Copy, Default)] pub struct PauseRequestEvent; +/// Request to solve the current deal and auto-play the winning line. +/// Fired by the pause menu's "Show solution" button; consumed by +/// `solution_playback_plugin`. +#[derive(Message, Debug, Clone, Copy, Default)] +pub struct ShowSolutionRequestEvent; + /// Request to toggle the help / controls overlay. Fired by the HUD "Help" /// button alongside the existing `F1` accelerator so the overlay is /// reachable without a keyboard. Consumed by `help_plugin::toggle_help_screen`. diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 9e89dd9..1ab4e59 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -46,6 +46,7 @@ pub mod safe_area; mod schedule_checks; pub mod selection_plugin; pub mod settings_plugin; +pub mod solution_playback_plugin; pub mod splash_plugin; pub mod stats_plugin; #[cfg(not(target_arch = "wasm32"))] @@ -161,6 +162,7 @@ pub use settings_plugin::{ SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS, }; pub use solitaire_data::SyncProvider; +pub use solution_playback_plugin::{SolutionPlayback, SolutionPlaybackPlugin, SolutionSolveTask}; pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot}; pub use stats_plugin::{ LatestReplayPath, ReplayHistoryResource, ReplayNextButton, ReplayPrevButton, diff --git a/solitaire_engine/src/pause_plugin.rs b/solitaire_engine/src/pause_plugin.rs index c57dffe..818d35f 100644 --- a/solitaire_engine/src/pause_plugin.rs +++ b/solitaire_engine/src/pause_plugin.rs @@ -71,6 +71,12 @@ struct PauseResumeButton; #[derive(Component, Debug)] struct PauseForfeitButton; +/// Marker on the "Show solution" secondary button on the pause modal. +/// A click resumes the game and fires `ShowSolutionRequestEvent`; +/// `solution_playback_plugin` takes it from there. +#[derive(Component, Debug)] +struct PauseSolutionButton; + /// Marker on the forfeit-confirm modal scrim. #[derive(Component, Debug)] pub struct ForfeitConfirmScreen; @@ -107,6 +113,7 @@ impl Plugin for PausePlugin { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .init_resource::() .add_systems( @@ -125,6 +132,7 @@ impl Plugin for PausePlugin { handle_pause_draw_buttons, handle_pause_resume_button, handle_pause_forfeit_button, + handle_pause_solution_button, handle_forfeit_request, handle_forfeit_confirm_buttons, handle_forfeit_keyboard, @@ -304,6 +312,22 @@ fn handle_pause_resume_button( } } +/// Translates a click on the pause modal's "Show solution" button into +/// a resume (`PauseRequestEvent` — playback can't run while paused) +/// plus a `ShowSolutionRequestEvent` for `solution_playback_plugin`. +fn handle_pause_solution_button( + interaction_query: Query<&Interaction, (Changed, With)>, + mut pause: MessageWriter, + mut solution: MessageWriter, +) { + for interaction in &interaction_query { + if *interaction == Interaction::Pressed { + pause.write(PauseRequestEvent); + solution.write(crate::events::ShowSolutionRequestEvent); + } + } +} + /// Translates a click on the pause modal's Forfeit button into a /// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the /// confirm modal — same code path as the `G` accelerator. @@ -498,6 +522,14 @@ fn spawn_pause_screen( ButtonVariant::Tertiary, font_res, ); + spawn_modal_button( + actions, + PauseSolutionButton, + "Show solution", + None, + ButtonVariant::Secondary, + font_res, + ); spawn_modal_button( actions, PauseResumeButton, diff --git a/solitaire_engine/src/solution_playback_plugin.rs b/solitaire_engine/src/solution_playback_plugin.rs new file mode 100644 index 0000000..1c65609 --- /dev/null +++ b/solitaire_engine/src/solution_playback_plugin.rs @@ -0,0 +1,414 @@ +//! "Show solution" — solve the current deal off-thread and auto-play +//! the winning line through the normal move pipeline. +//! +//! The pause menu's "Show solution" button fires +//! [`crate::events::ShowSolutionRequestEvent`]. This plugin snapshots +//! the live [`GameState`], runs [`GameState::winning_line`] on +//! [`AsyncComputeTaskPool`] (the solver plus `clean_solution` can take +//! seconds — §2.4 never block the main thread), then steps the returned +//! instructions on a cadence, one `MoveRequestEvent` / +//! `DrawRequestEvent` per tick — the same events player input produces, +//! so animations, scoring, undo history, and win detection all behave +//! exactly as if the player made the moves. +//! +//! Playback cancels on Esc, on pause, on undo / new-game requests, and +//! on any rejected move (which is what a player interfering mid-line +//! produces — their move diverges the state, the next scripted step +//! becomes illegal, and the rejection stops the run cleanly). + +use std::collections::VecDeque; + +use bevy::prelude::*; +use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; + +use solitaire_core::game_state::GameState; +use solitaire_core::{ + DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction, +}; + +use crate::events::{ + DrawRequestEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, + ShowSolutionRequestEvent, UndoRequestEvent, +}; +use crate::game_plugin::GameMutation; +use crate::pause_plugin::PausedResource; +use crate::resources::GameStateResource; + +/// Seconds between scripted moves — slow enough to follow, fast enough +/// not to drag on a 100-move line. +const STEP_INTERVAL_SECS: f32 = 0.45; + +/// Initial delay before the first scripted move, giving the pause modal +/// time to close and the player a beat to see what's happening. +const FIRST_STEP_DELAY_SECS: f32 = 0.9; + +/// In-flight solver task plus the `move_count` snapshot used to detect +/// a stale result (player moved while the solver ran). Mirrors +/// `PendingHintTask`. +#[derive(Resource, Default)] +pub struct SolutionSolveTask { + inner: Option<(u32, Task)>, +} + +/// What the solver task carries back to the main thread. +enum SolveTaskOutput { + Line(Vec), + Unwinnable, + Inconclusive, +} + +/// Queue of instructions currently being auto-played, or empty when no +/// playback is active. HUD/UI may read `is_active` to badge the state. +#[derive(Resource, Default)] +pub struct SolutionPlayback { + queue: VecDeque, + cooldown: f32, +} + +impl SolutionPlayback { + /// `true` while a solution line is being auto-played. + pub fn is_active(&self) -> bool { + !self.queue.is_empty() + } + + fn stop(&mut self) { + self.queue.clear(); + } +} + +/// Bevy plugin for the Show-solution flow. See the module docs. +pub struct SolutionPlaybackPlugin; + +impl Plugin for SolutionPlaybackPlugin { + fn build(&self, app: &mut App) { + // add_message is idempotent — GamePlugin registers most of these + // too, but this plugin must also boot standalone under + // MinimalPlugins in tests. + app.init_resource::() + .init_resource::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_systems( + Update, + ( + handle_show_solution_request, + poll_solution_task, + cancel_playback_on_interrupt, + drive_solution_playback, + ) + .chain() + .before(GameMutation), + ); + } +} + +/// Starts a solver task from the live game state. A repeat request +/// while one is already in flight (or playback is running) is ignored +/// — the button is idempotent, not a queue. +fn handle_show_solution_request( + mut requests: MessageReader, + game: Option>, + mut task: ResMut, + playback: Res, + mut toast: MessageWriter, +) { + if requests.is_empty() { + return; + } + requests.clear(); + if task.inner.is_some() || playback.is_active() { + return; + } + let Some(game) = game else { return }; + if game.0.is_won() { + return; + } + + toast.write(InfoToastEvent("Searching for a solution…".to_string())); + let snapshot: GameState = game.0.clone(); + let move_count = snapshot.move_count(); + let handle = AsyncComputeTaskPool::get().spawn(async move { + match snapshot.winning_line(DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET) { + Ok(Some(line)) => SolveTaskOutput::Line(line), + Ok(None) => SolveTaskOutput::Unwinnable, + Err(_) => SolveTaskOutput::Inconclusive, + } + }); + task.inner = Some((move_count, handle)); +} + +/// Polls the solver; on completion either starts playback or explains +/// why there is nothing to play. A result computed for a position the +/// player has since moved past is discarded silently. +fn poll_solution_task( + mut task: ResMut, + game: Option>, + mut playback: ResMut, + mut toast: MessageWriter, +) { + let Some((move_count_at_spawn, handle)) = task.inner.as_mut() else { + return; + }; + let Some(output) = future::block_on(future::poll_once(handle)) else { + return; + }; + let move_count_at_spawn = *move_count_at_spawn; + task.inner = None; + + let Some(game) = game else { return }; + if game.0.move_count() != move_count_at_spawn { + return; // Stale — the board moved while we were solving. + } + + match output { + SolveTaskOutput::Line(line) if line.is_empty() => {} + SolveTaskOutput::Line(line) => { + toast.write(InfoToastEvent(format!( + "Solution found — playing {} moves. Press Esc to stop.", + line.len() + ))); + playback.queue = line.into(); + playback.cooldown = FIRST_STEP_DELAY_SECS; + } + SolveTaskOutput::Unwinnable => { + toast.write(InfoToastEvent( + "No winning line exists from this position.".to_string(), + )); + } + SolveTaskOutput::Inconclusive => { + toast.write(InfoToastEvent( + "Couldn't find a solution within the search budget.".to_string(), + )); + } + } +} + +/// Stops playback on Esc, pause, undo / new-game requests, or a +/// rejected move (the signature of the player diverging the board +/// mid-line). Runs before `drive_solution_playback` so a cancel takes +/// effect without one extra scripted move slipping out. +fn cancel_playback_on_interrupt( + mut playback: ResMut, + keys: Option>>, + paused: Option>, + mut rejected: MessageReader, + mut undos: MessageReader, + mut new_games: MessageReader, + mut toast: MessageWriter, +) { + if !playback.is_active() { + rejected.clear(); + undos.clear(); + new_games.clear(); + return; + } + let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape)); + let interrupted = esc + || paused.is_some_and(|p| p.0) + || rejected.read().next().is_some() + || undos.read().next().is_some() + || new_games.read().next().is_some(); + if interrupted { + playback.stop(); + toast.write(InfoToastEvent("Solution playback stopped.".to_string())); + } +} + +/// Emits the next scripted instruction every [`STEP_INTERVAL_SECS`] +/// while playback is active, translated to the same request events +/// player input produces. Instructions that no longer decode against +/// the live state stop the run instead of guessing. +fn drive_solution_playback( + mut playback: ResMut, + game: Option>, + time: Res