diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index 56a2f18..07669fe 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -84,6 +84,7 @@ impl Plugin for InputPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() + .init_resource::() .add_message::() .add_message::() .add_message::() @@ -109,7 +110,18 @@ impl Plugin for InputPlugin { .chain(), ) .add_systems(Update, handle_fullscreen) - .add_systems(Update, reset_hint_cycle_on_state_change); + .add_systems(Update, reset_hint_cycle_on_state_change) + // Async hint pipeline: state-change drop runs before the + // poll system so a move applied this frame cancels any + // in-flight task before its result can be surfaced. + .add_systems( + Update, + ( + crate::pending_hint::drop_pending_hint_on_state_change, + crate::pending_hint::poll_pending_hint_task, + ) + .chain(), + ); } } @@ -219,36 +231,29 @@ fn handle_keyboard_core( // Esc is handled by `PausePlugin` (overlay toggle + paused flag). } -/// Handles the H key: surface the solver's provably-best first move when -/// the position is winnable; otherwise fall back to cycling through the -/// heuristic hints. +/// Handles the H key: spawn an async solver task on +/// `AsyncComputeTaskPool` whose result `pending_hint::poll_pending_hint_task` +/// turns into hint visuals one frame later. /// -/// The solver (`solitaire_core::solver::try_solve_from_state`) is run -/// synchronously on each H press — median ~2 ms on real positions, with a -/// hard cap from `SolverConfig::default()`'s budgets. When the verdict is -/// `Winnable`, the returned `first_move` is shown as a single, stable hint -/// (no cycling — the optimal move doesn't change between identical -/// presses). When the verdict is `Unwinnable` or `Inconclusive`, the -/// handler falls back to the legacy heuristic in `all_hints`, which still -/// cycles through every legal move. +/// Median solve time is ~2 ms but pathological positions can hit the +/// `SolverConfig::default()` cap at ~120 ms; running synchronously +/// (the v0.17.0 behaviour) blocked the main thread on the same frame +/// the player pressed H. Cancel-on-replace lives in +/// `PendingHintTask::spawn` — a fresh H press while a previous task +/// is in flight drops the previous task's handle. /// -/// When no moves are available a "No hints available" toast is shown -/// instead. The H key always produces a hint when any legal move exists. -/// -/// TODO: if profiling ever shows >100 ms solver calls in practice, move -/// the solver call to `AsyncComputeTaskPool` to keep input latency low. -#[allow(clippy::too_many_arguments)] +/// Special-cases: when the game is already won, surface a "Game won!" +/// toast instead of asking the solver. The poll system handles the +/// "no legal moves" toast on the heuristic fallback path so the +/// handler here only needs to dispatch. fn handle_keyboard_hint( keys: Res>, paused: Option>, game: Option>, layout: Option>, solver_config: Res, - mut hint_cycle: ResMut, - mut commands: Commands, - card_entities: Query<(Entity, &CardEntity, &mut Sprite)>, + mut pending_hint: ResMut, mut info_toast: MessageWriter, - mut hint_visual: MessageWriter, ) { if paused.is_some_and(|p| p.0) { return; @@ -266,43 +271,37 @@ fn handle_keyboard_hint( let Some(_layout_res) = layout else { return }; - // First pass: ask the solver for the provably-best move. The - // solver is deterministic, so repeated H presses on the same - // position keep showing the same hint (cycling is reserved for - // the heuristic fallback path). - use solitaire_core::solver::{try_solve_from_state, SolverResult}; - let outcome = try_solve_from_state(&g.0, &solver_config.0); - if outcome.result == SolverResult::Winnable - && let Some(mv) = outcome.first_move - { - let from = mv.source.clone(); - let to = mv.dest.clone(); - emit_hint_visuals(&g.0, &from, &to, &mut commands, card_entities, &mut info_toast, &mut hint_visual); - return; - } + pending_hint.spawn(g.0.clone(), solver_config.0); +} - // Fallback: heuristic cycling hint. Used when the solver verdict - // is `Unwinnable` (no legal winning path — but a legal *move* may - // still exist, e.g. drawing from stock) or `Inconclusive` (budget - // exhausted on a complex mid-game position). - let hints = all_hints(&g.0); +/// Heuristic hint helper used by `pending_hint::poll_pending_hint_task` +/// when the solver returns `Inconclusive` or `Unwinnable`. +/// +/// Picks the hint at `HintCycleIndex % hints.len()` (wrapping) and +/// advances the index so successive H presses on a stuck position +/// cycle through every legal move. Returns `None` when no legal move +/// exists at all — the caller surfaces a "No hints available" toast. +pub fn find_heuristic_hint( + game: &GameState, + hint_cycle: &mut HintCycleIndex, +) -> Option<(PileType, PileType)> { + let hints = all_hints(game); if hints.is_empty() { - info_toast.write(InfoToastEvent("No hints available".to_string())); - return; + return None; } - - // Pick the hint at the current cycle index (wrapping) and advance. let idx = hint_cycle.0 % hints.len(); hint_cycle.0 = hint_cycle.0.wrapping_add(1); let (from, to, _count) = hints[idx].clone(); - emit_hint_visuals(&g.0, &from, &to, &mut commands, card_entities, &mut info_toast, &mut hint_visual); + Some((from, to)) } /// Apply the visual + toast effects for a single chosen hint move. /// /// Shared between the solver-driven and heuristic-driven hint paths so -/// both produce identical player-facing feedback. -fn emit_hint_visuals( +/// both produce identical player-facing feedback. Called from +/// `pending_hint::poll_pending_hint_task` once the async solver task +/// resolves. +pub fn emit_hint_visuals( game: &GameState, from: &PileType, to: &PileType, @@ -2149,191 +2148,50 @@ mod tests { } // ----------------------------------------------------------------------- - // Hint system — solver promotion (v0.16.0+) + // Hint system — async port (v0.18.0+) // - // The H-key hint is now backed by `solitaire_core::solver::try_solve_from_state`. - // When the solver proves the position winnable, the hint is the - // first move on the solver's solution path. When the solver returns - // Inconclusive (budget exhausted) or Unwinnable, the legacy - // heuristic in `all_hints` supplies the hint instead so the H key - // always produces feedback while any legal move exists. + // `handle_keyboard_hint` no longer runs the solver inline; it + // spawns an `AsyncComputeTaskPool` task whose result the polling + // system in `pending_hint` turns into hint visuals one frame + // later. The behaviour contract this section pins is "pressing H + // populates `PendingHintTask`" — the spawn-to-emit pipeline is + // covered end-to-end in `pending_hint::tests`. // ----------------------------------------------------------------------- - /// Build a minimal Bevy app that registers only the resources and - /// messages needed to drive `handle_keyboard_hint` end-to-end. - /// Skips every other input system — the test only exercises the hint - /// path and we want the assertions to be unaffected by other handlers. - fn hint_test_app() -> App { + /// Pressing H on a non-paused, non-won game with a live + /// `GameStateResource` + `LayoutResource` must populate + /// `PendingHintTask`. The polling system, exercised in + /// `pending_hint::tests`, drives the result to a visual event. + #[test] + fn pressing_h_spawns_pending_hint_task() { let mut app = App::new(); app.add_plugins(MinimalPlugins); app.add_message::(); app.add_message::(); app.init_resource::(); app.init_resource::(); + app.init_resource::(); app.init_resource::>(); - // Layout: a fixed 1280x800 layout — `handle_keyboard_hint` only - // checks the resource is present, never reads coordinates. app.insert_resource(crate::layout::LayoutResource( crate::layout::compute_layout(Vec2::new(1280.0, 800.0)), )); + app.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne))); app.add_systems(Update, handle_keyboard_hint); - app - } - /// Helper: simulate "the player just pressed H this frame". - fn press_h(app: &mut App) { - let mut input = app.world_mut().resource_mut::>(); - input.release(KeyCode::KeyH); - input.clear(); - input.press(KeyCode::KeyH); - } - - /// Build a near-finished `GameState`: foundations hold A..Q for each - /// suit, four Kings sit on tableau columns 0..3, stock and waste - /// empty. Solver-side equivalent of the `near_finished_game_state` - /// helper in `solitaire_core::solver::tests`. - fn near_finished_game_state() -> GameState { - use solitaire_core::card::{Card, Rank, Suit}; - let mut game = GameState::new(1, DrawMode::DrawOne); - for slot in 0..4_u8 { - game.piles - .get_mut(&PileType::Foundation(slot)) - .unwrap() - .cards - .clear(); + // Simulate the H key being pressed this frame. + { + let mut input = app.world_mut().resource_mut::>(); + input.release(KeyCode::KeyH); + input.clear(); + input.press(KeyCode::KeyH); } - for i in 0..7_usize { - game.piles - .get_mut(&PileType::Tableau(i)) - .unwrap() - .cards - .clear(); - } - game.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); - game.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); - let suit_for_slot = [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 (slot, suit) in suit_for_slot.iter().enumerate() { - let pile = game - .piles - .get_mut(&PileType::Foundation(slot as u8)) - .unwrap(); - for (i, rank) in ranks_below_king.iter().enumerate() { - pile.cards.push(Card { - id: (slot as u32) * 13 + i as u32, - suit: *suit, - rank: *rank, - face_up: true, - }); - } - } - for (col, suit) in suit_for_slot.iter().enumerate() { - game.piles - .get_mut(&PileType::Tableau(col)) - .unwrap() - .cards - .push(Card { - id: 100 + col as u32, - suit: *suit, - rank: Rank::King, - face_up: true, - }); - } - game - } - - /// When the solver verdict is Winnable, the hint must come from the - /// solver: in our near-finished fixture, four Tableau→Foundation - /// moves are legal and the solver returns one of them. The - /// `HintVisualEvent` source card must be one of the four Kings and - /// the destination must be a foundation slot. - #[test] - fn hint_uses_solver_when_winnable() { - use solitaire_core::card::Rank; - let mut app = hint_test_app(); - let game = near_finished_game_state(); - // Track the 4 King ids so we can assert the hint source matches. - let king_ids: Vec = (0..4_u8) - .map(|c| { - game.piles - .get(&PileType::Tableau(c as usize)) - .unwrap() - .cards - .last() - .filter(|c| c.rank == Rank::King) - .map(|c| c.id) - .expect("each tableau col 0..3 has a King on top") - }) - .collect(); - - app.insert_resource(GameStateResource(game)); - press_h(&mut app); app.update(); - // Read out the messages via the standard cursor API. - 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 on a winnable solver verdict" - ); - let event = &collected[0]; assert!( - king_ids.contains(&event.source_card_id), - "solver hint must point at one of the four Kings; got id {}", - event.source_card_id - ); - assert!( - matches!(event.dest_pile, PileType::Foundation(_)), - "solver hint destination must be a foundation slot; got {:?}", - event.dest_pile - ); - } - - /// When the solver returns Inconclusive (e.g. tight budgets force an - /// early bail), the heuristic fallback must still produce a hint - /// event so the H key never feels broken. - /// - /// We force the solver inconclusive by setting both budgets to 0 — - /// the search bails on the very first iteration, returning - /// `SolverResult::Inconclusive`. The heuristic fallback then runs on - /// the fresh deal and finds at least one legal move. - #[test] - fn hint_falls_back_to_heuristic_when_solver_inconclusive() { - use solitaire_core::solver::SolverConfig; - let mut app = hint_test_app(); - // Force solver to bail before exploring anything. - app.insert_resource(HintSolverConfig(SolverConfig { - move_budget: 0, - state_budget: 0, - })); - // A fresh seeded deal — guaranteed to have at least one legal - // move (the standard Klondike opening always has draws available - // even if no immediate tableau move exists). - let game = GameState::new(42, DrawMode::DrawOne); - app.insert_resource(GameStateResource(game)); - press_h(&mut app); - app.update(); - - let world = app.world(); - let visuals = world.resource::>(); - let mut visual_cursor = visuals.get_cursor(); - let collected: Vec = visual_cursor.read(visuals).cloned().collect(); - // Either a card-move hint (most fresh deals) or a draw suggestion. - // A draw suggestion fires no `HintVisualEvent` (only an - // `InfoToastEvent`), so we accept zero-or-one HintVisualEvent so - // long as at least one feedback signal was emitted overall. - let toasts = world.resource::>(); - let mut toast_cursor = toasts.get_cursor(); - let toast_count = toast_cursor.read(toasts).count(); - assert!( - !collected.is_empty() || toast_count > 0, - "heuristic fallback must produce a hint signal (visual or toast)" + app.world() + .resource::() + .is_pending(), + "pressing H must spawn an async hint task", ); } } diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 9184d83..b2145b6 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -22,6 +22,7 @@ pub mod input_plugin; pub mod layout; pub mod onboarding_plugin; pub mod pause_plugin; +pub mod pending_hint; pub mod profile_plugin; pub mod radial_menu; pub mod replay_overlay; diff --git a/solitaire_engine/src/pending_hint.rs b/solitaire_engine/src/pending_hint.rs new file mode 100644 index 0000000..3a95416 --- /dev/null +++ b/solitaire_engine/src/pending_hint.rs @@ -0,0 +1,402 @@ +//! 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::{futures_lite::future, AsyncComputeTaskPool, Task}; +use solitaire_core::game_state::GameState; +use solitaire_core::pile::PileType; +use solitaire_core::solver::{try_solve_from_state, SolverConfig, SolverResult}; + +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: PileType, + to: PileType, + }, + /// 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::card::{Card, Rank, Suit}; + use solitaire_core::game_state::{DrawMode, 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); + for slot in 0..4_u8 { + game.piles + .get_mut(&PileType::Foundation(slot)) + .unwrap() + .cards + .clear(); + } + for i in 0..7_usize { + game.piles + .get_mut(&PileType::Tableau(i)) + .unwrap() + .cards + .clear(); + } + game.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); + game.piles.get_mut(&PileType::Waste).unwrap().cards.clear(); + 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 (slot, suit) in suits.iter().enumerate() { + let pile = game + .piles + .get_mut(&PileType::Foundation(slot as u8)) + .unwrap(); + for (i, rank) in ranks_below_king.iter().enumerate() { + pile.cards.push(Card { + id: (slot as u32) * 13 + i as u32, + suit: *suit, + rank: *rank, + face_up: true, + }); + } + } + for (col, suit) in suits.iter().enumerate() { + game.piles + .get_mut(&PileType::Tableau(col)) + .unwrap() + .cards + .push(Card { + id: 100 + col as u32, + suit: *suit, + rank: Rank::King, + face_up: true, + }); + } + 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, PileType::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", + ); + } +}