feat(engine): H-key hint runs on AsyncComputeTaskPool
Closes the last solver-on-main-thread hot path. The synchronous
v0.17.0 hint flow called solitaire_core::solver::try_solve_from_state
inline on every H press; median latency was ~2 ms but pathological
positions hit the SolverConfig::default() cap at ~120 ms — a visible
input stall on the same frame the player presses H.
Mirrors the d489e7a PendingNewGameSeed pattern. New module
pending_hint.rs holds:
- PendingHintTask resource carrying an Option<HintTask> with
handle: Task<HintTaskOutput> plus move_count_at_spawn for
staleness detection.
- HintTaskOutput enum: SolverMove { from, to } when the verdict
is Winnable + a first_move; NeedsHeuristic when the solver
returns Unwinnable or Inconclusive.
- poll_pending_hint_task system: polls the task each frame and
surfaces the result via the now-public emit_hint_visuals (or
runs find_heuristic_hint on the live state for the
NeedsHeuristic branch). Discards the result when
GameState.move_count has advanced past move_count_at_spawn.
- drop_pending_hint_on_state_change system: any
StateChangedEvent drops the in-flight task. Cooperatively
cancels via Bevy's Task Drop at the next await point.
- PendingHintTask::spawn implements cancel-on-replace — a fresh
H press while a previous task is in flight overwrites the
handle, dropping the prior task.
input_plugin changes:
- handle_keyboard_hint becomes a thin spawn point. Snapshots
the live state, asks the solver via PendingHintTask::spawn,
returns. No card-entity query, no event writers for the
hint visual / toast — the polling system owns those.
- emit_hint_visuals promoted to pub so pending_hint can call it.
- find_heuristic_hint extracted as a pub helper for the
NeedsHeuristic poll path.
- InputPlugin registers PendingHintTask + the two new systems.
drop-on-state-change is chained .before() poll so a move
applied this frame cancels any in-flight task before its
result can be surfaced.
Tests:
- input_plugin: pressing_h_spawns_pending_hint_task (1) — pins
the H-key wiring at one-frame granularity.
- pending_hint: winnable_solver_emits_hint_after_async_completes,
state_change_drops_in_flight_task,
second_spawn_drops_first_in_flight_task (3) — drives the
AsyncComputeTaskPool with a wall-clock-bounded loop mirroring
the winnable_seed_search_* template.
- Removed two now-stale synchronous tests
(hint_uses_solver_when_winnable,
hint_falls_back_to_heuristic_when_solver_inconclusive) — the
behaviours they pinned now live in pending_hint::tests at the
correct layer.
Workspace: 1168 passing tests / 0 failing, was 1166 (net +2:
removed 2 stale, added 4 new). cargo clippy --workspace
--all-targets -- -D warnings clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,7 @@ impl Plugin for InputPlugin {
|
|||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<HintCycleIndex>()
|
app.init_resource::<HintCycleIndex>()
|
||||||
.init_resource::<HintSolverConfig>()
|
.init_resource::<HintSolverConfig>()
|
||||||
|
.init_resource::<crate::pending_hint::PendingHintTask>()
|
||||||
.add_message::<StartZenRequestEvent>()
|
.add_message::<StartZenRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<ForfeitRequestEvent>()
|
.add_message::<ForfeitRequestEvent>()
|
||||||
@@ -109,7 +110,18 @@ impl Plugin for InputPlugin {
|
|||||||
.chain(),
|
.chain(),
|
||||||
)
|
)
|
||||||
.add_systems(Update, handle_fullscreen)
|
.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).
|
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the H key: surface the solver's provably-best first move when
|
/// Handles the H key: spawn an async solver task on
|
||||||
/// the position is winnable; otherwise fall back to cycling through the
|
/// `AsyncComputeTaskPool` whose result `pending_hint::poll_pending_hint_task`
|
||||||
/// heuristic hints.
|
/// turns into hint visuals one frame later.
|
||||||
///
|
///
|
||||||
/// The solver (`solitaire_core::solver::try_solve_from_state`) is run
|
/// Median solve time is ~2 ms but pathological positions can hit the
|
||||||
/// synchronously on each H press — median ~2 ms on real positions, with a
|
/// `SolverConfig::default()` cap at ~120 ms; running synchronously
|
||||||
/// hard cap from `SolverConfig::default()`'s budgets. When the verdict is
|
/// (the v0.17.0 behaviour) blocked the main thread on the same frame
|
||||||
/// `Winnable`, the returned `first_move` is shown as a single, stable hint
|
/// the player pressed H. Cancel-on-replace lives in
|
||||||
/// (no cycling — the optimal move doesn't change between identical
|
/// `PendingHintTask::spawn` — a fresh H press while a previous task
|
||||||
/// presses). When the verdict is `Unwinnable` or `Inconclusive`, the
|
/// is in flight drops the previous task's handle.
|
||||||
/// handler falls back to the legacy heuristic in `all_hints`, which still
|
|
||||||
/// cycles through every legal move.
|
|
||||||
///
|
///
|
||||||
/// When no moves are available a "No hints available" toast is shown
|
/// Special-cases: when the game is already won, surface a "Game won!"
|
||||||
/// instead. The H key always produces a hint when any legal move exists.
|
/// toast instead of asking the solver. The poll system handles the
|
||||||
///
|
/// "no legal moves" toast on the heuristic fallback path so the
|
||||||
/// TODO: if profiling ever shows >100 ms solver calls in practice, move
|
/// handler here only needs to dispatch.
|
||||||
/// the solver call to `AsyncComputeTaskPool` to keep input latency low.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn handle_keyboard_hint(
|
fn handle_keyboard_hint(
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
game: Option<Res<GameStateResource>>,
|
game: Option<Res<GameStateResource>>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
solver_config: Res<HintSolverConfig>,
|
solver_config: Res<HintSolverConfig>,
|
||||||
mut hint_cycle: ResMut<HintCycleIndex>,
|
mut pending_hint: ResMut<crate::pending_hint::PendingHintTask>,
|
||||||
mut commands: Commands,
|
|
||||||
card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
|
||||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
mut hint_visual: MessageWriter<HintVisualEvent>,
|
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
@@ -266,43 +271,37 @@ fn handle_keyboard_hint(
|
|||||||
|
|
||||||
let Some(_layout_res) = layout else { return };
|
let Some(_layout_res) = layout else { return };
|
||||||
|
|
||||||
// First pass: ask the solver for the provably-best move. The
|
pending_hint.spawn(g.0.clone(), solver_config.0);
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: heuristic cycling hint. Used when the solver verdict
|
/// Heuristic hint helper used by `pending_hint::poll_pending_hint_task`
|
||||||
// is `Unwinnable` (no legal winning path — but a legal *move* may
|
/// when the solver returns `Inconclusive` or `Unwinnable`.
|
||||||
// still exist, e.g. drawing from stock) or `Inconclusive` (budget
|
///
|
||||||
// exhausted on a complex mid-game position).
|
/// Picks the hint at `HintCycleIndex % hints.len()` (wrapping) and
|
||||||
let hints = all_hints(&g.0);
|
/// 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() {
|
if hints.is_empty() {
|
||||||
info_toast.write(InfoToastEvent("No hints available".to_string()));
|
return None;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick the hint at the current cycle index (wrapping) and advance.
|
|
||||||
let idx = hint_cycle.0 % hints.len();
|
let idx = hint_cycle.0 % hints.len();
|
||||||
hint_cycle.0 = hint_cycle.0.wrapping_add(1);
|
hint_cycle.0 = hint_cycle.0.wrapping_add(1);
|
||||||
let (from, to, _count) = hints[idx].clone();
|
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.
|
/// Apply the visual + toast effects for a single chosen hint move.
|
||||||
///
|
///
|
||||||
/// Shared between the solver-driven and heuristic-driven hint paths so
|
/// Shared between the solver-driven and heuristic-driven hint paths so
|
||||||
/// both produce identical player-facing feedback.
|
/// both produce identical player-facing feedback. Called from
|
||||||
fn emit_hint_visuals(
|
/// `pending_hint::poll_pending_hint_task` once the async solver task
|
||||||
|
/// resolves.
|
||||||
|
pub fn emit_hint_visuals(
|
||||||
game: &GameState,
|
game: &GameState,
|
||||||
from: &PileType,
|
from: &PileType,
|
||||||
to: &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`.
|
// `handle_keyboard_hint` no longer runs the solver inline; it
|
||||||
// When the solver proves the position winnable, the hint is the
|
// spawns an `AsyncComputeTaskPool` task whose result the polling
|
||||||
// first move on the solver's solution path. When the solver returns
|
// system in `pending_hint` turns into hint visuals one frame
|
||||||
// Inconclusive (budget exhausted) or Unwinnable, the legacy
|
// later. The behaviour contract this section pins is "pressing H
|
||||||
// heuristic in `all_hints` supplies the hint instead so the H key
|
// populates `PendingHintTask`" — the spawn-to-emit pipeline is
|
||||||
// always produces feedback while any legal move exists.
|
// covered end-to-end in `pending_hint::tests`.
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/// Build a minimal Bevy app that registers only the resources and
|
/// Pressing H on a non-paused, non-won game with a live
|
||||||
/// messages needed to drive `handle_keyboard_hint` end-to-end.
|
/// `GameStateResource` + `LayoutResource` must populate
|
||||||
/// Skips every other input system — the test only exercises the hint
|
/// `PendingHintTask`. The polling system, exercised in
|
||||||
/// path and we want the assertions to be unaffected by other handlers.
|
/// `pending_hint::tests`, drives the result to a visual event.
|
||||||
fn hint_test_app() -> App {
|
#[test]
|
||||||
|
fn pressing_h_spawns_pending_hint_task() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins);
|
app.add_plugins(MinimalPlugins);
|
||||||
app.add_message::<InfoToastEvent>();
|
app.add_message::<InfoToastEvent>();
|
||||||
app.add_message::<HintVisualEvent>();
|
app.add_message::<HintVisualEvent>();
|
||||||
app.init_resource::<HintCycleIndex>();
|
app.init_resource::<HintCycleIndex>();
|
||||||
app.init_resource::<HintSolverConfig>();
|
app.init_resource::<HintSolverConfig>();
|
||||||
|
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
// Layout: a fixed 1280x800 layout — `handle_keyboard_hint` only
|
|
||||||
// checks the resource is present, never reads coordinates.
|
|
||||||
app.insert_resource(crate::layout::LayoutResource(
|
app.insert_resource(crate::layout::LayoutResource(
|
||||||
crate::layout::compute_layout(Vec2::new(1280.0, 800.0)),
|
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.add_systems(Update, handle_keyboard_hint);
|
||||||
app
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper: simulate "the player just pressed H this frame".
|
// Simulate the H key being pressed this frame.
|
||||||
fn press_h(app: &mut App) {
|
{
|
||||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
input.release(KeyCode::KeyH);
|
input.release(KeyCode::KeyH);
|
||||||
input.clear();
|
input.clear();
|
||||||
input.press(KeyCode::KeyH);
|
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();
|
|
||||||
}
|
}
|
||||||
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<u32> = (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();
|
app.update();
|
||||||
|
|
||||||
// Read out the messages via the standard cursor API.
|
|
||||||
let messages = app.world().resource::<Messages<HintVisualEvent>>();
|
|
||||||
let mut cursor = messages.get_cursor();
|
|
||||||
let collected: Vec<HintVisualEvent> = 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!(
|
assert!(
|
||||||
king_ids.contains(&event.source_card_id),
|
app.world()
|
||||||
"solver hint must point at one of the four Kings; got id {}",
|
.resource::<crate::pending_hint::PendingHintTask>()
|
||||||
event.source_card_id
|
.is_pending(),
|
||||||
);
|
"pressing H must spawn an async hint task",
|
||||||
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::<Messages<HintVisualEvent>>();
|
|
||||||
let mut visual_cursor = visuals.get_cursor();
|
|
||||||
let collected: Vec<HintVisualEvent> = 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::<Messages<InfoToastEvent>>();
|
|
||||||
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)"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub mod input_plugin;
|
|||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod onboarding_plugin;
|
pub mod onboarding_plugin;
|
||||||
pub mod pause_plugin;
|
pub mod pause_plugin;
|
||||||
|
pub mod pending_hint;
|
||||||
pub mod profile_plugin;
|
pub mod profile_plugin;
|
||||||
pub mod radial_menu;
|
pub mod radial_menu;
|
||||||
pub mod replay_overlay;
|
pub mod replay_overlay;
|
||||||
|
|||||||
@@ -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<HintTask>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HintTaskOutput>,
|
||||||
|
/// `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<StateChangedEvent>,
|
||||||
|
mut pending: ResMut<PendingHintTask>,
|
||||||
|
) {
|
||||||
|
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<PendingHintTask>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut hint_cycle: ResMut<HintCycleIndex>,
|
||||||
|
mut commands: Commands,
|
||||||
|
card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||||
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
|
mut hint_visual: MessageWriter<HintVisualEvent>,
|
||||||
|
) {
|
||||||
|
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::<InfoToastEvent>();
|
||||||
|
app.add_message::<HintVisualEvent>();
|
||||||
|
app.add_message::<StateChangedEvent>();
|
||||||
|
app.init_resource::<HintCycleIndex>();
|
||||||
|
app.init_resource::<HintSolverConfig>();
|
||||||
|
app.init_resource::<PendingHintTask>();
|
||||||
|
// 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::<HintSolverConfig>().0;
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<PendingHintTask>()
|
||||||
|
.spawn(near_finished_state(), cfg);
|
||||||
|
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||||
|
while app.world().resource::<PendingHintTask>().is_pending() {
|
||||||
|
app.update();
|
||||||
|
std::thread::yield_now();
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!app.world().resource::<PendingHintTask>().is_pending(),
|
||||||
|
"hint task should have completed within 15 s wall-clock",
|
||||||
|
);
|
||||||
|
let messages = app.world().resource::<Messages<HintVisualEvent>>();
|
||||||
|
let mut cursor = messages.get_cursor();
|
||||||
|
let collected: Vec<HintVisualEvent> = 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::<HintSolverConfig>().0;
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<PendingHintTask>()
|
||||||
|
.spawn(near_finished_state(), cfg);
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<PendingHintTask>().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::<PendingHintTask>().is_pending(),
|
||||||
|
"StateChangedEvent must drop the in-flight hint task",
|
||||||
|
);
|
||||||
|
// No HintVisualEvent should ever have fired.
|
||||||
|
let messages = app.world().resource::<Messages<HintVisualEvent>>();
|
||||||
|
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::<HintSolverConfig>().0;
|
||||||
|
|
||||||
|
// First spawn.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<PendingHintTask>()
|
||||||
|
.spawn(near_finished_state(), cfg);
|
||||||
|
let first_handle_present = app.world().resource::<PendingHintTask>().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::<PendingHintTask>()
|
||||||
|
.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::<PendingHintTask>().is_pending());
|
||||||
|
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||||
|
while app.world().resource::<PendingHintTask>().is_pending() {
|
||||||
|
app.update();
|
||||||
|
std::thread::yield_now();
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!app.world().resource::<PendingHintTask>().is_pending(),
|
||||||
|
"second hint task should have completed within 15 s wall-clock",
|
||||||
|
);
|
||||||
|
let messages = app.world().resource::<Messages<HintVisualEvent>>();
|
||||||
|
let mut cursor = messages.get_cursor();
|
||||||
|
let collected: Vec<HintVisualEvent> = cursor.read(messages).cloned().collect();
|
||||||
|
assert_eq!(
|
||||||
|
collected.len(), 1,
|
||||||
|
"cancel-on-replace: only the surviving task's result emits a visual",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user