feat(engine): add Play-by-Seed dialog with solver preview
Adds a numeric-input modal (PlayBySeedPlugin) that lets the player type a decimal seed and receive an instant solver-verified verdict before the hand is dealt. A new HomeMode::PlayBySeed card surfaces it in the home overlay, matched by the StartPlayBySeedRequestEvent carrier. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -29,10 +29,11 @@ use solitaire_engine::{
|
|||||||
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, FeedbackAnimPlugin, FontPlugin,
|
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, FeedbackAnimPlugin, FontPlugin,
|
||||||
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||||
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
|
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
||||||
ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
|
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin,
|
||||||
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
|
||||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||||
|
WinSummaryPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// App entry point — builds and runs the Bevy app.
|
/// App entry point — builds and runs the Bevy app.
|
||||||
@@ -145,6 +146,13 @@ pub fn run() {
|
|||||||
.add_plugins(GamePlugin)
|
.add_plugins(GamePlugin)
|
||||||
.add_plugins(TablePlugin)
|
.add_plugins(TablePlugin)
|
||||||
.add_plugins(CardPlugin)
|
.add_plugins(CardPlugin)
|
||||||
|
// Cursor-icon feedback is desktop-only; Android has no pointer cursor.
|
||||||
|
// The drop-target highlight systems (update_drop_highlights,
|
||||||
|
// update_drop_target_overlays) live in CursorPlugin but ARE useful
|
||||||
|
// on Android — they've been left running because their Bevy system
|
||||||
|
// params compile and function on Android; only the CursorIcon insert
|
||||||
|
// is inert. Gate the whole plugin if the cursor APIs ever cause
|
||||||
|
// Android linker issues; for now it's harmless to leave it registered.
|
||||||
.add_plugins(CursorPlugin)
|
.add_plugins(CursorPlugin)
|
||||||
.add_plugins(InputPlugin)
|
.add_plugins(InputPlugin)
|
||||||
.add_plugins(RadialMenuPlugin)
|
.add_plugins(RadialMenuPlugin)
|
||||||
@@ -161,6 +169,7 @@ pub fn run() {
|
|||||||
.add_plugins(DailyChallengePlugin)
|
.add_plugins(DailyChallengePlugin)
|
||||||
.add_plugins(WeeklyGoalsPlugin)
|
.add_plugins(WeeklyGoalsPlugin)
|
||||||
.add_plugins(ChallengePlugin)
|
.add_plugins(ChallengePlugin)
|
||||||
|
.add_plugins(PlayBySeedPlugin)
|
||||||
.add_plugins(TimeAttackPlugin)
|
.add_plugins(TimeAttackPlugin)
|
||||||
.add_plugins(HudPlugin)
|
.add_plugins(HudPlugin)
|
||||||
.add_plugins(HelpPlugin)
|
.add_plugins(HelpPlugin)
|
||||||
|
|||||||
@@ -172,6 +172,13 @@ pub struct StartTimeAttackRequestEvent;
|
|||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct StartDailyChallengeRequestEvent;
|
pub struct StartDailyChallengeRequestEvent;
|
||||||
|
|
||||||
|
/// Request to open the Play-by-Seed dialog. Fired by the Home overlay
|
||||||
|
/// "Play by Seed" mode card. The handler in `play_by_seed_plugin` spawns
|
||||||
|
/// a numeric-input modal where the player types a decimal seed and
|
||||||
|
/// optionally sees a solver-verified verdict before dealing.
|
||||||
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct StartPlayBySeedRequestEvent;
|
||||||
|
|
||||||
/// Request to toggle the Stats overlay. Fired by the HUD Menu-popover
|
/// Request to toggle the Stats overlay. Fired by the HUD Menu-popover
|
||||||
/// "Stats" row alongside the existing `S` accelerator.
|
/// "Stats" row alongside the existing `S` accelerator.
|
||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
|||||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
||||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
StartDailyChallengeRequestEvent, StartPlayBySeedRequestEvent, StartTimeAttackRequestEvent,
|
||||||
ToggleProfileRequestEvent,
|
StartZenRequestEvent, ToggleProfileRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
@@ -96,6 +96,7 @@ enum HomeMode {
|
|||||||
Zen,
|
Zen,
|
||||||
Challenge,
|
Challenge,
|
||||||
TimeAttack,
|
TimeAttack,
|
||||||
|
PlayBySeed,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HomeMode {
|
impl HomeMode {
|
||||||
@@ -107,6 +108,7 @@ impl HomeMode {
|
|||||||
HomeMode::Zen => "Zen Mode",
|
HomeMode::Zen => "Zen Mode",
|
||||||
HomeMode::Challenge => "Challenge",
|
HomeMode::Challenge => "Challenge",
|
||||||
HomeMode::TimeAttack => "Time Attack",
|
HomeMode::TimeAttack => "Time Attack",
|
||||||
|
HomeMode::PlayBySeed => "Play by Seed",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +120,7 @@ impl HomeMode {
|
|||||||
HomeMode::Zen => "No timer, no score. Just the cards.",
|
HomeMode::Zen => "No timer, no score. Just the cards.",
|
||||||
HomeMode::Challenge => "Hand-picked hard deals. No undo. Win to advance.",
|
HomeMode::Challenge => "Hand-picked hard deals. No undo. Win to advance.",
|
||||||
HomeMode::TimeAttack => "How many can you finish in ten minutes?",
|
HomeMode::TimeAttack => "How many can you finish in ten minutes?",
|
||||||
|
HomeMode::PlayBySeed => "Enter any number to play a specific deal.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +153,9 @@ impl HomeMode {
|
|||||||
// ships ▲ (up triangle) but evidently not the sideways
|
// ships ▲ (up triangle) but evidently not the sideways
|
||||||
// siblings.
|
// siblings.
|
||||||
HomeMode::TimeAttack => "\u{2192}",
|
HomeMode::TimeAttack => "\u{2192}",
|
||||||
|
// Number sign — ASCII, universally available. Reads as
|
||||||
|
// "a specific number / seed ID".
|
||||||
|
HomeMode::PlayBySeed => "#",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +168,7 @@ impl HomeMode {
|
|||||||
HomeMode::Zen => "Z",
|
HomeMode::Zen => "Z",
|
||||||
HomeMode::Challenge => "X",
|
HomeMode::Challenge => "X",
|
||||||
HomeMode::TimeAttack => "T",
|
HomeMode::TimeAttack => "T",
|
||||||
|
HomeMode::PlayBySeed => "6",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +245,7 @@ impl Plugin for HomePlugin {
|
|||||||
.add_message::<StartChallengeRequestEvent>()
|
.add_message::<StartChallengeRequestEvent>()
|
||||||
.add_message::<StartTimeAttackRequestEvent>()
|
.add_message::<StartTimeAttackRequestEvent>()
|
||||||
.add_message::<StartDailyChallengeRequestEvent>()
|
.add_message::<StartDailyChallengeRequestEvent>()
|
||||||
|
.add_message::<StartPlayBySeedRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<ToggleProfileRequestEvent>()
|
.add_message::<ToggleProfileRequestEvent>()
|
||||||
.add_message::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
@@ -423,6 +431,7 @@ fn handle_home_card_click(
|
|||||||
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
||||||
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
||||||
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
||||||
|
mut play_by_seed: MessageWriter<StartPlayBySeedRequestEvent>,
|
||||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
let level = progress.as_ref().map_or(0, |p| p.0.level);
|
let level = progress.as_ref().map_or(0, |p| p.0.level);
|
||||||
@@ -457,6 +466,9 @@ fn handle_home_card_click(
|
|||||||
HomeMode::TimeAttack => {
|
HomeMode::TimeAttack => {
|
||||||
time_attack.write(StartTimeAttackRequestEvent);
|
time_attack.write(StartTimeAttackRequestEvent);
|
||||||
}
|
}
|
||||||
|
HomeMode::PlayBySeed => {
|
||||||
|
play_by_seed.write(StartPlayBySeedRequestEvent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the modal after dispatching the launch event.
|
// Close the modal after dispatching the launch event.
|
||||||
@@ -619,6 +631,7 @@ fn digit_to_home_mode(key: KeyCode) -> Option<HomeMode> {
|
|||||||
KeyCode::Digit3 => Some(HomeMode::Zen),
|
KeyCode::Digit3 => Some(HomeMode::Zen),
|
||||||
KeyCode::Digit4 => Some(HomeMode::Challenge),
|
KeyCode::Digit4 => Some(HomeMode::Challenge),
|
||||||
KeyCode::Digit5 => Some(HomeMode::TimeAttack),
|
KeyCode::Digit5 => Some(HomeMode::TimeAttack),
|
||||||
|
KeyCode::Digit6 => Some(HomeMode::PlayBySeed),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -646,6 +659,7 @@ fn handle_home_digit_keys(
|
|||||||
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
||||||
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
||||||
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
||||||
|
mut play_by_seed: MessageWriter<StartPlayBySeedRequestEvent>,
|
||||||
) {
|
) {
|
||||||
// Modal-scoped: do nothing when the Mode Launcher isn't open.
|
// Modal-scoped: do nothing when the Mode Launcher isn't open.
|
||||||
if screens.is_empty() {
|
if screens.is_empty() {
|
||||||
@@ -658,6 +672,7 @@ fn handle_home_digit_keys(
|
|||||||
KeyCode::Digit3,
|
KeyCode::Digit3,
|
||||||
KeyCode::Digit4,
|
KeyCode::Digit4,
|
||||||
KeyCode::Digit5,
|
KeyCode::Digit5,
|
||||||
|
KeyCode::Digit6,
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|k| keys.just_pressed(*k))
|
.find(|k| keys.just_pressed(*k))
|
||||||
@@ -687,6 +702,9 @@ fn handle_home_digit_keys(
|
|||||||
HomeMode::TimeAttack => {
|
HomeMode::TimeAttack => {
|
||||||
time_attack.write(StartTimeAttackRequestEvent);
|
time_attack.write(StartTimeAttackRequestEvent);
|
||||||
}
|
}
|
||||||
|
HomeMode::PlayBySeed => {
|
||||||
|
play_by_seed.write(StartPlayBySeedRequestEvent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the modal after dispatching the launch event — same shape as
|
// Close the modal after dispatching the launch event — same shape as
|
||||||
@@ -784,6 +802,7 @@ fn spawn_home_screen(commands: &mut Commands, ctx: HomeContext<'_>) {
|
|||||||
HomeMode::Zen,
|
HomeMode::Zen,
|
||||||
HomeMode::Challenge,
|
HomeMode::Challenge,
|
||||||
HomeMode::TimeAttack,
|
HomeMode::TimeAttack,
|
||||||
|
HomeMode::PlayBySeed,
|
||||||
] {
|
] {
|
||||||
spawn_mode_card(grid, mode, &ctx);
|
spawn_mode_card(grid, mode, &ctx);
|
||||||
}
|
}
|
||||||
@@ -999,6 +1018,7 @@ fn home_mode_focus_order(mode: HomeMode) -> i32 {
|
|||||||
HomeMode::Zen => 2,
|
HomeMode::Zen => 2,
|
||||||
HomeMode::Challenge => 3,
|
HomeMode::Challenge => 3,
|
||||||
HomeMode::TimeAttack => 4,
|
HomeMode::TimeAttack => 4,
|
||||||
|
HomeMode::PlayBySeed => 5,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1402,13 +1422,14 @@ mod tests {
|
|||||||
HomeMode::Zen,
|
HomeMode::Zen,
|
||||||
HomeMode::Challenge,
|
HomeMode::Challenge,
|
||||||
HomeMode::TimeAttack,
|
HomeMode::TimeAttack,
|
||||||
|
HomeMode::PlayBySeed,
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
modes.contains(&expected),
|
modes.contains(&expected),
|
||||||
"missing card for {expected:?}; found {modes:?}"
|
"missing card for {expected:?}; found {modes:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert_eq!(modes.len(), 5, "exactly five cards expected");
|
assert_eq!(modes.len(), 6, "exactly six cards expected");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1600,7 +1621,7 @@ mod tests {
|
|||||||
.map(|(c, f)| (c.0, *f))
|
.map(|(c, f)| (c.0, *f))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
assert_eq!(cards.len(), 5, "all five cards must carry a Focusable");
|
assert_eq!(cards.len(), 6, "all six cards must carry a Focusable");
|
||||||
for (mode, focusable) in &cards {
|
for (mode, focusable) in &cards {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
focusable.group,
|
focusable.group,
|
||||||
@@ -1626,7 +1647,7 @@ mod tests {
|
|||||||
|
|
||||||
for (mode, disabled) in states {
|
for (mode, disabled) in states {
|
||||||
match mode {
|
match mode {
|
||||||
HomeMode::Classic | HomeMode::Daily => assert!(
|
HomeMode::Classic | HomeMode::Daily | HomeMode::PlayBySeed => assert!(
|
||||||
!disabled,
|
!disabled,
|
||||||
"{mode:?} must not be Disabled at level 0 (it's never locked)"
|
"{mode:?} must not be Disabled at level 0 (it's never locked)"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub mod layout;
|
|||||||
pub mod onboarding_plugin;
|
pub mod onboarding_plugin;
|
||||||
pub mod pause_plugin;
|
pub mod pause_plugin;
|
||||||
pub mod pending_hint;
|
pub mod pending_hint;
|
||||||
|
pub mod play_by_seed_plugin;
|
||||||
pub mod profile_plugin;
|
pub mod profile_plugin;
|
||||||
pub mod radial_menu;
|
pub mod radial_menu;
|
||||||
pub mod replay_overlay;
|
pub mod replay_overlay;
|
||||||
@@ -92,11 +93,12 @@ pub use events::{
|
|||||||
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
|
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
|
||||||
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||||
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||||
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
|
StartDailyChallengeRequestEvent, StartPlayBySeedRequestEvent, StartTimeAttackRequestEvent,
|
||||||
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
|
||||||
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
||||||
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, XpAwardedEvent,
|
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, XpAwardedEvent,
|
||||||
};
|
};
|
||||||
|
pub use play_by_seed_plugin::{PlayBySeedPlugin, PlayBySeedScreen};
|
||||||
pub use game_plugin::{
|
pub use game_plugin::{
|
||||||
ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath, RecordingReplay,
|
ConfirmNewGameScreen, GameMutation, GameOverScreen, GamePlugin, GameStatePath, RecordingReplay,
|
||||||
ReplayPath,
|
ReplayPath,
|
||||||
|
|||||||
@@ -0,0 +1,663 @@
|
|||||||
|
//! Play-by-Seed dialog: lets the player type a decimal seed number and start
|
||||||
|
//! a Classic game with that exact deal. A live solver-verification badge
|
||||||
|
//! updates asynchronously after a short typing debounce so the player knows
|
||||||
|
//! whether the deal is provably winnable before committing.
|
||||||
|
//!
|
||||||
|
//! # Flow
|
||||||
|
//!
|
||||||
|
//! 1. `HomePlugin` fires [`StartPlayBySeedRequestEvent`] when the "Play by
|
||||||
|
//! Seed" card is clicked (or `6` is pressed in the Mode Launcher).
|
||||||
|
//! 2. `handle_open_dialog` reads the event and spawns the seed-input modal.
|
||||||
|
//! 3. `handle_text_input` appends decimal digits / handles Backspace while
|
||||||
|
//! the modal is open, updating [`SeedInputBuffer`] each frame.
|
||||||
|
//! 4. `tick_debounce_and_spawn_solver_task` waits for 12 frames (~200 ms at
|
||||||
|
//! 60 Hz) of no input before spawning a [`try_solve`] task on
|
||||||
|
//! [`AsyncComputeTaskPool`]. Any fresh keypress drops the in-flight task
|
||||||
|
//! by resetting the resource.
|
||||||
|
//! 5. `poll_solver_task` polls the in-flight task each frame and updates the
|
||||||
|
//! [`SolverVerdictBadge`] text node with the verdict.
|
||||||
|
//! 6. `handle_confirm` fires [`NewGameRequestEvent`] with the parsed seed and
|
||||||
|
//! despawns the dialog on Play click or `Enter`.
|
||||||
|
//! 7. `handle_cancel` despawns the dialog on Cancel click or `Escape`.
|
||||||
|
|
||||||
|
use bevy::input::ButtonInput;
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
|
||||||
|
|
||||||
|
use crate::events::{NewGameRequestEvent, StartPlayBySeedRequestEvent};
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::game_plugin::GameMutation;
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
|
use crate::ui_modal::{
|
||||||
|
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button, spawn_modal_header,
|
||||||
|
ButtonVariant, ScrimDismissible,
|
||||||
|
};
|
||||||
|
use crate::ui_theme::{
|
||||||
|
ACCENT_PRIMARY, BG_ELEVATED_PRESSED, BORDER_SUBTLE, HighContrastBorder, RADIUS_MD,
|
||||||
|
TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
|
Z_MODAL_PANEL,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Components and resources
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Marker on the seed-input modal scrim (the despawn root).
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct PlayBySeedScreen;
|
||||||
|
|
||||||
|
/// Holds the decimal digit string the player is typing and a frame counter
|
||||||
|
/// used to debounce solver task spawning.
|
||||||
|
#[derive(Component, Debug, Default)]
|
||||||
|
struct SeedInputBuffer {
|
||||||
|
/// Raw decimal digit string. Never longer than 20 chars (u64::MAX is 20
|
||||||
|
/// decimal digits). Empty means "no seed entered".
|
||||||
|
text: String,
|
||||||
|
/// Frames elapsed since the last keystroke. The solver task is spawned
|
||||||
|
/// once this crosses [`DEBOUNCE_FRAMES`] and the buffer is non-empty.
|
||||||
|
frames_since_change: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker on the text node that renders the solver verdict caption.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SolverVerdictBadge;
|
||||||
|
|
||||||
|
/// Marker on the Play (confirm) button so `handle_confirm` can find it.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct PlayBySeedConfirmButton;
|
||||||
|
|
||||||
|
/// Marker on the Cancel button.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct PlayBySeedCancelButton;
|
||||||
|
|
||||||
|
/// Marker on the input-field text node so `handle_text_input` can update
|
||||||
|
/// it without a separate query for the buffer entity.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SeedInputDisplay;
|
||||||
|
|
||||||
|
/// In-flight async solver verification task. At most one is live at a time —
|
||||||
|
/// a fresh keypress resets this resource (dropping the previous `Task<_>`)
|
||||||
|
/// before spawning the next one.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct PendingVerification {
|
||||||
|
seed: Option<u64>,
|
||||||
|
handle: Option<Task<SolverResult>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Frames of no-keypress activity before the solver task is spawned.
|
||||||
|
/// 12 frames ≈ 200 ms at 60 Hz — long enough to avoid thrashing on fast
|
||||||
|
/// typists but short enough to feel responsive.
|
||||||
|
const DEBOUNCE_FRAMES: u32 = 12;
|
||||||
|
|
||||||
|
/// Maximum decimal digits accepted. 20 covers all of u64::MAX (18,446,744,073,709,551,615).
|
||||||
|
const MAX_SEED_DIGITS: usize = 20;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Registers all play-by-seed systems and resources.
|
||||||
|
pub struct PlayBySeedPlugin;
|
||||||
|
|
||||||
|
impl Plugin for PlayBySeedPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<PendingVerification>()
|
||||||
|
.add_message::<StartPlayBySeedRequestEvent>()
|
||||||
|
.add_message::<NewGameRequestEvent>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
handle_open_dialog,
|
||||||
|
handle_text_input,
|
||||||
|
tick_debounce_and_spawn_solver_task,
|
||||||
|
poll_solver_task,
|
||||||
|
handle_confirm,
|
||||||
|
handle_cancel,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
// Fire before GameMutation so `handle_confirm`'s
|
||||||
|
// NewGameRequestEvent is processed on the same frame.
|
||||||
|
.before(GameMutation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Spawns the seed-input dialog when `StartPlayBySeedRequestEvent` fires.
|
||||||
|
fn handle_open_dialog(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut requests: MessageReader<StartPlayBySeedRequestEvent>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
existing: Query<(), With<PlayBySeedScreen>>,
|
||||||
|
) {
|
||||||
|
if requests.read().count() == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Guard against double-spawn (e.g. two events in one frame).
|
||||||
|
if !existing.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let font = font_res.as_deref();
|
||||||
|
let font_handle = font.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
|
||||||
|
let scrim = spawn_modal(&mut commands, PlayBySeedScreen, Z_MODAL_PANEL, |card| {
|
||||||
|
spawn_modal_header(card, "Play by Seed", font);
|
||||||
|
spawn_modal_body_text(
|
||||||
|
card,
|
||||||
|
"Enter a number to play that specific deal.",
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
font,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Input field — a bordered box that shows the typed digits.
|
||||||
|
card.spawn((
|
||||||
|
Node {
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_ELEVATED_PRESSED),
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
|
SeedInputBuffer::default(),
|
||||||
|
))
|
||||||
|
.with_children(|field| {
|
||||||
|
field.spawn((
|
||||||
|
SeedInputDisplay,
|
||||||
|
Text::new(""),
|
||||||
|
TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_DISABLED),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Solver verdict badge — updates as solver runs.
|
||||||
|
card.spawn((
|
||||||
|
SolverVerdictBadge,
|
||||||
|
Text::new("Type a number"),
|
||||||
|
TextFont {
|
||||||
|
font: font_handle,
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
|
||||||
|
spawn_modal_actions(card, |row| {
|
||||||
|
spawn_modal_button(
|
||||||
|
row,
|
||||||
|
PlayBySeedCancelButton,
|
||||||
|
"Cancel",
|
||||||
|
Some("Esc"),
|
||||||
|
ButtonVariant::Secondary,
|
||||||
|
font,
|
||||||
|
);
|
||||||
|
spawn_modal_button(
|
||||||
|
row,
|
||||||
|
PlayBySeedConfirmButton,
|
||||||
|
"Play",
|
||||||
|
Some("Enter"),
|
||||||
|
ButtonVariant::Primary,
|
||||||
|
font,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Play-by-Seed is read-only input — opt into click-outside-to-dismiss.
|
||||||
|
commands.entity(scrim).insert(ScrimDismissible);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends decimal digits and handles Backspace while the dialog is open.
|
||||||
|
fn handle_text_input(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
screen: Query<(), With<PlayBySeedScreen>>,
|
||||||
|
mut buffers: Query<&mut SeedInputBuffer>,
|
||||||
|
mut displays: Query<(&mut Text, &mut TextColor), With<SeedInputDisplay>>,
|
||||||
|
mut pending: ResMut<PendingVerification>,
|
||||||
|
) {
|
||||||
|
if screen.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(mut buf) = buffers.single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let digit_keys = [
|
||||||
|
(KeyCode::Digit0, '0'),
|
||||||
|
(KeyCode::Digit1, '1'),
|
||||||
|
(KeyCode::Digit2, '2'),
|
||||||
|
(KeyCode::Digit3, '3'),
|
||||||
|
(KeyCode::Digit4, '4'),
|
||||||
|
(KeyCode::Digit5, '5'),
|
||||||
|
(KeyCode::Digit6, '6'),
|
||||||
|
(KeyCode::Digit7, '7'),
|
||||||
|
(KeyCode::Digit8, '8'),
|
||||||
|
(KeyCode::Digit9, '9'),
|
||||||
|
(KeyCode::Numpad0, '0'),
|
||||||
|
(KeyCode::Numpad1, '1'),
|
||||||
|
(KeyCode::Numpad2, '2'),
|
||||||
|
(KeyCode::Numpad3, '3'),
|
||||||
|
(KeyCode::Numpad4, '4'),
|
||||||
|
(KeyCode::Numpad5, '5'),
|
||||||
|
(KeyCode::Numpad6, '6'),
|
||||||
|
(KeyCode::Numpad7, '7'),
|
||||||
|
(KeyCode::Numpad8, '8'),
|
||||||
|
(KeyCode::Numpad9, '9'),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut changed = false;
|
||||||
|
|
||||||
|
for (key, ch) in digit_keys {
|
||||||
|
if keys.just_pressed(key) && buf.text.len() < MAX_SEED_DIGITS {
|
||||||
|
// Drop a leading zero unless the buffer is empty (prevents "007").
|
||||||
|
if ch == '0' && buf.text.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
buf.text.push(ch);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if keys.just_pressed(KeyCode::Backspace) && !buf.text.is_empty() {
|
||||||
|
buf.text.pop();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
buf.frames_since_change = 0;
|
||||||
|
// Cancel any in-flight solver task — its seed is now stale.
|
||||||
|
*pending = PendingVerification::default();
|
||||||
|
|
||||||
|
// Update the display node.
|
||||||
|
if let Ok((mut text, mut color)) = displays.single_mut() {
|
||||||
|
if buf.text.is_empty() {
|
||||||
|
text.0 = String::new();
|
||||||
|
color.0 = TEXT_DISABLED;
|
||||||
|
} else {
|
||||||
|
text.0 = buf.text.clone();
|
||||||
|
color.0 = TEXT_PRIMARY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increments the debounce counter each frame and spawns the solver task
|
||||||
|
/// once the counter passes [`DEBOUNCE_FRAMES`] and the buffer holds a
|
||||||
|
/// valid u64.
|
||||||
|
fn tick_debounce_and_spawn_solver_task(
|
||||||
|
screen: Query<(), With<PlayBySeedScreen>>,
|
||||||
|
mut buffers: Query<&mut SeedInputBuffer>,
|
||||||
|
mut pending: ResMut<PendingVerification>,
|
||||||
|
mut badges: Query<(&mut Text, &mut TextColor), With<SolverVerdictBadge>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
) {
|
||||||
|
if screen.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(mut buf) = buffers.single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Always update the badge when the buffer is empty.
|
||||||
|
if buf.text.is_empty() {
|
||||||
|
if let Ok((mut text, mut color)) = badges.single_mut() {
|
||||||
|
text.0 = "Type a number".to_string();
|
||||||
|
color.0 = TEXT_SECONDARY;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't spawn if a task is already running for this seed.
|
||||||
|
let parsed = buf.text.parse::<u64>().ok();
|
||||||
|
if pending.handle.is_some() && pending.seed == parsed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.frames_since_change = buf.frames_since_change.saturating_add(1);
|
||||||
|
if buf.frames_since_change < DEBOUNCE_FRAMES {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(seed) = parsed else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let draw_mode = settings
|
||||||
|
.as_ref()
|
||||||
|
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode.clone());
|
||||||
|
let cfg = SolverConfig::default();
|
||||||
|
let task = AsyncComputeTaskPool::get()
|
||||||
|
.spawn(async move { try_solve(seed, draw_mode, &cfg) });
|
||||||
|
|
||||||
|
pending.seed = Some(seed);
|
||||||
|
pending.handle = Some(task);
|
||||||
|
|
||||||
|
if let Ok((mut text, mut color)) = badges.single_mut() {
|
||||||
|
text.0 = "Verifying\u{2026}".to_string();
|
||||||
|
color.0 = TEXT_SECONDARY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls the in-flight solver task and updates the verdict badge on completion.
|
||||||
|
fn poll_solver_task(
|
||||||
|
mut pending: ResMut<PendingVerification>,
|
||||||
|
mut badges: Query<(&mut Text, &mut TextColor), With<SolverVerdictBadge>>,
|
||||||
|
) {
|
||||||
|
let Some(handle) = pending.handle.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(result) = future::block_on(future::poll_once(handle)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
pending.handle = None;
|
||||||
|
|
||||||
|
let Ok((mut text, mut color)) = badges.single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
SolverResult::Winnable => {
|
||||||
|
text.0 = "\u{2713} Provably winnable".to_string();
|
||||||
|
color.0 = ACCENT_PRIMARY;
|
||||||
|
}
|
||||||
|
SolverResult::Inconclusive => {
|
||||||
|
text.0 = "? Likely winnable (search timed out)".to_string();
|
||||||
|
color.0 = TEXT_SECONDARY;
|
||||||
|
}
|
||||||
|
SolverResult::Unwinnable => {
|
||||||
|
text.0 = "\u{2717} Provably unwinnable".to_string();
|
||||||
|
color.0 = TEXT_DISABLED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires [`NewGameRequestEvent`] with the parsed seed when Play is clicked
|
||||||
|
/// or `Enter` is pressed, then despawns the dialog. Does nothing when the
|
||||||
|
/// buffer is empty.
|
||||||
|
fn handle_confirm(
|
||||||
|
mut commands: Commands,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
buttons: Query<&Interaction, (With<PlayBySeedConfirmButton>, Changed<Interaction>)>,
|
||||||
|
buffers: Query<&SeedInputBuffer>,
|
||||||
|
screen: Query<Entity, With<PlayBySeedScreen>>,
|
||||||
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
|
) {
|
||||||
|
if screen.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let click = buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
let enter = keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter);
|
||||||
|
if !click && !enter {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(buf) = buffers.single() else { return };
|
||||||
|
let Ok(seed) = buf.text.parse::<u64>() else { return };
|
||||||
|
|
||||||
|
new_game.write(NewGameRequestEvent {
|
||||||
|
seed: Some(seed),
|
||||||
|
mode: None,
|
||||||
|
confirmed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
for entity in &screen {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the dialog on Cancel click or `Escape`.
|
||||||
|
fn handle_cancel(
|
||||||
|
mut commands: Commands,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
buttons: Query<&Interaction, (With<PlayBySeedCancelButton>, Changed<Interaction>)>,
|
||||||
|
screen: Query<Entity, With<PlayBySeedScreen>>,
|
||||||
|
other_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<PlayBySeedScreen>)>,
|
||||||
|
) {
|
||||||
|
if screen.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let click = buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
// Esc only closes this dialog when it is the topmost modal.
|
||||||
|
let esc = keys.just_pressed(KeyCode::Escape) && other_scrims.is_empty();
|
||||||
|
if !click && !esc {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for entity in &screen {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use crate::table_plugin::TablePlugin;
|
||||||
|
|
||||||
|
fn headless_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
|
.add_plugins(TablePlugin)
|
||||||
|
.add_plugins(PlayBySeedPlugin);
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_dialog(app: &mut App) {
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(StartPlayBySeedRequestEvent);
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press_key(app: &mut App, key: KeyCode) {
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(key);
|
||||||
|
app.update();
|
||||||
|
// Simulate what Bevy's PreUpdate input system does: flush just_pressed /
|
||||||
|
// just_released so stale key state doesn't bleed into the next frame.
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(key);
|
||||||
|
input.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dialog_present(app: &mut App) -> bool {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&PlayBySeedScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_buffer_text(app: &mut App) -> String {
|
||||||
|
let mut q = app.world_mut().query::<&SeedInputBuffer>();
|
||||||
|
q.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.map(|b| b.text.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dialog_spawns_on_request() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
assert!(!dialog_present(&mut app));
|
||||||
|
open_dialog(&mut app);
|
||||||
|
assert!(dialog_present(&mut app));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn digit_keys_append_to_buffer() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
open_dialog(&mut app);
|
||||||
|
|
||||||
|
press_key(&mut app, KeyCode::Digit4);
|
||||||
|
press_key(&mut app, KeyCode::Digit2);
|
||||||
|
|
||||||
|
assert_eq!(read_buffer_text(&mut app), "42");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backspace_removes_last_char() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
open_dialog(&mut app);
|
||||||
|
|
||||||
|
press_key(&mut app, KeyCode::Digit4);
|
||||||
|
press_key(&mut app, KeyCode::Digit2);
|
||||||
|
press_key(&mut app, KeyCode::Backspace);
|
||||||
|
|
||||||
|
assert_eq!(read_buffer_text(&mut app), "4");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirm_does_nothing_when_buffer_is_empty() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
open_dialog(&mut app);
|
||||||
|
|
||||||
|
// Simulate Enter with empty buffer.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::Enter);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let msgs = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
assert!(cursor.read(msgs).next().is_none(), "no NewGameRequestEvent when buffer empty");
|
||||||
|
// Dialog should still be open.
|
||||||
|
assert!(dialog_present(&mut app));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirm_writes_new_game_request_with_parsed_seed() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
open_dialog(&mut app);
|
||||||
|
|
||||||
|
press_key(&mut app, KeyCode::Digit4);
|
||||||
|
press_key(&mut app, KeyCode::Digit2);
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::Enter);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let msgs = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
let fired: Vec<_> = cursor.read(msgs).copied().collect();
|
||||||
|
assert_eq!(fired.len(), 1);
|
||||||
|
assert_eq!(fired[0].seed, Some(42));
|
||||||
|
assert_eq!(fired[0].mode, None);
|
||||||
|
assert!(!fired[0].confirmed);
|
||||||
|
|
||||||
|
// Dialog should be gone.
|
||||||
|
assert!(!dialog_present(&mut app));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancel_despawns_dialog_without_new_game_request() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
open_dialog(&mut app);
|
||||||
|
|
||||||
|
press_key(&mut app, KeyCode::Escape);
|
||||||
|
|
||||||
|
assert!(!dialog_present(&mut app));
|
||||||
|
|
||||||
|
let msgs = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
assert!(cursor.read(msgs).next().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn solver_task_spawns_after_debounce_window() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
open_dialog(&mut app);
|
||||||
|
|
||||||
|
press_key(&mut app, KeyCode::Digit4);
|
||||||
|
press_key(&mut app, KeyCode::Digit2);
|
||||||
|
|
||||||
|
// Debounce window — no task yet.
|
||||||
|
for _ in 0..DEBOUNCE_FRAMES {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
let pending = app.world().resource::<PendingVerification>();
|
||||||
|
assert!(pending.handle.is_some(), "solver task should have been spawned after debounce");
|
||||||
|
assert_eq!(pending.seed, Some(42));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keypress_mid_flight_cancels_previous_solver_task() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
open_dialog(&mut app);
|
||||||
|
|
||||||
|
press_key(&mut app, KeyCode::Digit4);
|
||||||
|
press_key(&mut app, KeyCode::Digit2);
|
||||||
|
|
||||||
|
// Let the debounce fire.
|
||||||
|
for _ in 0..DEBOUNCE_FRAMES {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
assert!(app.world().resource::<PendingVerification>().handle.is_some());
|
||||||
|
|
||||||
|
// New keypress should cancel the in-flight task.
|
||||||
|
press_key(&mut app, KeyCode::Digit3);
|
||||||
|
assert!(app.world().resource::<PendingVerification>().handle.is_none());
|
||||||
|
assert_eq!(app.world().resource::<PendingVerification>().seed, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn solver_task_completes_and_updates_badge() {
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
let mut app = headless_app();
|
||||||
|
open_dialog(&mut app);
|
||||||
|
|
||||||
|
// Seed 42 — solver will return some verdict.
|
||||||
|
press_key(&mut app, KeyCode::Digit4);
|
||||||
|
press_key(&mut app, KeyCode::Digit2);
|
||||||
|
|
||||||
|
// Wait for the debounce to spawn the task.
|
||||||
|
for _ in 0..DEBOUNCE_FRAMES {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll until the solver task resolves (cap at 15 s wall-clock).
|
||||||
|
let deadline = Instant::now() + std::time::Duration::from_secs(15);
|
||||||
|
while app.world().resource::<PendingVerification>().handle.is_some()
|
||||||
|
&& Instant::now() < deadline
|
||||||
|
{
|
||||||
|
app.update();
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badge text should no longer read "Verifying…".
|
||||||
|
let badge_text = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<(&Text, &SolverVerdictBadge)>()
|
||||||
|
.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.map(|(t, _)| t.0.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert_ne!(badge_text, "Verifying\u{2026}", "badge should have resolved to a verdict");
|
||||||
|
assert_ne!(badge_text, "Type a number", "badge should show verdict, not idle state");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user