//! 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, handle: Option>, } // --------------------------------------------------------------------------- // 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::() .add_message::() .add_message::() .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, font_res: Option>, existing: Query<(), With>, ) { 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>, screen: Query<(), With>, mut buffers: Query<&mut SeedInputBuffer>, mut displays: Query<(&mut Text, &mut TextColor), With>, mut pending: ResMut, ) { 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>, mut buffers: Query<&mut SeedInputBuffer>, mut pending: ResMut, mut badges: Query<(&mut Text, &mut TextColor), With>, settings: Option>, ) { 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::().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, mut badges: Query<(&mut Text, &mut TextColor), With>, ) { 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>, buttons: Query<&Interaction, (With, Changed)>, buffers: Query<&SeedInputBuffer>, screen: Query>, mut new_game: MessageWriter, ) { 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::() 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>, buttons: Query<&Interaction, (With, Changed)>, screen: Query>, other_scrims: Query<(), (With, Without)>, ) { 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::>(); 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::>() .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::>(); 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::>() .press(KeyCode::Enter); app.update(); let msgs = app.world().resource::>(); 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::>() .press(KeyCode::Enter); app.update(); let msgs = app.world().resource::>(); 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::>(); 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::(); 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::().handle.is_some()); // New keypress should cancel the in-flight task. press_key(&mut app, KeyCode::Digit3); assert!(app.world().resource::().handle.is_none()); assert_eq!(app.world().resource::().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::().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"); } }