Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea1014285d | |||
| 299f6bfea7 | |||
| 6f27e775f2 | |||
| 358bbc7eb5 | |||
| 444f8d7e33 | |||
| a80547c514 | |||
| a4ad848c93 | |||
| ff8c00d2f4 | |||
| 38b81a4004 | |||
| ae7af9adf4 |
@@ -34,6 +34,11 @@ spec:
|
||||
key: jwt-secret
|
||||
- name: SERVER_PORT
|
||||
value: "8080"
|
||||
# Theme-store catalog directory on the persistent volume.
|
||||
# Scanned once at startup — after dropping new theme zips
|
||||
# into /data/theme_store, restart the deployment.
|
||||
- name: THEME_STORE_DIR
|
||||
value: /data/theme_store
|
||||
volumeMounts:
|
||||
- name: db-data
|
||||
mountPath: /data
|
||||
|
||||
@@ -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<Option<Vec<KlondikeInstruction>>, 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<KlondikeInstruction> = solution
|
||||
.clean_solution()
|
||||
.iter()
|
||||
.map(|snapshot| *snapshot.instruction())
|
||||
.collect();
|
||||
let filtered: Vec<KlondikeInstruction> = 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 = <Klondike as card_game::Game>::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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::<PauseRequestEvent>()
|
||||
.add_message::<ForfeitRequestEvent>()
|
||||
.add_message::<ForfeitEvent>()
|
||||
.add_message::<crate::events::ShowSolutionRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.init_resource::<PausedResource>()
|
||||
.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<Interaction>, With<PauseSolutionButton>)>,
|
||||
mut pause: MessageWriter<PauseRequestEvent>,
|
||||
mut solution: MessageWriter<crate::events::ShowSolutionRequestEvent>,
|
||||
) {
|
||||
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,
|
||||
|
||||
@@ -84,7 +84,8 @@ impl Plugin for SafeAreaInsetsPlugin {
|
||||
#[cfg(target_os = "android")]
|
||||
app.init_resource::<android::SafeAreaPollTries>()
|
||||
.add_systems(Update, android::refresh_insets)
|
||||
.add_systems(Update, android::rearm_on_resumed);
|
||||
.add_systems(Update, android::rearm_on_resumed)
|
||||
.add_systems(Update, android::refresh_surface_size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +226,7 @@ fn on_app_resumed(
|
||||
mod android {
|
||||
use super::{AppLifecycle, SafeAreaInsets};
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::WindowResized;
|
||||
|
||||
/// Tracks how many frames `refresh_insets` has polled. Stored as a
|
||||
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
|
||||
@@ -299,11 +301,108 @@ mod android {
|
||||
) {
|
||||
for event in lifecycle.read() {
|
||||
if matches!(event, AppLifecycle::WillResume) {
|
||||
// Evidence line for #130: winit's Android backend has open
|
||||
// TODOs around forwarding resume notifications, so whether
|
||||
// this ever fires on a given device is an open question.
|
||||
info!("safe_area: AppLifecycle::WillResume received; re-arming inset poll");
|
||||
poll.0 = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the decor view's size via JNI and forces a relayout when it
|
||||
/// disagrees with Bevy's cached `Window` resolution (#130).
|
||||
///
|
||||
/// winit's Android backend does not forward content-rect changes that
|
||||
/// happen while the app is backgrounded (fold/unfold on foldables), so
|
||||
/// after a fold cycle Bevy can keep rendering and laying out for the
|
||||
/// previous screen's dimensions. Unlike `refresh_insets` this poller
|
||||
/// never settles: it cannot rely on `AppLifecycle::WillResume` to re-arm
|
||||
/// it, because that event is itself delivered through the same unreliable
|
||||
/// lifecycle plumbing. A JNI round-trip every `POLL_INTERVAL_FRAMES`
|
||||
/// frames is cheap.
|
||||
///
|
||||
/// On a mismatch it:
|
||||
/// 1. writes the real size into `window.resolution` so the renderer
|
||||
/// reconfigures the surface and systems reading `window.width()` see
|
||||
/// the truth,
|
||||
/// 2. emits a synthetic `WindowResized` (logical pixels) so
|
||||
/// `on_window_resized` in `table_plugin` recomputes the board layout,
|
||||
/// 3. re-arms the inset poller, because a screen change almost always
|
||||
/// moves the system bars too — covering the "re-poll never fires"
|
||||
/// hole left open in #116.
|
||||
pub(super) fn refresh_surface_size(
|
||||
mut frame: Local<u32>,
|
||||
mut windows: Query<(Entity, &mut Window)>,
|
||||
mut resize_events: MessageWriter<WindowResized>,
|
||||
mut poll: ResMut<SafeAreaPollTries>,
|
||||
) {
|
||||
const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps
|
||||
|
||||
*frame += 1;
|
||||
if !frame.is_multiple_of(POLL_INTERVAL_FRAMES) {
|
||||
return;
|
||||
}
|
||||
let Some((entity, mut window)) = windows.iter_mut().next() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (decor_w, decor_h) = match query_decor_size() {
|
||||
Ok(size) => size,
|
||||
Err(e) => {
|
||||
// One-time note; the bridge simply isn't up yet during the
|
||||
// first frames of a launch.
|
||||
if *frame == POLL_INTERVAL_FRAMES {
|
||||
warn!("safe_area: decor size query failed (will retry): {e}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
if decor_w == 0 || decor_h == 0 {
|
||||
return; // decor view not laid out yet
|
||||
}
|
||||
|
||||
// Reads go through `Deref` and do not trip change detection; only
|
||||
// mutate `window` once a mismatch is confirmed.
|
||||
let cached_w = window.resolution.physical_width();
|
||||
let cached_h = window.resolution.physical_height();
|
||||
if decor_w == cached_w && decor_h == cached_h {
|
||||
return;
|
||||
}
|
||||
|
||||
info!(
|
||||
"safe_area: decor view is {decor_w}x{decor_h} but cached resolution is \
|
||||
{cached_w}x{cached_h}; forcing relayout (fold/unfold missed by winit?)"
|
||||
);
|
||||
window.resolution.set_physical_resolution(decor_w, decor_h);
|
||||
let scale = window.scale_factor();
|
||||
resize_events.write(WindowResized {
|
||||
window: entity,
|
||||
width: decor_w as f32 / scale,
|
||||
height: decor_h as f32 / scale,
|
||||
});
|
||||
poll.0 = 0;
|
||||
}
|
||||
|
||||
/// Physical pixel size of the activity's decor view — the ground truth
|
||||
/// for the surface we are actually being displayed on, independent of
|
||||
/// whatever winit last told Bevy.
|
||||
fn query_decor_size() -> Result<(u32, u32), String> {
|
||||
use solitaire_data::android_jni;
|
||||
|
||||
android_jni::with_activity_env(|env, activity| {
|
||||
let window = env
|
||||
.call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
|
||||
.l()?;
|
||||
let decor = env
|
||||
.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
|
||||
.l()?;
|
||||
let w = env.call_method(&decor, "getWidth", "()I", &[])?.i()?;
|
||||
let h = env.call_method(&decor, "getHeight", "()I", &[])?.i()?;
|
||||
Ok((w.max(0) as u32, h.max(0) as u32))
|
||||
})
|
||||
}
|
||||
|
||||
fn query_insets() -> Result<SafeAreaInsets, String> {
|
||||
use solitaire_data::android_jni;
|
||||
|
||||
|
||||
@@ -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<SolveTaskOutput>)>,
|
||||
}
|
||||
|
||||
/// What the solver task carries back to the main thread.
|
||||
enum SolveTaskOutput {
|
||||
Line(Vec<KlondikeInstruction>),
|
||||
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<KlondikeInstruction>,
|
||||
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::<SolutionSolveTask>()
|
||||
.init_resource::<SolutionPlayback>()
|
||||
.add_message::<ShowSolutionRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<MoveRequestEvent>()
|
||||
.add_message::<DrawRequestEvent>()
|
||||
.add_message::<MoveRejectedEvent>()
|
||||
.add_message::<UndoRequestEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.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<ShowSolutionRequestEvent>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut task: ResMut<SolutionSolveTask>,
|
||||
playback: Res<SolutionPlayback>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
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<SolutionSolveTask>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut playback: ResMut<SolutionPlayback>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
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<SolutionPlayback>,
|
||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut rejected: MessageReader<MoveRejectedEvent>,
|
||||
mut undos: MessageReader<UndoRequestEvent>,
|
||||
mut new_games: MessageReader<NewGameRequestEvent>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
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<SolutionPlayback>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
time: Res<Time>,
|
||||
mut moves: MessageWriter<MoveRequestEvent>,
|
||||
mut draws: MessageWriter<DrawRequestEvent>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
if !playback.is_active() {
|
||||
return;
|
||||
}
|
||||
let Some(game) = game else {
|
||||
playback.stop();
|
||||
return;
|
||||
};
|
||||
if game.0.is_won() {
|
||||
playback.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
playback.cooldown -= time.delta_secs();
|
||||
if playback.cooldown > 0.0 {
|
||||
return;
|
||||
}
|
||||
playback.cooldown = STEP_INTERVAL_SECS;
|
||||
|
||||
let Some(instruction) = playback.queue.pop_front() else {
|
||||
return;
|
||||
};
|
||||
|
||||
match instruction {
|
||||
KlondikeInstruction::RotateStock => {
|
||||
draws.write(DrawRequestEvent);
|
||||
}
|
||||
other => {
|
||||
// Decode against the LIVE state — tableau run lengths depend on
|
||||
// the current face-up counts, so this must happen at step time,
|
||||
// not at solve time.
|
||||
let Some((from, to, count)) = game.0.instruction_to_piles(other) else {
|
||||
playback.stop();
|
||||
toast.write(InfoToastEvent("Solution playback stopped.".to_string()));
|
||||
return;
|
||||
};
|
||||
moves.write(MoveRequestEvent { from, to, count });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use bevy::ecs::message::Messages;
|
||||
use solitaire_core::DrawStockConfig;
|
||||
|
||||
/// Seed proven Winnable at 5k budgets by the core
|
||||
/// `budget_is_passed_through_not_clamped` test. Solved here under
|
||||
/// standard rules (no take-from-foundation) to match that baseline.
|
||||
const WINNABLE_SEED: u64 = 0xD1FF_0000_0000_0012;
|
||||
|
||||
fn winnable_state() -> GameState {
|
||||
let mut game = GameState::new(WINNABLE_SEED, DrawStockConfig::DrawOne);
|
||||
game.take_from_foundation = false;
|
||||
game
|
||||
}
|
||||
|
||||
/// Full pipeline: GamePlugin consumes the Move/Draw requests the
|
||||
/// playback driver emits, exactly as in production.
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(SolutionPlaybackPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn request_solution(app: &mut App) {
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ShowSolutionRequestEvent>>()
|
||||
.write(ShowSolutionRequestEvent);
|
||||
}
|
||||
|
||||
/// Pump updates until the solver task resolves (wall-clock bounded,
|
||||
/// mirroring `winnable_solver_emits_hint_after_async_completes`).
|
||||
fn pump_until_solved(app: &mut App) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while app.world().resource::<SolutionSolveTask>().inner.is_some() {
|
||||
app.update();
|
||||
std::thread::yield_now();
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
app.world().resource::<SolutionSolveTask>().inner.is_none(),
|
||||
"solver task should have completed within 30 s wall-clock",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_solves_and_arms_playback() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(GameStateResource(winnable_state()));
|
||||
|
||||
request_solution(&mut app);
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<SolutionSolveTask>().inner.is_some(),
|
||||
"request must spawn a solver task",
|
||||
);
|
||||
pump_until_solved(&mut app);
|
||||
assert!(
|
||||
app.world().resource::<SolutionPlayback>().is_active(),
|
||||
"a winnable position must arm playback",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_reaches_win_through_normal_pipeline() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(GameStateResource(winnable_state()));
|
||||
request_solution(&mut app);
|
||||
app.update();
|
||||
pump_until_solved(&mut app);
|
||||
assert!(app.world().resource::<SolutionPlayback>().is_active());
|
||||
|
||||
// Force each step instead of waiting out the real cadence; a line
|
||||
// is at most a few hundred instructions.
|
||||
for _ in 0..600 {
|
||||
app.world_mut().resource_mut::<SolutionPlayback>().cooldown = 0.0;
|
||||
app.update();
|
||||
if app.world().resource::<GameStateResource>().0.is_won() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
app.world().resource::<GameStateResource>().0.is_won(),
|
||||
"auto-played line must drive the real game to a win",
|
||||
);
|
||||
assert!(
|
||||
!app.world().resource::<SolutionPlayback>().is_active(),
|
||||
"playback must deactivate once the game is won",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_playback() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(GameStateResource(winnable_state()));
|
||||
app.world_mut()
|
||||
.resource_mut::<SolutionPlayback>()
|
||||
.queue
|
||||
.push_back(KlondikeInstruction::RotateStock);
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::Escape);
|
||||
app.update();
|
||||
assert!(
|
||||
!app.world().resource::<SolutionPlayback>().is_active(),
|
||||
"Esc must stop playback",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_request_is_ignored_while_active() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(GameStateResource(winnable_state()));
|
||||
app.world_mut()
|
||||
.resource_mut::<SolutionPlayback>()
|
||||
.queue
|
||||
.push_back(KlondikeInstruction::RotateStock);
|
||||
|
||||
request_solution(&mut app);
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<SolutionSolveTask>().inner.is_none(),
|
||||
"a request during active playback must not spawn a solver task",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1649,62 +1649,62 @@ function __wbg_get_imports() {
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 61868, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 61918, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7314, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7364, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7312, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7362, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7313, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7363, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c);
|
||||
return ret;
|
||||
},
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user