Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db1cc58f3a | |||
| 255b781420 | |||
| 5b5d587818 | |||
| 2b2e7a7f2c | |||
| 5c4d440b31 | |||
| 374858ab6d | |||
| 38b82d4858 | |||
| e1d91bee73 | |||
| 9dcd25b3e7 | |||
| 2ef0bce1ea | |||
| 326ef6894a | |||
| caaafe34e0 | |||
| 2254693a7a | |||
| 4ba646738c | |||
| 251d35bc28 | |||
| 1d2b6dc5de | |||
| fe3c3aed31 | |||
| d0e4ce796b | |||
| 9e4d4a6716 | |||
| ea1014285d | |||
| 4f849a23b8 | |||
| 2c2f0b592a | |||
| 299f6bfea7 |
@@ -29,14 +29,21 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: rust-host
|
||||||
|
|
||||||
# Full debuginfo made the solitaire_engine test-binary link peak past the
|
# Full debuginfo made the solitaire_engine test-binary link peak past the
|
||||||
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
|
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
|
||||||
# line-tables-only keeps file:line in panic backtraces while cutting the
|
# line-tables-only keeps file:line in panic backtraces while cutting the
|
||||||
# link's memory footprint enough to fit the runner.
|
# link's memory footprint enough to fit the runner.
|
||||||
|
#
|
||||||
|
# CARGO_BUILD_JOBS=2: with one job per core, cargo links several large
|
||||||
|
# test binaries concurrently; as the workspace grew (runs 514/516/519)
|
||||||
|
# two+ simultaneous ld processes OOM-killed the runner again even at
|
||||||
|
# line-tables-only. Two jobs keeps at most two links in flight — the
|
||||||
|
# compile-throughput cost is small next to the cache-warm build.
|
||||||
env:
|
env:
|
||||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||||
|
CARGO_BUILD_JOBS: '2'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
Generated
+1
@@ -7331,6 +7331,7 @@ dependencies = [
|
|||||||
"card_game",
|
"card_game",
|
||||||
"klondike",
|
"klondike",
|
||||||
"proptest",
|
"proptest",
|
||||||
|
"rand 0.10.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@ wasm-bindgen \
|
|||||||
--out-name canvas \
|
--out-name canvas \
|
||||||
--target web \
|
--target web \
|
||||||
--no-typescript \
|
--no-typescript \
|
||||||
"$REPO_ROOT/target/wasm32-unknown-unknown/wasm-release/solitaire_web.wasm"
|
"${CARGO_TARGET_DIR:-$REPO_ROOT/target}/wasm32-unknown-unknown/wasm-release/solitaire_web.wasm"
|
||||||
|
|
||||||
# Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed).
|
# Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed).
|
||||||
# wasm-opt passes are skipped silently when the tool is not installed.
|
# wasm-opt passes are skipped silently when the tool is not installed.
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ serde = { workspace = true }
|
|||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
klondike = { workspace = true }
|
klondike = { workspace = true }
|
||||||
card_game = { workspace = true }
|
card_game = { workspace = true }
|
||||||
|
# Deliberately NOT the workspace rand (0.9): this pins the exact dep the
|
||||||
|
# upstream `klondike` crate uses, so `SeedableRng`/`SliceRandom` resolve
|
||||||
|
# against the same crate version as `klondike::Rng` and Spider deals go
|
||||||
|
# through the identical shuffle stack as Klondike deals.
|
||||||
|
rand = { version = "0.10.1", default-features = false, features = ["std_rng"] }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod error;
|
|||||||
pub mod game_state;
|
pub mod game_state;
|
||||||
pub mod klondike_adapter;
|
pub mod klondike_adapter;
|
||||||
pub mod scoring;
|
pub mod scoring;
|
||||||
|
pub mod spider;
|
||||||
|
|
||||||
// Re-export the upstream types that cross the solitaire_core API boundary so
|
// Re-export the upstream types that cross the solitaire_core API boundary so
|
||||||
// downstream crates (engine, wasm) can import from one place without a direct
|
// downstream crates (engine, wasm) can import from one place without a direct
|
||||||
@@ -21,6 +22,13 @@ pub use klondike::{
|
|||||||
// former `solitaire_data::solver` wrapper module.
|
// former `solitaire_data::solver` wrapper module.
|
||||||
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
||||||
|
|
||||||
|
// Spider rules (second `card_game::Game` implementation; engine UI is a
|
||||||
|
// later phase — nothing outside solitaire_core consumes these yet).
|
||||||
|
pub use spider::{
|
||||||
|
RunLength, Spider, SpiderConfig, SpiderGameState, SpiderInstruction, SpiderIter, SpiderMove,
|
||||||
|
SpiderScoring, SpiderStats, SpiderSuits, SpiderTableau,
|
||||||
|
};
|
||||||
|
|
||||||
/// All four foundation slots, in slot order.
|
/// All four foundation slots, in slot order.
|
||||||
///
|
///
|
||||||
/// Canonical iteration source for `Foundation` — upstream `klondike` has no
|
/// Canonical iteration source for `Foundation` — upstream `klondike` has no
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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"))]
|
||||||
@@ -162,6 +163,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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,13 @@
|
|||||||
//! started), a full-screen modal is spawned showing score, time, XP, and a
|
//! started), a full-screen modal is spawned showing score, time, XP, and a
|
||||||
//! "Play Again" button that fires `NewGameRequestEvent` and closes the modal.
|
//! "Play Again" button that fires `NewGameRequestEvent` and closes the modal.
|
||||||
//!
|
//!
|
||||||
|
//! # Phase G (docs/ui-redesign-2026-07.md) — action hierarchy
|
||||||
|
//! The modal leads with actions: **Play Again** (primary, Enter
|
||||||
|
//! accelerator), then **Watch Replay** and **Share Replay** (shared
|
||||||
|
//! `stats_plugin` markers, so the global handlers there act on the
|
||||||
|
//! just-won replay), with the score/time/XP recap reading quietly
|
||||||
|
//! below.
|
||||||
|
//!
|
||||||
//! # Task #47 — Win fanfare screen-shake
|
//! # Task #47 — Win fanfare screen-shake
|
||||||
//! When `GameWonEvent` fires, `ScreenShakeResource` is set. A system offsets
|
//! When `GameWonEvent` fires, `ScreenShakeResource` is set. A system offsets
|
||||||
//! the `Camera2d` `Transform` each frame with a decaying oscillation until the
|
//! the `Camera2d` `Transform` each frame with a decaying oscillation until the
|
||||||
@@ -23,7 +30,7 @@ use crate::game_plugin::GameMutation;
|
|||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
use crate::stats_plugin::{CopyShareLinkButton, StatsResource, StatsUpdate, WatchReplayButton};
|
||||||
use crate::ui_modal::ModalScrim;
|
use crate::ui_modal::ModalScrim;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
|
ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
|
||||||
@@ -163,12 +170,15 @@ pub struct SessionAchievements {
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct WinSummaryOverlay;
|
pub struct WinSummaryOverlay;
|
||||||
|
|
||||||
/// Marker on the "Play Again" / "Watch Replay" buttons inside the win-summary modal.
|
/// Marker on the "Play Again" button inside the win-summary modal.
|
||||||
|
///
|
||||||
|
/// Watch Replay and Share Replay carry the shared
|
||||||
|
/// [`WatchReplayButton`] / [`CopyShareLinkButton`] markers from
|
||||||
|
/// `stats_plugin`, so the global handlers there drive them (both act
|
||||||
|
/// on [`crate::stats_plugin::SelectedReplayIndex`], which snaps to the
|
||||||
|
/// just-won replay on every `GameWonEvent`).
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
enum WinSummaryButton {
|
struct WinSummaryPlayAgainButton;
|
||||||
PlayAgain,
|
|
||||||
WatchReplay,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marker for one row of the win-modal score-breakdown reveal.
|
/// Marker for one row of the win-modal score-breakdown reveal.
|
||||||
///
|
///
|
||||||
@@ -230,6 +240,7 @@ impl Plugin for WinSummaryPlugin {
|
|||||||
collect_session_achievements,
|
collect_session_achievements,
|
||||||
spawn_win_summary_after_delay,
|
spawn_win_summary_after_delay,
|
||||||
handle_win_summary_buttons,
|
handle_win_summary_buttons,
|
||||||
|
close_overlay_on_watch_replay,
|
||||||
handle_win_summary_keyboard,
|
handle_win_summary_keyboard,
|
||||||
apply_screen_shake,
|
apply_screen_shake,
|
||||||
reveal_score_breakdown,
|
reveal_score_breakdown,
|
||||||
@@ -604,50 +615,48 @@ fn spawn_win_summary_after_delay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
|
/// Handles "Play Again" in the win-summary modal: collapses the
|
||||||
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
|
/// overlay and requests a fresh deal. `NewGameRequestEvent::default()`
|
||||||
|
/// reuses the current game's `GameMode`, and `handle_new_game` reads
|
||||||
|
/// the deal options (draw mode, difficulty) from `Settings` — so the
|
||||||
|
/// rematch is "same mode + same options" in one tap.
|
||||||
fn handle_win_summary_buttons(
|
fn handle_win_summary_buttons(
|
||||||
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>,
|
interaction_query: Query<&Interaction, (Changed<Interaction>, With<WinSummaryPlayAgainButton>)>,
|
||||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
mut toast: MessageWriter<InfoToastEvent>,
|
|
||||||
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
|
|
||||||
mut playback: Option<ResMut<crate::replay_playback::ReplayPlaybackState>>,
|
|
||||||
) {
|
) {
|
||||||
// Collect all pressed buttons first to avoid moving `playback` inside the loop.
|
if !interaction_query.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
let pressed: Vec<&WinSummaryButton> = interaction_query
|
return;
|
||||||
.iter()
|
}
|
||||||
.filter(|(i, _)| **i == Interaction::Pressed)
|
for entity in &overlays {
|
||||||
.map(|(_, b)| b)
|
commands.entity(entity).despawn();
|
||||||
.collect();
|
}
|
||||||
|
new_game.write(NewGameRequestEvent::default());
|
||||||
|
}
|
||||||
|
|
||||||
for button in pressed {
|
/// Collapses the win-summary overlay when its "Watch Replay" button is
|
||||||
match button {
|
/// pressed, so playback (started by `stats_plugin`'s global
|
||||||
WinSummaryButton::PlayAgain => {
|
/// [`WatchReplayButton`] handler reacting to the same press) is not
|
||||||
for entity in &overlays {
|
/// hidden behind the celebration scrim. No-op while the overlay is
|
||||||
commands.entity(entity).despawn();
|
/// closed — the Replays-tab copy of the button manages its own modal.
|
||||||
}
|
///
|
||||||
new_game.write(NewGameRequestEvent::default());
|
/// "Share Replay" ([`CopyShareLinkButton`]) deliberately does NOT
|
||||||
}
|
/// close the overlay: the player stays on the celebration while the
|
||||||
WinSummaryButton::WatchReplay => {
|
/// copy-feedback toast confirms the link.
|
||||||
let latest = history.as_ref().and_then(|h| h.0.replays.last()).cloned();
|
fn close_overlay_on_watch_replay(
|
||||||
match (latest, playback.as_mut()) {
|
buttons: Query<&Interaction, (Changed<Interaction>, With<WatchReplayButton>)>,
|
||||||
(Some(replay), Some(pb)) => {
|
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||||
for entity in &overlays {
|
mut commands: Commands,
|
||||||
commands.entity(entity).despawn();
|
) {
|
||||||
}
|
if overlays.is_empty() {
|
||||||
crate::replay_playback::start_replay_playback(&mut commands, pb, replay);
|
return;
|
||||||
}
|
}
|
||||||
(Some(_), None) => {
|
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
toast.write(InfoToastEvent("Replay playback not available".to_string()));
|
return;
|
||||||
}
|
}
|
||||||
(None, _) => {
|
for entity in &overlays {
|
||||||
toast.write(InfoToastEvent("No replay saved yet".to_string()));
|
commands.entity(entity).despawn();
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,18 +827,68 @@ fn spawn_overlay(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score breakdown reveal — replaces the previous single
|
// --- Action hierarchy (Phase G) ---
|
||||||
// "Score:" line with a per-component multi-row layout.
|
// Play Again is the hero action; the two replay
|
||||||
|
// actions sit under it; the stats recap reads quietly
|
||||||
|
// below all three. Rematch is one tap.
|
||||||
|
|
||||||
|
// Play Again (primary, full row)
|
||||||
|
card.spawn((
|
||||||
|
WinSummaryPlayAgainButton,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_self: AlignSelf::Stretch,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
margin: UiRect::top(VAL_SPACE_2),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(ACCENT_PRIMARY),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new("Play Again \u{21B5}"),
|
||||||
|
TextFont {
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(BG_BASE),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch Replay + Share Replay (secondary, side by side).
|
||||||
|
// Both reuse the global stats_plugin handlers, which
|
||||||
|
// target the just-won replay (`SelectedReplayIndex`
|
||||||
|
// snaps to 0 on every win). Each is always rendered —
|
||||||
|
// with no replay / no share URL the handler explains
|
||||||
|
// itself in a toast instead of silently doing nothing.
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
spawn_replay_action(row, WatchReplayButton, "Watch Replay");
|
||||||
|
spawn_replay_action(row, CopyShareLinkButton, "Share Replay");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Quiet stats recap, below the actions ---
|
||||||
|
|
||||||
|
// Score breakdown reveal — per-component multi-row
|
||||||
|
// layout with the staggered fade-in.
|
||||||
spawn_score_breakdown(card, &breakdown, anim_speed);
|
spawn_score_breakdown(card, &breakdown, anim_speed);
|
||||||
|
|
||||||
// Time
|
// Time (demoted to body/secondary — part of the quiet
|
||||||
|
// recap, not the celebration headline)
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
|
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
|
||||||
TextFont {
|
TextFont {
|
||||||
font_size: TYPE_HEADLINE,
|
font_size: TYPE_BODY_LG,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
TextColor(TEXT_PRIMARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
|
|
||||||
// XP total
|
// XP total
|
||||||
@@ -859,68 +918,40 @@ fn spawn_overlay(
|
|||||||
if !session.names.is_empty() {
|
if !session.names.is_empty() {
|
||||||
spawn_achievements_section(card, &session.names);
|
spawn_achievements_section(card, &session.names);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Button row: Watch Replay + Play Again side by side.
|
|
||||||
card.spawn(Node {
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
column_gap: VAL_SPACE_3,
|
|
||||||
margin: UiRect::top(VAL_SPACE_2),
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|row| {
|
|
||||||
// Watch Replay (secondary style)
|
|
||||||
row.spawn((
|
|
||||||
WinSummaryButton::WatchReplay,
|
|
||||||
Button,
|
|
||||||
Node {
|
|
||||||
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(Color::NONE),
|
|
||||||
BorderColor::all(ACCENT_PRIMARY),
|
|
||||||
))
|
|
||||||
.with_children(|b| {
|
|
||||||
b.spawn((
|
|
||||||
Text::new("Watch Replay"),
|
|
||||||
TextFont {
|
|
||||||
font_size: TYPE_BODY_LG,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(ACCENT_PRIMARY),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Play Again (primary style)
|
|
||||||
row.spawn((
|
|
||||||
WinSummaryButton::PlayAgain,
|
|
||||||
Button,
|
|
||||||
Node {
|
|
||||||
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(ACCENT_PRIMARY),
|
|
||||||
))
|
|
||||||
.with_children(|b| {
|
|
||||||
b.spawn((
|
|
||||||
Text::new("Play Again \u{21B5}"),
|
|
||||||
TextFont {
|
|
||||||
font_size: TYPE_BODY_LG,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(BG_BASE),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawns one secondary (outline-style) replay action button in the
|
||||||
|
/// win modal's replay row. `marker` is the shared `stats_plugin`
|
||||||
|
/// click-target component (`WatchReplayButton` / `CopyShareLinkButton`)
|
||||||
|
/// whose global handler reacts to the press.
|
||||||
|
fn spawn_replay_action<M: Component>(row: &mut ChildSpawnerCommands, marker: M, label: &str) {
|
||||||
|
row.spawn((
|
||||||
|
marker,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::NONE),
|
||||||
|
BorderColor::all(ACCENT_PRIMARY),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new(label.to_string()),
|
||||||
|
TextFont {
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(ACCENT_PRIMARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Maximum number of achievement names shown explicitly in the win modal before
|
/// Maximum number of achievement names shown explicitly in the win modal before
|
||||||
/// the overflow "...and N more" line is shown instead.
|
/// the overflow "...and N more" line is shown instead.
|
||||||
const MAX_ACHIEVEMENTS_SHOWN: usize = 3;
|
const MAX_ACHIEVEMENTS_SHOWN: usize = 3;
|
||||||
@@ -1863,4 +1894,134 @@ mod tests {
|
|||||||
assert_eq!(stagger, 0.0);
|
assert_eq!(stagger, 0.0);
|
||||||
assert_eq!(fade, 0.0);
|
assert_eq!(fade, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase G — action hierarchy
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Like [`make_app`] but with `TimePlugin` disabled and a manual
|
||||||
|
/// `Time` resource, so tests can step the win-summary delay timer
|
||||||
|
/// deterministically via `Time::advance_by` (the real clock's
|
||||||
|
/// microsecond deltas would never reach the 0.5 s threshold).
|
||||||
|
fn make_app_manual_clock() -> App {
|
||||||
|
use bevy::time::TimePlugin;
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins.build().disable::<TimePlugin>())
|
||||||
|
.add_plugins(WinSummaryPlugin)
|
||||||
|
.insert_resource(StatsResource(StatsSnapshot::default()))
|
||||||
|
.insert_resource(GameStateResource(GameState::new(
|
||||||
|
0,
|
||||||
|
solitaire_core::DrawStockConfig::DrawOne,
|
||||||
|
)))
|
||||||
|
.insert_resource(ProgressResource(PlayerProgress::default()));
|
||||||
|
app.init_resource::<Time>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives the real spawn path: fire `GameWonEvent`, then advance
|
||||||
|
/// `Time` past the 0.5 s celebration delay so
|
||||||
|
/// `spawn_win_summary_after_delay` spawns the overlay.
|
||||||
|
fn open_win_overlay(app: &mut App) {
|
||||||
|
app.world_mut().write_message(GameWonEvent {
|
||||||
|
score: 1200,
|
||||||
|
time_seconds: 90,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
{
|
||||||
|
let mut time = app.world_mut().resource_mut::<Time>();
|
||||||
|
time.advance_by(std::time::Duration::from_secs_f32(
|
||||||
|
WIN_SUMMARY_DELAY_SECS + 0.1,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
// One more frame so the deferred spawn commands flush.
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlay_count(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&WinSummaryOverlay>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn button_entity<M: Component>(app: &mut App) -> Entity {
|
||||||
|
let entities: Vec<Entity> = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<Entity, With<M>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(entities.len(), 1, "expected exactly one button");
|
||||||
|
entities[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The win modal must render all three Phase G actions: the
|
||||||
|
/// primary Play Again plus the shared Watch/Share replay buttons.
|
||||||
|
#[test]
|
||||||
|
fn win_modal_renders_play_again_watch_and_share_actions() {
|
||||||
|
let mut app = make_app_manual_clock();
|
||||||
|
open_win_overlay(&mut app);
|
||||||
|
assert_eq!(overlay_count(&mut app), 1, "overlay must spawn after delay");
|
||||||
|
button_entity::<WinSummaryPlayAgainButton>(&mut app);
|
||||||
|
button_entity::<WatchReplayButton>(&mut app);
|
||||||
|
button_entity::<CopyShareLinkButton>(&mut app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play Again collapses the overlay and requests a fresh deal.
|
||||||
|
#[test]
|
||||||
|
fn play_again_press_closes_overlay_and_requests_new_game() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
|
||||||
|
let mut app = make_app_manual_clock();
|
||||||
|
open_win_overlay(&mut app);
|
||||||
|
let button = button_entity::<WinSummaryPlayAgainButton>(&mut app);
|
||||||
|
app.world_mut()
|
||||||
|
.entity_mut(button)
|
||||||
|
.insert(Interaction::Pressed);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(overlay_count(&mut app), 0, "Play Again must close overlay");
|
||||||
|
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||||
|
assert!(
|
||||||
|
!events.is_empty(),
|
||||||
|
"Play Again must write NewGameRequestEvent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch Replay collapses the overlay so playback (driven by the
|
||||||
|
/// global `stats_plugin` handler on the same press) is visible.
|
||||||
|
#[test]
|
||||||
|
fn watch_replay_press_closes_the_overlay() {
|
||||||
|
let mut app = make_app_manual_clock();
|
||||||
|
open_win_overlay(&mut app);
|
||||||
|
let button = button_entity::<WatchReplayButton>(&mut app);
|
||||||
|
app.world_mut()
|
||||||
|
.entity_mut(button)
|
||||||
|
.insert(Interaction::Pressed);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
overlay_count(&mut app),
|
||||||
|
0,
|
||||||
|
"Watch Replay must close the celebration overlay"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Share Replay leaves the overlay open — the player stays on the
|
||||||
|
/// celebration while the copy-feedback toast confirms the link.
|
||||||
|
#[test]
|
||||||
|
fn share_replay_press_keeps_the_overlay_open() {
|
||||||
|
let mut app = make_app_manual_clock();
|
||||||
|
open_win_overlay(&mut app);
|
||||||
|
let button = button_entity::<CopyShareLinkButton>(&mut app);
|
||||||
|
app.world_mut()
|
||||||
|
.entity_mut(button)
|
||||||
|
.insert(Interaction::Pressed);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
overlay_count(&mut app),
|
||||||
|
1,
|
||||||
|
"Share Replay must not close the overlay"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 61918, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 62028, 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: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7471, 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: 7364, 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: 7474, 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: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7471, 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: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7471, 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: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7471, 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: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7471, 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: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7471, 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: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7471, 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: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7471, 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: 7362, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7472, 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: 7363, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7473, 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.
Reference in New Issue
Block a user