//! "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