//! Async H-key hint solver, modelled on `PendingNewGameSeed` in //! `game_plugin`. //! //! The synchronous version (v0.17.0) called //! `solitaire_core::solver::try_solve_from_state` on the main thread on //! every H press. Median latency was ~2 ms but pathological positions //! can hit the `SolverConfig::default()` cap at ~120 ms, which is a //! noticeable input-stall on the same frame the player sees the hint //! request. //! //! This module hosts the resource and polling system that move the //! solver call onto `AsyncComputeTaskPool`. `handle_keyboard_hint` //! (input_plugin) becomes a thin spawn point: snapshot the state, //! spawn the task, store the handle. The polling system takes the //! result one frame later and surfaces the hint visuals via the //! shared `emit_hint_visuals` helper. //! //! Cancel-on-replace: a fresh H press while a previous task is in //! flight drops the previous task. Bevy's `Task` `Drop` cancels //! cooperatively at the next await point. //! //! Stale-state drop: any `StateChangedEvent` (move applied, undo, //! new game) drops the in-flight task — the position the solver was //! reasoning about no longer exists, and surfacing a hint for the //! old state would be confusing. use bevy::prelude::*; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use solitaire_core::KlondikePile; use solitaire_core::game_state::GameState; use solitaire_data::solver::{SolverConfig, SolverResult, try_solve_from_state}; use crate::card_plugin::CardEntity; use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent}; use crate::input_plugin::{emit_hint_visuals, find_heuristic_hint}; use crate::resources::{GameStateResource, HintCycleIndex}; /// In-flight async work for the H-key hint. /// /// `handle_keyboard_hint` writes here when the player presses H; /// `poll_pending_hint_task` reads from here, polls the task, and /// emits the hint visuals once the task completes. At most one task /// is ever in flight: a fresh H press while a previous task is /// running drops the previous task and queues the new one. #[derive(Resource, Default)] pub struct PendingHintTask { /// `Some` while the solver is still working on a verdict. inner: Option, } impl PendingHintTask { /// Whether a hint task is currently in flight. pub fn is_pending(&self) -> bool { self.inner.is_some() } /// Drop any in-flight task. Bevy's `Task` `Drop` cancels the /// underlying future cooperatively at the next await point. pub fn cancel(&mut self) { self.inner = None; } /// Spawn a new solver task for `state` with `config`. Drops any /// previously in-flight task first (cancel-on-replace). pub fn spawn(&mut self, state: GameState, config: SolverConfig) { let move_count_at_spawn = state.move_count; let handle = AsyncComputeTaskPool::get().spawn(async move { let outcome = try_solve_from_state(&state, &config); match outcome.result { SolverResult::Winnable => outcome .first_move .map(|mv| HintTaskOutput::SolverMove { from: mv.source, to: mv.dest, }) .unwrap_or(HintTaskOutput::NeedsHeuristic), SolverResult::Unwinnable | SolverResult::Inconclusive => { HintTaskOutput::NeedsHeuristic } } }); self.inner = Some(HintTask { handle, move_count_at_spawn, }); } } /// One in-flight hint search plus the snapshot data needed to detect /// a stale result if the live state moved while the solver ran. struct HintTask { handle: Task, /// `GameState.move_count` at spawn time. The poll system discards /// the result if the live move_count has advanced — the player /// applied a move while the solver ran, so the hint would be /// stale even if the StateChangedEvent drop didn't fire first. move_count_at_spawn: u32, } /// What the solver task carries back to the main thread. enum HintTaskOutput { /// Solver verdict was `Winnable`; here is the first move on the /// solution path. SolverMove { from: KlondikePile, to: KlondikePile, }, /// Solver was `Unwinnable` or `Inconclusive`. The poll system /// runs the legacy heuristic against the live `GameState` so the /// H key always produces feedback while any legal move exists. NeedsHeuristic, } /// Drop the in-flight hint task whenever the live `GameState` shifts. /// /// The position the solver was reasoning about no longer matches the /// live state, so its result would be stale. Mirrors the semantics /// of `reset_hint_cycle_on_state_change` for `HintCycleIndex`. pub fn drop_pending_hint_on_state_change( mut state_events: MessageReader, mut pending: ResMut, ) { if state_events.read().next().is_some() { pending.cancel(); } } /// Poll the in-flight hint solver task. When the task resolves, run /// `emit_hint_visuals` on the result — either the solver's /// provably-best first move (Winnable verdict) or a heuristic hint /// over the live state (Unwinnable / Inconclusive). /// /// Discards the result when `GameState.move_count` has moved past the /// snapshot taken at spawn time — the player applied a move during /// the solve and `drop_pending_hint_on_state_change` should have /// already cleared the resource, but we double-check here for the /// rare case where the solver task completed in the same frame the /// move was applied. #[allow(clippy::too_many_arguments)] pub fn poll_pending_hint_task( mut pending: ResMut, game: Option>, mut hint_cycle: ResMut, mut commands: Commands, card_entities: Query<(Entity, &CardEntity, &mut Sprite)>, mut info_toast: MessageWriter, mut hint_visual: MessageWriter, ) { let Some(p) = pending.inner.as_mut() else { return; }; let Some(output) = future::block_on(future::poll_once(&mut p.handle)) else { return; }; let move_count_at_spawn = p.move_count_at_spawn; pending.inner = None; let Some(g) = game else { return }; if g.0.move_count != move_count_at_spawn { return; } let (from, to) = match output { HintTaskOutput::SolverMove { from, to } => (from, to), HintTaskOutput::NeedsHeuristic => match find_heuristic_hint(&g.0, &mut hint_cycle) { Some(pair) => pair, None => { info_toast.write(InfoToastEvent("No hints available".to_string())); return; } }, }; emit_hint_visuals( &g.0, &from, &to, &mut commands, card_entities, &mut info_toast, &mut hint_visual, ); } #[cfg(test)] mod tests { use super::*; use crate::events::HintVisualEvent; use crate::input_plugin::HintSolverConfig; use solitaire_core::{Foundation, Tableau}; use solitaire_core::card::{Card, Deck, Rank, Suit}; use solitaire_core::{DrawMode, game_state::GameState}; /// Build a minimal Bevy app exercising only the polling system /// and the resources/messages it touches. fn pending_hint_app() -> App { let mut app = App::new(); app.add_plugins(MinimalPlugins); app.add_message::(); app.add_message::(); app.add_message::(); app.init_resource::(); app.init_resource::(); app.init_resource::(); // Chain the drop-on-state-change system before the poll // system, mirroring how `InputPlugin::build` wires them. // Without this, system order is unspecified and the // state_change_drops_in_flight_task test sometimes sees the // poll fire before the drop. app.add_systems( Update, (drop_pending_hint_on_state_change, poll_pending_hint_task).chain(), ); app } /// Same near-finished fixture used by the v0.17 hint tests: /// foundations hold A..Q for each suit, four Kings sit on /// tableau columns 0..3, stock and waste empty. fn near_finished_state() -> GameState { let mut game = GameState::new(1, DrawMode::DrawOne); game.set_test_stock_cards(Vec::new()); game.set_test_waste_cards(Vec::new()); for foundation in [ Foundation::Foundation1, Foundation::Foundation2, Foundation::Foundation3, Foundation::Foundation4, ] { game.set_test_foundation_cards(foundation, Vec::new()); } for tableau in [ Tableau::Tableau1, Tableau::Tableau2, Tableau::Tableau3, Tableau::Tableau4, Tableau::Tableau5, Tableau::Tableau6, Tableau::Tableau7, ] { game.set_test_tableau_cards(tableau, Vec::new()); } let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades]; let ranks_below_king = [ Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen, ]; for (foundation, suit) in [ Foundation::Foundation1, Foundation::Foundation2, Foundation::Foundation3, Foundation::Foundation4, ] .into_iter() .zip(suits.iter()) { let mut cards = Vec::new(); for rank in ranks_below_king.iter() { cards.push(Card::new(Deck::Deck1, *suit, *rank)); } game.set_test_foundation_cards(foundation, cards); } for (tableau, suit) in [ Tableau::Tableau1, Tableau::Tableau2, Tableau::Tableau3, Tableau::Tableau4, ] .into_iter() .zip(suits.iter()) { game.set_test_tableau_cards( tableau, vec![Card::new(Deck::Deck1, *suit, Rank::King)], ); } game } /// Spawning a task and pumping update() until it completes must /// emit a HintVisualEvent. Mirrors the `winnable_seed_search_*` /// pattern in game_plugin tests — drives a wall-clock-bounded /// loop so the shared AsyncComputeTaskPool can schedule the /// future under cargo-test parallelism. #[test] fn winnable_solver_emits_hint_after_async_completes() { let mut app = pending_hint_app(); app.insert_resource(GameStateResource(near_finished_state())); let cfg = app.world().resource::().0; app.world_mut() .resource_mut::() .spawn(near_finished_state(), cfg); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); while app.world().resource::().is_pending() { app.update(); std::thread::yield_now(); if std::time::Instant::now() >= deadline { break; } } assert!( !app.world().resource::().is_pending(), "hint task should have completed within 15 s wall-clock", ); let messages = app.world().resource::>(); let mut cursor = messages.get_cursor(); let collected: Vec = cursor.read(messages).cloned().collect(); assert_eq!( collected.len(), 1, "exactly one HintVisualEvent must fire when the solver returns Winnable", ); assert!( matches!(collected[0].dest_pile, KlondikePile::Foundation(_)), "solver hint destination must be a foundation slot; got {:?}", collected[0].dest_pile, ); } /// A StateChangedEvent fired while the task is in flight must /// drop the task; the polling system must not emit any visuals /// once the result eventually arrives. #[test] fn state_change_drops_in_flight_task() { let mut app = pending_hint_app(); app.insert_resource(GameStateResource(near_finished_state())); let cfg = app.world().resource::().0; app.world_mut() .resource_mut::() .spawn(near_finished_state(), cfg); assert!( app.world().resource::().is_pending(), "task is in flight after spawn", ); // Fire a StateChangedEvent before draining the task. The // drop-on-state-change system runs in the same Update tick // and clears the resource. app.world_mut().write_message(StateChangedEvent); app.update(); assert!( !app.world().resource::().is_pending(), "StateChangedEvent must drop the in-flight hint task", ); // No HintVisualEvent should ever have fired. let messages = app.world().resource::>(); let mut cursor = messages.get_cursor(); assert_eq!( cursor.read(messages).count(), 0, "dropped hint task must not emit any visuals", ); } /// Cancel-on-replace: spawning a fresh task while a previous one /// is in flight must drop the previous task. Only the second /// spawn's result is allowed to surface. #[test] fn second_spawn_drops_first_in_flight_task() { let mut app = pending_hint_app(); app.insert_resource(GameStateResource(near_finished_state())); let cfg = app.world().resource::().0; // First spawn. app.world_mut() .resource_mut::() .spawn(near_finished_state(), cfg); let first_handle_present = app.world().resource::().is_pending(); assert!(first_handle_present); // Second spawn. The `spawn` helper drops the prior task // before assigning the new one — at no point are two tasks // in flight. app.world_mut() .resource_mut::() .spawn(near_finished_state(), cfg); // Resource still pending (the second task), but the first // is gone. We can't directly observe the first handle once // it's been overwritten — what we *can* assert is that the // resource still holds a single task, and that task // eventually completes producing exactly one hint visual. assert!(app.world().resource::().is_pending()); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); while app.world().resource::().is_pending() { app.update(); std::thread::yield_now(); if std::time::Instant::now() >= deadline { break; } } assert!( !app.world().resource::().is_pending(), "second hint task should have completed within 15 s wall-clock", ); let messages = app.world().resource::>(); let mut cursor = messages.get_cursor(); let collected: Vec = cursor.read(messages).cloned().collect(); assert_eq!( collected.len(), 1, "cancel-on-replace: only the surviving task's result emits a visual", ); } }