Compare commits

..

10 Commits

Author SHA1 Message Date
funman300 ea1014285d feat(engine): Show solution — auto-play the winning line from the pause menu
Test / test (pull_request) Successful in 15m21s
New SolutionPlaybackPlugin: the pause modal's 'Show solution' button
resumes the game and requests a solve of the live position via
GameState::winning_line on AsyncComputeTaskPool (never blocks the main
thread; stale results discarded via a move_count snapshot). The line
then plays back one instruction per 0.45 s through the normal
MoveRequestEvent / DrawRequestEvent pipeline — animations, scoring,
undo history and win detection behave as if the player made the moves.
Tableau run counts are decoded against the live state at step time.

Playback cancels on Esc, pause, undo / new-game requests, and any
rejected move (the signature of the player diverging the board).
Toasts cover search start, line found, unwinnable, and budget-
exhausted outcomes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:33:49 -07:00
funman300 299f6bfea7 feat(core): winning_line — full solver line via Solution::clean_solution
New GameState::winning_line(moves_budget, states_budget) returns the
complete instruction sequence to a win (Ok(None) when unwinnable or
already won, Err on budget exhaustion), compacting the raw DFS trace
with the previously unused card_game Solution::clean_solution and
stripping foundation→foundation no-ops when the stripped line still
replays to a win — an internal replay check guarantees the returned
sequence always applies cleanly via apply_instruction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:19:12 -07:00
funman300 6f27e775f2 chore(deploy): mount theme store at /data/theme_store
Points THEME_STORE_DIR at the existing solitaire-db PVC so catalog
content survives pod restarts. Scan is startup-only; restart the
deployment after adding zips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:47:28 -07:00
Gitea CI 358bbc7eb5 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m23s
Web E2E / web-e2e (push) Successful in 3m57s
2026-07-07 21:17:01 +00:00
funman300 444f8d7e33 Merge pull request 'feat: in-game theme store — server catalog + verified downloads + install UI' (#154) from feat/theme-store into master
Build and Deploy / build-and-push (push) Successful in 6m2s
Test / test (push) Failing after 8m38s
Web E2E / web-e2e (push) Successful in 6m55s
Web WASM Rebuild / rebuild (push) Successful in 9m25s
2026-07-07 20:23:52 +00:00
funman300 a80547c514 Merge pull request 'fix: July 7 code-review remediation (M1–M3, L1–L3)' (#153) from fix/review-2026-07-07 into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 5m56s
Test / test (push) Successful in 13m51s
Web E2E / web-e2e (push) Successful in 4m18s
2026-07-07 20:16:19 +00:00
Gitea CI a4ad848c93 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 5m28s
Web E2E / web-e2e (push) Successful in 4m12s
2026-07-07 18:54:30 +00:00
funman300 ff8c00d2f4 Merge pull request 'fix(engine): poll decor-view size to catch fold resizes winit misses (#130)' (#152) from fix/130-fold-stale-relayout into master
Build and Deploy / build-and-push (push) Successful in 1m56s
Test / test (push) Failing after 16m17s
Web WASM Rebuild / rebuild (push) Successful in 8m23s
Android Release / build-apk (push) Successful in 4m19s
2026-07-07 18:12:57 +00:00
funman300 38b81a4004 fix(engine): poll decor-view size to catch fold resizes winit misses
Test / test (pull_request) Successful in 23m3s
winit's Android backend does not forward content-rect changes that happen
while backgrounded (open TODOs in winit 0.30 logged on every resume), so a
fold/unfold cycle can leave Bevy rendering and laying out for the previous
screen: tableau clipped off the left edge, bottom third empty (#130).

Add a continuous decor-view size poller (JNI, every 30 frames) that, on
mismatch with the cached Window resolution: writes the real physical size
into window.resolution, emits a synthetic WindowResized in logical pixels,
and re-arms the safe-area inset poller — covering both the fold-size and
inset-staleness cases in one mechanism, without relying on
AppLifecycle::WillResume being delivered (it may never be; a new evidence
log in rearm_on_resumed settles that question on-device).

Closes #130

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:49:17 -07:00
Gitea CI ae7af9adf4 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 7m12s
Web E2E / web-e2e (push) Successful in 5m16s
2026-07-07 09:12:14 +00:00
11 changed files with 718 additions and 16 deletions
+5
View File
@@ -34,6 +34,11 @@ spec:
key: jwt-secret key: jwt-secret
- name: SERVER_PORT - name: SERVER_PORT
value: "8080" 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: volumeMounts:
- name: db-data - name: db-data
mountPath: /data mountPath: /data
+142
View File
@@ -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`. /// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`.
/// ///
/// Fresh-deal solving models standard Klondike rules, so the non-standard /// 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1351,6 +1430,69 @@ mod tests {
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded))); 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] #[test]
fn budget_is_passed_through_not_clamped() { fn budget_is_passed_through_not_clamped() {
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the // This seed is Inconclusive at 1k states but Winnable at 5k — proving the
+5 -3
View File
@@ -18,9 +18,10 @@ use crate::{
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider, SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin,
TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
WinSummaryPlugin,
}; };
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use crate::{ use crate::{
@@ -93,6 +94,7 @@ impl Plugin for CoreGamePlugin {
.add_plugins(FeedbackAnimPlugin) .add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin) .add_plugins(CardAnimationPlugin)
.add_plugins(AutoCompletePlugin) .add_plugins(AutoCompletePlugin)
.add_plugins(SolutionPlaybackPlugin)
.add_plugins(ReplayPlaybackPlugin) .add_plugins(ReplayPlaybackPlugin)
.add_plugins(ReplayOverlayPlugin) .add_plugins(ReplayOverlayPlugin)
.add_plugins(StatsPlugin::default()) .add_plugins(StatsPlugin::default())
+6
View File
@@ -159,6 +159,12 @@ pub struct DeleteAccountRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)] #[derive(Message, Debug, Clone, Copy, Default)]
pub struct PauseRequestEvent; 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" /// Request to toggle the help / controls overlay. Fired by the HUD "Help"
/// button alongside the existing `F1` accelerator so the overlay is /// button alongside the existing `F1` accelerator so the overlay is
/// reachable without a keyboard. Consumed by `help_plugin::toggle_help_screen`. /// reachable without a keyboard. Consumed by `help_plugin::toggle_help_screen`.
+2
View File
@@ -46,6 +46,7 @@ pub mod safe_area;
mod schedule_checks; mod schedule_checks;
pub mod selection_plugin; pub mod selection_plugin;
pub mod settings_plugin; pub mod settings_plugin;
pub mod solution_playback_plugin;
pub mod splash_plugin; pub mod splash_plugin;
pub mod stats_plugin; pub mod stats_plugin;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@@ -161,6 +162,7 @@ pub use settings_plugin::{
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS, SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
}; };
pub use solitaire_data::SyncProvider; pub use solitaire_data::SyncProvider;
pub use solution_playback_plugin::{SolutionPlayback, SolutionPlaybackPlugin, SolutionSolveTask};
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot}; pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
pub use stats_plugin::{ pub use stats_plugin::{
LatestReplayPath, ReplayHistoryResource, ReplayNextButton, ReplayPrevButton, LatestReplayPath, ReplayHistoryResource, ReplayNextButton, ReplayPrevButton,
+32
View File
@@ -71,6 +71,12 @@ struct PauseResumeButton;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct PauseForfeitButton; 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. /// Marker on the forfeit-confirm modal scrim.
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ForfeitConfirmScreen; pub struct ForfeitConfirmScreen;
@@ -107,6 +113,7 @@ impl Plugin for PausePlugin {
.add_message::<PauseRequestEvent>() .add_message::<PauseRequestEvent>()
.add_message::<ForfeitRequestEvent>() .add_message::<ForfeitRequestEvent>()
.add_message::<ForfeitEvent>() .add_message::<ForfeitEvent>()
.add_message::<crate::events::ShowSolutionRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.init_resource::<PausedResource>() .init_resource::<PausedResource>()
.add_systems( .add_systems(
@@ -125,6 +132,7 @@ impl Plugin for PausePlugin {
handle_pause_draw_buttons, handle_pause_draw_buttons,
handle_pause_resume_button, handle_pause_resume_button,
handle_pause_forfeit_button, handle_pause_forfeit_button,
handle_pause_solution_button,
handle_forfeit_request, handle_forfeit_request,
handle_forfeit_confirm_buttons, handle_forfeit_confirm_buttons,
handle_forfeit_keyboard, 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 /// Translates a click on the pause modal's Forfeit button into a
/// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the /// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the
/// confirm modal — same code path as the `G` accelerator. /// confirm modal — same code path as the `G` accelerator.
@@ -498,6 +522,14 @@ fn spawn_pause_screen(
ButtonVariant::Tertiary, ButtonVariant::Tertiary,
font_res, font_res,
); );
spawn_modal_button(
actions,
PauseSolutionButton,
"Show solution",
None,
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button( spawn_modal_button(
actions, actions,
PauseResumeButton, PauseResumeButton,
+100 -1
View File
@@ -84,7 +84,8 @@ impl Plugin for SafeAreaInsetsPlugin {
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
app.init_resource::<android::SafeAreaPollTries>() app.init_resource::<android::SafeAreaPollTries>()
.add_systems(Update, android::refresh_insets) .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 { mod android {
use super::{AppLifecycle, SafeAreaInsets}; use super::{AppLifecycle, SafeAreaInsets};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::WindowResized;
/// Tracks how many frames `refresh_insets` has polled. Stored as a /// Tracks how many frames `refresh_insets` has polled. Stored as a
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0 /// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
@@ -299,11 +301,108 @@ mod android {
) { ) {
for event in lifecycle.read() { for event in lifecycle.read() {
if matches!(event, AppLifecycle::WillResume) { 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; 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> { fn query_insets() -> Result<SafeAreaInsets, String> {
use solitaire_data::android_jni; 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",
);
}
}
+12 -12
View File
@@ -1649,62 +1649,62 @@ function __wbg_get_imports() {
return ret; return ret;
}, },
__wbindgen_cast_0000000000000001: function(arg0, arg1) { __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); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000002: function(arg0, arg1) { __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); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000003: function(arg0, arg1) { __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); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000004: function(arg0, arg1) { __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); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000005: function(arg0, arg1) { __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); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000006: function(arg0, arg1) { __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); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000007: function(arg0, arg1) { __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); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000008: function(arg0, arg1) { __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); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000009: function(arg0, arg1) { __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); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8);
return ret; return ret;
}, },
__wbindgen_cast_000000000000000a: function(arg0, arg1) { __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); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9);
return ret; return ret;
}, },
__wbindgen_cast_000000000000000b: function(arg0, arg1) { __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); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1);
return ret; return ret;
}, },
__wbindgen_cast_000000000000000c: function(arg0, arg1) { __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); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c);
return ret; return ret;
}, },
Binary file not shown.
Binary file not shown.