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 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