diff --git a/solitaire_app/src/lib.rs b/solitaire_app/src/lib.rs index 7acdd7b..bcea166 100644 --- a/solitaire_app/src/lib.rs +++ b/solitaire_app/src/lib.rs @@ -25,7 +25,10 @@ use bevy::window::{MonitorSelection, PresentMode, WindowPosition}; use bevy::winit::WinitWindows; #[cfg(target_os = "android")] use bevy::winit::{UpdateMode, WinitSettings}; -use solitaire_data::{Settings, load_settings_from, provider_for_backend, settings_file_path}; +use solitaire_data::{ + Settings, cleanup_orphaned_tmp_files, load_settings_from, provider_for_backend, + settings_file_path, +}; use solitaire_engine::{CoreGamePlugin, SyncProvider, register_theme_asset_sources}; fn load_settings() -> Settings { @@ -49,6 +52,12 @@ pub fn run() { // and any debugger attached still sees the panic). install_crash_log_hook(); + // Remove any *.tmp files left behind by a crash between an atomic write + // and its rename. Safe to call unconditionally — missing data dir is a + // no-op. Must run before GamePlugin loads saved state so orphaned files + // don't accumulate across launches. + let _ = cleanup_orphaned_tmp_files(); + // Initialise the platform keyring store before any token operations. // On Linux this uses the Secret Service (GNOME Keyring / KWallet); on // macOS it uses the Keychain; on Windows it uses the Credential store. diff --git a/solitaire_core/src/klondike_adapter.rs b/solitaire_core/src/klondike_adapter.rs index 3deedc1..9ecf189 100644 --- a/solitaire_core/src/klondike_adapter.rs +++ b/solitaire_core/src/klondike_adapter.rs @@ -2,10 +2,10 @@ //! //! # Current scope (integration steps 1–4) //! -//! [`KlondikeAdapter`] owns the authoritative [`KlondikeConfig`] and exposes -//! scoring helpers backed by [`ScoringConfig::DEFAULT`] (Windows XP Standard -//! values). [`GameState`] delegates scoring here so that klondike remains the -//! single source of truth for scoring constants. +//! [`KlondikeAdapter`] is a pure helper namespace for: +//! - building [`KlondikeConfig`] from Ferrous settings +//! - translating between local and upstream types +//! - applying Ferrous-specific scoring policy on top of upstream defaults //! //! # Not yet implemented //! @@ -25,38 +25,16 @@ use crate::game_state::{DrawMode, GameMode}; /// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate. /// -/// Holds a [`KlondikeConfig`] reflecting the current game settings and exposes -/// scoring helpers that read from [`ScoringConfig::DEFAULT`] (WXP values). -/// [`GameState`] uses this instead of calling `scoring.rs` functions directly. -#[derive(Clone, Debug)] -pub struct KlondikeAdapter { - config: KlondikeConfig, -} - -impl PartialEq for KlondikeAdapter { - fn eq(&self, other: &Self) -> bool { - self.config.draw_stock == other.config.draw_stock - && self.config.move_from_foundation == other.config.move_from_foundation - } -} -impl Eq for KlondikeAdapter {} - -impl Default for KlondikeAdapter { - /// Returns an adapter with Draw-1 and `take_from_foundation = true`, - /// matching `GameState`'s own defaults. Used by `#[serde(skip)]` - /// field initialisation on deserialisation. - fn default() -> Self { - Self::new(DrawMode::DrawOne, true) - } -} +/// This type is intentionally zero-sized: it does not carry mutable runtime +/// state, and exists only as a namespace for configuration, conversion, and +/// scoring helpers. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct KlondikeAdapter; impl KlondikeAdapter { - /// Create an adapter from the game's draw mode and foundation house-rule setting. - /// - /// `take_from_foundation = true` maps to [`MoveFromFoundationConfig::Allowed`]; - /// `false` maps to [`MoveFromFoundationConfig::Disallowed`]. - pub fn new(draw_mode: DrawMode, take_from_foundation: bool) -> Self { - let config = KlondikeConfig { + /// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting. + pub fn config_for(draw_mode: DrawMode, take_from_foundation: bool) -> KlondikeConfig { + KlondikeConfig { draw_stock: match draw_mode { DrawMode::DrawOne => DrawStockConfig::DrawOne, DrawMode::DrawThree => DrawStockConfig::DrawThree, @@ -67,24 +45,7 @@ impl KlondikeAdapter { MoveFromFoundationConfig::Disallowed }, scoring: ScoringConfig::DEFAULT, - }; - Self { config } - } - - /// Returns a reference to the underlying [`KlondikeConfig`]. - /// - /// Used by the solver and pile-mapping code added in later integration steps. - pub fn klondike_config(&self) -> &KlondikeConfig { - &self.config - } - - /// Update the foundation house-rule flag, keeping [`KlondikeConfig`] in sync. - pub fn set_take_from_foundation(&mut self, allowed: bool) { - self.config.move_from_foundation = if allowed { - MoveFromFoundationConfig::Allowed - } else { - MoveFromFoundationConfig::Disallowed - }; + } } // ── Scoring helpers ─────────────────────────────────────────────────── @@ -96,8 +57,8 @@ impl KlondikeAdapter { /// - Waste → Tableau: +5 /// - Foundation → Tableau: −15 /// - All other moves: 0 - pub fn score_for_move(&self, from: &KlondikePile, to: &KlondikePile) -> i32 { - let sc = &self.config.scoring; + pub fn score_for_move(from: &KlondikePile, to: &KlondikePile) -> i32 { + let sc = ScoringConfig::DEFAULT; match (from, to) { (_, KlondikePile::Foundation(_)) => sc.move_to_foundation, (KlondikePile::Stock, KlondikePile::Tableau(_)) => sc.move_to_tableau, @@ -107,8 +68,8 @@ impl KlondikeAdapter { } /// Score delta for exposing a face-down tableau card: +5. - pub fn score_for_flip(&self) -> i32 { - self.config.scoring.flip_up_bonus + pub fn score_for_flip() -> i32 { + ScoringConfig::DEFAULT.flip_up_bonus } /// Score delta for undo: −15. @@ -131,6 +92,12 @@ impl KlondikeAdapter { /// | Draw-1 | 1 | −100 | /// | Draw-3 | 3 | −20 | /// + /// **Design note:** recycling is *never* blocked — only penalised. + /// This is intentional: Draw-1 can be played indefinitely with the score + /// dropping toward zero after the first free recycle. A hard cap would + /// create unwinnable positions when the solver cannot find a path without + /// additional recycling. Zen mode suppresses the penalty entirely. + /// /// `recycle_count` must be the new total **after** this recycle. pub fn score_for_recycle(recycle_count: u32, is_draw_three: bool) -> i32 { if is_draw_three { @@ -145,20 +112,23 @@ impl KlondikeAdapter { /// Score delta for a card move, accounting for game mode. /// /// Returns 0 in [`GameMode::Zen`] (all scoring suppressed). - pub fn score_for_move_with_mode( - &self, - from: &KlondikePile, - to: &KlondikePile, - mode: GameMode, - ) -> i32 { - if mode == GameMode::Zen { 0 } else { self.score_for_move(from, to) } + pub fn score_for_move_with_mode(from: &KlondikePile, to: &KlondikePile, mode: GameMode) -> i32 { + if mode == GameMode::Zen { + 0 + } else { + Self::score_for_move(from, to) + } } /// Score delta for exposing a face-down card, accounting for game mode. /// /// Returns 0 in [`GameMode::Zen`]. - pub fn score_for_flip_with_mode(&self, mode: GameMode) -> i32 { - if mode == GameMode::Zen { 0 } else { self.score_for_flip() } + pub fn score_for_flip_with_mode(mode: GameMode) -> i32 { + if mode == GameMode::Zen { + 0 + } else { + Self::score_for_flip() + } } /// Compute the new score after an undo, accounting for game mode. @@ -191,13 +161,58 @@ impl KlondikeAdapter { // ── Type-conversion utilities ───────────────────────────────────────────── +/// Convert a zero-based tableau index (0..=6) into [`Tableau`]. +pub fn tableau_from_index(index: usize) -> Option { + match index { + 0 => Some(Tableau::Tableau1), + 1 => Some(Tableau::Tableau2), + 2 => Some(Tableau::Tableau3), + 3 => Some(Tableau::Tableau4), + 4 => Some(Tableau::Tableau5), + 5 => Some(Tableau::Tableau6), + 6 => Some(Tableau::Tableau7), + _ => None, + } +} + +/// Convert a zero-based foundation slot (0..=3) into [`Foundation`]. +pub fn foundation_from_slot(slot: u8) -> Option { + match slot { + 0 => Some(Foundation::Foundation1), + 1 => Some(Foundation::Foundation2), + 2 => Some(Foundation::Foundation3), + 3 => Some(Foundation::Foundation4), + _ => None, + } +} + +/// Convert a tableau skip count (0..=12) into [`SkipCards`]. +pub fn skip_cards_from_count(skip: usize) -> Option { + match skip { + 0 => Some(SkipCards::Skip0), + 1 => Some(SkipCards::Skip1), + 2 => Some(SkipCards::Skip2), + 3 => Some(SkipCards::Skip3), + 4 => Some(SkipCards::Skip4), + 5 => Some(SkipCards::Skip5), + 6 => Some(SkipCards::Skip6), + 7 => Some(SkipCards::Skip7), + 8 => Some(SkipCards::Skip8), + 9 => Some(SkipCards::Skip9), + 10 => Some(SkipCards::Skip10), + 11 => Some(SkipCards::Skip11), + 12 => Some(SkipCards::Skip12), + _ => None, + } +} + /// Convert [`card_game::Suit`] back to our [`crate::card::Suit`]. pub(crate) fn suit_from_kl(suit: KlSuit) -> crate::card::Suit { match suit { - KlSuit::Clubs => crate::card::Suit::Clubs, + KlSuit::Clubs => crate::card::Suit::Clubs, KlSuit::Diamonds => crate::card::Suit::Diamonds, - KlSuit::Hearts => crate::card::Suit::Hearts, - KlSuit::Spades => crate::card::Suit::Spades, + KlSuit::Hearts => crate::card::Suit::Hearts, + KlSuit::Spades => crate::card::Suit::Spades, } } @@ -221,7 +236,12 @@ pub fn card_from_kl(card: &KlCard) -> crate::card::Card { .position(|s| *s == suit) .expect("suit always in SUITS") as u32; let id = suit_index * 13 + (rank.value() as u32 - 1); - crate::card::Card { id, suit, rank, face_up: false } + crate::card::Card { + id, + suit, + rank, + face_up: false, + } } // ── Serde newtypes for KlondikeInstruction (Step 7) ────────────────────────── @@ -343,7 +363,10 @@ impl From for SavedKlondikePile { impl From for SavedTableauStack { fn from(ts: TableauStack) -> Self { - Self { tableau: ts.tableau.into(), skip_cards: ts.skip_cards.into() } + Self { + tableau: ts.tableau.into(), + skip_cards: ts.skip_cards.into(), + } } } @@ -359,13 +382,19 @@ impl From for SavedKlondikePileStack { impl From for SavedDstFoundation { fn from(df: DstFoundation) -> Self { - Self { src: df.src.into(), foundation: df.foundation.into() } + Self { + src: df.src.into(), + foundation: df.foundation.into(), + } } } impl From for SavedDstTableau { fn from(dt: DstTableau) -> Self { - Self { src: dt.src.into(), tableau: dt.tableau.into() } + Self { + src: dt.src.into(), + tableau: dt.tableau.into(), + } } } @@ -384,51 +413,21 @@ impl From for SavedInstruction { impl TryFrom for Tableau { type Error = InvalidSavedInstruction; fn try_from(s: SavedTableau) -> Result { - match s.0 { - 0 => Ok(Tableau::Tableau1), - 1 => Ok(Tableau::Tableau2), - 2 => Ok(Tableau::Tableau3), - 3 => Ok(Tableau::Tableau4), - 4 => Ok(Tableau::Tableau5), - 5 => Ok(Tableau::Tableau6), - 6 => Ok(Tableau::Tableau7), - n => Err(InvalidSavedInstruction::Tableau(n)), - } + tableau_from_index(s.0 as usize).ok_or(InvalidSavedInstruction::Tableau(s.0)) } } impl TryFrom for Foundation { type Error = InvalidSavedInstruction; fn try_from(s: SavedFoundation) -> Result { - match s.0 { - 0 => Ok(Foundation::Foundation1), - 1 => Ok(Foundation::Foundation2), - 2 => Ok(Foundation::Foundation3), - 3 => Ok(Foundation::Foundation4), - n => Err(InvalidSavedInstruction::Foundation(n)), - } + foundation_from_slot(s.0).ok_or(InvalidSavedInstruction::Foundation(s.0)) } } impl TryFrom for SkipCards { type Error = InvalidSavedInstruction; fn try_from(s: SavedSkipCards) -> Result { - match s.0 { - 0 => Ok(SkipCards::Skip0), - 1 => Ok(SkipCards::Skip1), - 2 => Ok(SkipCards::Skip2), - 3 => Ok(SkipCards::Skip3), - 4 => Ok(SkipCards::Skip4), - 5 => Ok(SkipCards::Skip5), - 6 => Ok(SkipCards::Skip6), - 7 => Ok(SkipCards::Skip7), - 8 => Ok(SkipCards::Skip8), - 9 => Ok(SkipCards::Skip9), - 10 => Ok(SkipCards::Skip10), - 11 => Ok(SkipCards::Skip11), - 12 => Ok(SkipCards::Skip12), - n => Err(InvalidSavedInstruction::SkipCards(n)), - } + skip_cards_from_count(s.0 as usize).ok_or(InvalidSavedInstruction::SkipCards(s.0)) } } @@ -459,9 +458,7 @@ impl TryFrom for KlondikePileStack { Ok(match s { SavedKlondikePileStack::Tableau(ts) => KlondikePileStack::Tableau(ts.try_into()?), SavedKlondikePileStack::Stock => KlondikePileStack::Stock, - SavedKlondikePileStack::Foundation(f) => { - KlondikePileStack::Foundation(f.try_into()?) - } + SavedKlondikePileStack::Foundation(f) => KlondikePileStack::Foundation(f.try_into()?), }) } } @@ -469,14 +466,20 @@ impl TryFrom for KlondikePileStack { impl TryFrom for DstFoundation { type Error = InvalidSavedInstruction; fn try_from(s: SavedDstFoundation) -> Result { - Ok(DstFoundation { src: s.src.try_into()?, foundation: s.foundation.try_into()? }) + Ok(DstFoundation { + src: s.src.try_into()?, + foundation: s.foundation.try_into()?, + }) } } impl TryFrom for DstTableau { type Error = InvalidSavedInstruction; fn try_from(s: SavedDstTableau) -> Result { - Ok(DstTableau { src: s.src.try_into()?, tableau: s.tableau.try_into()? }) + Ok(DstTableau { + src: s.src.try_into()?, + tableau: s.tableau.try_into()?, + }) } } diff --git a/solitaire_engine/src/auto_complete_plugin.rs b/solitaire_engine/src/auto_complete_plugin.rs index 7ca7462..3409e03 100644 --- a/solitaire_engine/src/auto_complete_plugin.rs +++ b/solitaire_engine/src/auto_complete_plugin.rs @@ -51,15 +51,15 @@ impl Plugin for AutoCompletePlugin { app.init_resource::() .add_message::() .add_systems( - Update, - ( - detect_auto_complete, - on_auto_complete_start, - drive_auto_complete, - ) - .chain() - .after(GameMutation), - ); + Update, + ( + detect_auto_complete, + on_auto_complete_start, + drive_auto_complete, + ) + .chain() + .after(GameMutation), + ); } } @@ -83,14 +83,21 @@ fn detect_auto_complete( if game.0.is_auto_completable && !state.active { state.active = true; state.cooldown = AUTO_COMPLETE_INITIAL_DELAY; + } else if !game.0.is_auto_completable && state.active { + // `is_auto_completable` only becomes false after an explicit undo + // (which puts a card back on the tableau or re-fills the stock/waste) + // or a new-game reset — never as a transient gap during a normal + // auto-complete sequence. Deactivate here so `drive_auto_complete` + // does not keep retrying indefinitely after the player undoes out of + // the sequence. + // + // Note: the transient-`None` case mentioned in older versions of this + // comment referred to `next_auto_complete_move()` returning `None`, not + // to `is_auto_completable` being false. Those are independent fields; + // `drive_auto_complete` still retries on a transient `None` return from + // `next_auto_complete_move` because that check happens there, not here. + state.active = false; } - // Intentionally no `else if !is_auto_completable` branch here. - // Deactivating on every frame where `is_auto_completable` is false - // would hard-stop the sequence mid-flight whenever `next_auto_complete_move` - // transiently returns `None` (e.g. while the previous move is still - // in-flight). The `is_won` check above already handles the definitive - // end-of-game case; `drive_auto_complete` simply retries next tick - // when no move is available yet. } /// Plays a distinct chime the moment auto-complete first activates. @@ -244,9 +251,7 @@ mod tests { // Zero out the cooldown so drive fires on the next update regardless // of the initial delay constant. - app.world_mut() - .resource_mut::() - .cooldown = 0.0; + app.world_mut().resource_mut::().cooldown = 0.0; app.update(); // drive fires the move let events = app.world().resource::>(); diff --git a/solitaire_engine/src/card_animation/tuning.rs b/solitaire_engine/src/card_animation/tuning.rs index 2390c5e..15fbd4e 100644 --- a/solitaire_engine/src/card_animation/tuning.rs +++ b/solitaire_engine/src/card_animation/tuning.rs @@ -100,7 +100,7 @@ impl AnimationTuning { platform: InputPlatform::Mouse, duration_scale: 1.0, overshoot_scale: 1.0, - drag_threshold_px: 4.0, + drag_threshold_px: 6.0, drag_scale: 1.08, hover_scale: 1.04, hover_lerp_speed: 14.0, diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index 1d2cab1..c08cbc0 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -24,9 +24,9 @@ use bevy::input::touch::{TouchInput, TouchPhase, Touches}; use bevy::math::{Vec2, Vec3}; use bevy::prelude::*; use bevy::window::PrimaryWindow; -use klondike::{Foundation, KlondikePile, Tableau}; #[cfg(not(target_os = "android"))] use bevy::window::{MonitorSelection, WindowMode}; +use klondike::{Foundation, KlondikePile, Tableau}; use solitaire_core::card::{Card, Suit}; use solitaire_core::game_state::GameState; @@ -789,8 +789,9 @@ fn end_drag( continue; }; let target_pos = card_position(&game.0, &layout.0, &origin, stack_index); - if let Some((entity, _, transform)) = - card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id) + if let Some((entity, _, transform)) = card_entities + .iter() + .find(|(_, ce, _)| ce.card_id == card_id) { let drag_pos = transform.translation.truncate(); let drag_z = transform.translation.z; @@ -1027,8 +1028,9 @@ fn touch_end_drag( continue; }; let target_pos = card_position(&game.0, &layout.0, &origin, stack_index); - if let Some((entity, _, transform)) = - card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id) + if let Some((entity, _, transform)) = card_entities + .iter() + .find(|(_, ce, _)| ce.card_id == card_id) { let drag_pos = transform.translation.truncate(); let drag_z = transform.translation.z; @@ -1060,6 +1062,13 @@ fn touch_end_drag( // Helpers // --------------------------------------------------------------------------- +/// Converts the mouse cursor position to world-space 2-D coordinates. +/// +/// **Invariant:** assumes a single un-zoomed 2-D camera whose viewport exactly +/// covers the primary window (centre at world origin, 1 logical pixel = 1 world +/// unit). Hit-testing in `find_draggable_at` / `find_drop_target` relies on +/// this 1:1 mapping. Do not add camera zoom or offset this without auditing +/// every call site of `cursor_world` and `touch_to_world`. fn cursor_world( windows: &Query<&Window, With>, cameras: &Query<(&Camera, &GlobalTransform)>, @@ -1073,6 +1082,9 @@ fn cursor_world( /// Converts a touch screen position (logical pixels, top-left origin) to /// world-space 2-D coordinates using the primary camera. /// +/// Shares the same 1:1 viewport invariant as [`cursor_world`] — see that +/// function's doc for the constraints. +/// /// Returns `None` if no camera is present or the projection fails. fn touch_to_world(cameras: &Query<(&Camera, &GlobalTransform)>, screen_pos: Vec2) -> Option { let (camera, camera_transform) = cameras.single().ok()?; @@ -1097,7 +1109,12 @@ fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool { /// face-up cards by `layout.tableau_fan_frac`. Mirrors `card_plugin::card_positions` /// exactly; any drift creates an offset between the visible card face and /// where clicks land. -fn card_position(game: &GameState, layout: &Layout, pile: &KlondikePile, stack_index: usize) -> Vec2 { +fn card_position( + game: &GameState, + layout: &Layout, + pile: &KlondikePile, + stack_index: usize, +) -> Vec2 { let base = layout.pile_positions[pile]; if matches!(pile, KlondikePile::Tableau(_)) { let mut y_offset = 0.0_f32; @@ -1436,6 +1453,7 @@ fn handle_double_tap( mut touch_selection: Option>, mut moves: MessageWriter, mut rejected: MessageWriter, + mut toast: MessageWriter, mut commands: Commands, mut card_sprites: Query<(Entity, &CardEntity, &mut Sprite)>, ) { @@ -1509,8 +1527,9 @@ fn handle_double_tap( sel.clear(); return; } - // First tap: select the source. + // First tap: select the source, then nudge the player. sel.set(*tapped_pile, drag.cards.clone()); + toast.write(InfoToastEvent("Tap a pile to move".into())); } return; } @@ -1540,8 +1559,12 @@ fn handle_double_tap( if drag.cards.len() > 1 { let stack_index = pile_cards.len() - drag.cards.len(); if let Some(bottom_card) = pile_cards.get(stack_index) - && let Some((dest, count)) = - best_tableau_destination_for_stack(bottom_card, tapped_pile, &game.0, drag.cards.len()) + && let Some((dest, count)) = best_tableau_destination_for_stack( + bottom_card, + tapped_pile, + &game.0, + drag.cards.len(), + ) { for (entity, ce, mut sprite) in card_sprites.iter_mut() { if drag.cards.contains(&ce.card_id) { @@ -1573,9 +1596,7 @@ fn handle_double_tap( // --------------------------------------------------------------------------- /// Build the complete list of legal moves available in `game`, ordered so that -/// foundation moves come first, then tableau-to-tableau moves, with "draw from -/// stock" appended last when the stock is non-empty and nothing else is -/// available. +/// upstream `klondike` priorities are preserved. /// /// Each entry is `(from, to, count)` — the same triple used by /// [`MoveRequestEvent`]. The list may be empty when no move exists at all @@ -1584,6 +1605,23 @@ fn handle_double_tap( /// This is the backing data for the cycling hint system: the H key steps /// through `hints[HintCycleIndex % hints.len()]` on each press. pub fn all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)> { + if game.has_test_pile_overrides() { + return legacy_all_hints(game); + } + + game.possible_instructions() + .into_iter() + .filter(|(_, _, count)| *count == 1) + .collect() +} + +/// Legacy hint enumeration used only when test pile overrides are active. +/// +/// `possible_instructions()` reflects the internal upstream `Session` state. +/// In test fixtures that inject synthetic piles via `set_test_*`, these +/// synthetic piles can diverge from the session state; this fallback preserves +/// deterministic test semantics in those fixtures. +fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)> { let sources: Vec = { let mut s = vec![KlondikePile::Stock]; for tableau in tableaus() { @@ -1818,7 +1856,8 @@ mod tests { // face-up card, but the iterator should skip face-down cards and // the cursor sits above the face-up card's AABB, so the result // is None. - let face_down_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0); + let face_down_pos = + card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0); let result = find_draggable_at(face_down_pos, &game, &layout); assert!(result.is_none(), "face-down cards should not be draggable"); } @@ -1836,7 +1875,8 @@ mod tests { // Tableau 6 starts with 6 face-down + 1 face-up. The face-up card // sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at // base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre. - let face_up_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6); + let face_up_pos = + card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6); let result = find_draggable_at(face_up_pos, &game, &layout) .expect("clicking the face-up card's visible centre must initiate a drag"); assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7)); @@ -1878,7 +1918,8 @@ mod tests { // (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the // Queen we click in her visible strip: the 0.25h band above the Jack's top // edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h. - let queen_center = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1); + let queen_center = + card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1); let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375); let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit"); assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1)); @@ -1923,7 +1964,12 @@ mod tests { let mut game = game; game.set_test_tableau_cards(Tableau::Tableau1, Vec::new()); let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)]; - let target = find_drop_target(pos, &game, &layout, &KlondikePile::Tableau(Tableau::Tableau7)); + let target = find_drop_target( + pos, + &game, + &layout, + &KlondikePile::Tableau(Tableau::Tableau7), + ); assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1))); } @@ -1932,7 +1978,12 @@ mod tests { let game = GameState::new(42, DrawMode::DrawOne); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)]; - let target = find_drop_target(pos, &game, &layout, &KlondikePile::Tableau(Tableau::Tableau4)); + let target = find_drop_target( + pos, + &game, + &layout, + &KlondikePile::Tableau(Tableau::Tableau4), + ); assert_eq!(target, None); } @@ -2012,7 +2063,10 @@ mod tests { fn pile_drop_rect_is_card_sized_for_non_tableau() { let game = GameState::new(42, DrawMode::DrawOne); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - for pile in [KlondikePile::Stock, KlondikePile::Foundation(Foundation::Foundation3)] { + for pile in [ + KlondikePile::Stock, + KlondikePile::Foundation(Foundation::Foundation3), + ] { let (_, size) = pile_drop_rect(&pile, &layout, &game); assert_eq!(size, layout.card_size); } @@ -2022,7 +2076,7 @@ mod tests { // Task #27 — best_destination pure-function tests // ----------------------------------------------------------------------- - #[test] + #[test] fn best_destination_returns_none_when_no_legal_move() { use solitaire_core::card::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawMode::DrawOne); @@ -2044,7 +2098,7 @@ mod tests { // best_tableau_destination_for_stack pure-function tests // ----------------------------------------------------------------------- - #[test] + #[test] fn best_tableau_destination_for_stack_skips_source_pile() { use solitaire_core::card::{Card, Rank, Suit}; let mut game = GameState::new(1, DrawMode::DrawOne); @@ -2070,8 +2124,12 @@ mod tests { rank: Rank::King, face_up: true, }; - let result = - best_tableau_destination_for_stack(&bottom_card, &KlondikePile::Tableau(Tableau::Tableau1), &game, 1); + let result = best_tableau_destination_for_stack( + &bottom_card, + &KlondikePile::Tableau(Tableau::Tableau1), + &game, + 1, + ); // Result must be some other empty tableau column, never the source. if let Some((dest, _)) = result { assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1)); @@ -2103,8 +2161,12 @@ mod tests { rank: Rank::Two, face_up: true, }; - let result = - best_tableau_destination_for_stack(&bottom_card, &KlondikePile::Tableau(Tableau::Tableau1), &game, 1); + let result = best_tableau_destination_for_stack( + &bottom_card, + &KlondikePile::Tableau(Tableau::Tableau1), + &game, + 1, + ); assert!( result.is_none(), "Two of Clubs has no legal tableau destination on empty piles" @@ -2140,7 +2202,7 @@ mod tests { assert_eq!(count, 1); } - // ----------------------------------------------------------------------- + // ----------------------------------------------------------------------- // G key fires ForfeitRequestEvent (modal-based forfeit flow) // ----------------------------------------------------------------------- @@ -2176,11 +2238,11 @@ mod tests { clear_test_piles(&mut game); // Put one card back into the stock so "draw" is a valid suggestion. game.set_test_stock_cards(vec![Card { - id: 1, - suit: Suit::Clubs, - rank: Rank::Ace, - face_up: false, - }]); + id: 1, + suit: Suit::Clubs, + rank: Rank::Ace, + face_up: false, + }]); let hints = all_hints(&game); assert_eq!(hints.len(), 1, "exactly one hint: draw from stock"); @@ -2192,7 +2254,7 @@ mod tests { /// `all_hints` must be empty when both stock and waste are empty and no /// pile-to-pile move exists — the game is truly stuck. - // ----------------------------------------------------------------------- + // ----------------------------------------------------------------------- // Drag-rejection return tween — `CardAnimation` replaces the legacy // `ShakeAnim` on the dragged cards. The audio cue // (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the diff --git a/solitaire_engine/src/radial_menu.rs b/solitaire_engine/src/radial_menu.rs index fa04fa1..46b8a97 100644 --- a/solitaire_engine/src/radial_menu.rs +++ b/solitaire_engine/src/radial_menu.rs @@ -329,7 +329,12 @@ pub fn find_top_face_up_card_at( /// Mirror of `input_plugin::card_position` — kept private to this /// module so the radial's hit-test geometry tracks renderer geometry /// without depending on `input_plugin` internals. -fn card_position(game: &GameState, layout: &Layout, pile: &KlondikePile, stack_index: usize) -> Vec2 { +fn card_position( + game: &GameState, + layout: &Layout, + pile: &KlondikePile, + stack_index: usize, +) -> Vec2 { let base = layout.pile_positions[pile]; if matches!(pile, KlondikePile::Tableau(_)) { let mut y_offset = 0.0_f32; @@ -376,16 +381,27 @@ const fn tableaus() -> [Tableau; 7] { } /// Builds the `(destination, anchor)` list for a fresh radial open. -fn build_radial_destinations(centre: Vec2, dests: Vec) -> Vec<(KlondikePile, Vec2)> { +/// +/// `half_extents` is the window half-size in world space — icons are clamped +/// so that their edges stay within the viewport, preventing them from appearing +/// off-screen on small or narrow devices. +fn build_radial_destinations( + centre: Vec2, + dests: Vec, + half_extents: Vec2, +) -> Vec<(KlondikePile, Vec2)> { let count = dests.len(); + let margin = RADIAL_ICON_SIZE_PX / 2.0; dests .into_iter() .enumerate() .map(|(i, d)| { - ( - d, - radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX), - ) + let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX); + let clamped = Vec2::new( + raw.x.clamp(-half_extents.x + margin, half_extents.x - margin), + raw.y.clamp(-half_extents.y + margin, half_extents.y - margin), + ); + (d, clamped) }) .collect() } @@ -472,7 +488,12 @@ fn radial_open_on_right_click( }); return; } - let legal_destinations = build_radial_destinations(world, dests); + let half_extents = windows + .single() + .ok() + .map(|w| Vec2::new(w.width() / 2.0, w.height() / 2.0)) + .unwrap_or(Vec2::splat(f32::MAX)); + let legal_destinations = build_radial_destinations(world, dests, half_extents); *state = RightClickRadialState::Active { source_pile, @@ -498,6 +519,7 @@ fn radial_open_on_long_press( drag: Res, paused: Option>, touches: Option>, + windows: Query<&Window, With>, cameras: Query<(&Camera, &GlobalTransform)>, layout: Option>, game: Option>, @@ -540,7 +562,12 @@ fn radial_open_on_long_press( if dests.is_empty() { return; } - let legal_destinations = build_radial_destinations(world, dests); + let half_extents = windows + .single() + .ok() + .map(|w| Vec2::new(w.width() / 2.0, w.height() / 2.0)) + .unwrap_or(Vec2::splat(f32::MAX)); + let legal_destinations = build_radial_destinations(world, dests, half_extents); *state = RightClickRadialState::Active { source_pile, count: 1, @@ -958,7 +985,8 @@ mod tests { rank: Rank::Ace, face_up: true, }; - let dests = legal_destinations_for_card(&card, &KlondikePile::Tableau(Tableau::Tableau1), &g); + let dests = + legal_destinations_for_card(&card, &KlondikePile::Tableau(Tableau::Tableau1), &g); // Ace can be placed on every empty foundation. We only need // the count to be ≥ 1 and the source pile to be excluded. assert!( @@ -977,7 +1005,11 @@ mod tests { rank: Rank::Ace, face_up: true, }; - let dests = legal_destinations_for_card(&card, &KlondikePile::Foundation(Foundation::Foundation1), &g); + let dests = legal_destinations_for_card( + &card, + &KlondikePile::Foundation(Foundation::Foundation1), + &g, + ); assert!(!dests.contains(&KlondikePile::Foundation(Foundation::Foundation1))); } @@ -988,7 +1020,7 @@ mod tests { /// Pressing right-click on a face-up card with at least one legal /// destination must transition the state to `Active` carrying the /// expected source / count / legal-destination set. - /// Releasing the right button while the cursor is over a destination + /// Releasing the right button while the cursor is over a destination /// icon must fire a `MoveRequestEvent` and return the state to Idle. #[test] fn right_click_release_over_destination_fires_move_request() { diff --git a/solitaire_engine/src/safe_area.rs b/solitaire_engine/src/safe_area.rs index f15948a..1fc4e16 100644 --- a/solitaire_engine/src/safe_area.rs +++ b/solitaire_engine/src/safe_area.rs @@ -253,24 +253,24 @@ mod android { } } - /// Resets the inset poller and clears cached insets on - /// `AppLifecycle::WillResume` so that `refresh_insets` re-queries JNI in the - /// frames immediately after the app returns to the foreground. + /// Resets the inset poller on `AppLifecycle::WillResume` so that + /// `refresh_insets` re-queries JNI in the frames immediately after the app + /// returns to the foreground. /// - /// Clearing `SafeAreaInsets` to the default (all-zero) fires - /// `on_safe_area_changed` in `table_plugin`, which emits a synthetic - /// `WindowResized`. `on_window_resized` then recomputes the layout; - /// once `refresh_insets` resolves the real values a second synthetic - /// `WindowResized` fires and the layout converges to the correct position. + /// The cached `SafeAreaInsets` are intentionally **not** zeroed here. + /// Zeroing them would cause two layout recomputes on every resume: + /// once with zero insets (wrong position) and again when JNI resolves the + /// real values — visible as a flash. By preserving the last-known values + /// the layout remains stable; if JNI returns a different value (e.g. after + /// a rotation) the single update that fires when `SafeAreaInsets` actually + /// changes is enough. pub(super) fn rearm_on_resumed( mut lifecycle: MessageReader, mut poll: ResMut, - mut insets: ResMut, ) { for event in lifecycle.read() { if matches!(event, AppLifecycle::WillResume) { poll.0 = 0; - *insets = SafeAreaInsets::default(); } } } diff --git a/solitaire_engine/src/touch_selection_plugin.rs b/solitaire_engine/src/touch_selection_plugin.rs index e31a268..904d2e3 100644 --- a/solitaire_engine/src/touch_selection_plugin.rs +++ b/solitaire_engine/src/touch_selection_plugin.rs @@ -77,8 +77,9 @@ impl TouchSelectionState { /// Marker component placed on the highlight sprite child of a selected source card. /// -/// Despawned and respawned each frame by [`update_touch_selection_highlight`] so -/// stale highlights never linger after a game-state change. +/// Despawned and respawned by [`update_touch_selection_highlight`] whenever +/// [`TouchSelectionState`] changes. The system is gated on `is_changed()` so it +/// is a no-op every frame that the selection is stable. #[derive(Component)] pub struct TouchSelectionHighlight; @@ -91,16 +92,15 @@ pub struct TouchSelectionPlugin; impl Plugin for TouchSelectionPlugin { fn build(&self, app: &mut App) { - app.init_resource::() - .add_systems( - Update, - ( - clear_touch_selection_on_state_change, - update_touch_selection_highlight, - ) - .chain() - .after(GameMutation), - ); + app.init_resource::().add_systems( + Update, + ( + clear_touch_selection_on_state_change, + update_touch_selection_highlight, + ) + .chain() + .after(GameMutation), + ); } } @@ -121,9 +121,9 @@ pub(crate) fn clear_touch_selection_on_state_change( /// Maintains the `TouchSelectionHighlight` outline sprite on the selected source card. /// -/// All existing `TouchSelectionHighlight` entities are despawned each frame and -/// a new one is spawned on the top card of the selected pile (if any). This -/// matches the pattern used by `selection_plugin::update_selection_highlight`. +/// Rebuilds the highlight set only when [`TouchSelectionState`] or the layout +/// actually changes — not every frame. Existing highlights are despawned first, +/// then a fresh highlight is spawned on every card in the selected stack. pub(crate) fn update_touch_selection_highlight( mut commands: Commands, selection: Res, @@ -131,6 +131,12 @@ pub(crate) fn update_touch_selection_highlight( highlights: Query>, layout: Option>, ) { + // Skip when neither the selection nor the layout changed this frame. + let layout_changed = layout.as_ref().map(|l| l.is_changed()).unwrap_or(false); + if !selection.is_changed() && !layout_changed { + return; + } + // Despawn stale highlights first. for entity in &highlights { commands.entity(entity).despawn(); diff --git a/solitaire_server/web/game.js b/solitaire_server/web/game.js index 1fe50fd..f8978f9 100644 --- a/solitaire_server/web/game.js +++ b/solitaire_server/web/game.js @@ -176,9 +176,12 @@ async function bootstrap() { if (saved) { showResumeDialog(saved); } else { - const params = new URLSearchParams(window.location.search); - const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed(); - drawThree = params.has("draw3"); + const params = new URLSearchParams(window.location.search); + const rawSeed = Number(params.get("seed")); + const urlSeed = params.has("seed") && Number.isFinite(rawSeed) && rawSeed > 0 + ? Math.floor(rawSeed) + : randomSeed(); + drawThree = params.has("draw3"); chkDraw3.checked = drawThree; startGame(urlSeed); } @@ -393,8 +396,16 @@ function render(s) { stopTimer(); if (acTimer) { clearInterval(acTimer); acTimer = null; } if (noMovesBanner) noMovesBanner.classList.add("hidden"); - showWin(s); + // Delay slightly so the last card's CSS transition finishes before + // the win overlay covers the board. Card transitions are ~260 ms. + setTimeout(() => showWin(s), 320); } else { + // If the player undid out of auto-complete, restart the timer — + // stopTimer() was called when auto-complete began, but no code path + // before here restarts it after an undo. + if (!s.is_auto_completable && !timerInterval) { + startTimer(); + } saveState(); const noMoves = !s.has_moves && !s.is_auto_completable; if (noMovesBanner) noMovesBanner.classList.toggle("hidden", !noMoves); @@ -429,20 +440,34 @@ function showWin(s) { submitReplay(s); } -async function submitReplay(s) { - const token = localStorage.getItem('fs_token'); - if (!token) return; - const payload = { - schema_version: 1, +function buildReplayPayload(s) { + if (!game || !s) return null; + let moves; + try { + moves = game.replay_moves(); + if (!Array.isArray(moves) || moves.length === 0) return null; + } catch (e) { + console.warn("fs: replay export failed", e); + return null; + } + return { + schema_version: 2, seed: Math.round(game.seed()), draw_mode: drawThree ? "DrawThree" : "DrawOne", mode: "Classic", - time_seconds: elapsedSecs, + time_seconds: Math.max(1, elapsedSecs), final_score: s.score, - move_count: s.move_count, recorded_at: new Date().toISOString().slice(0, 10), - moves: [], + moves, + win_move_index: moves.length - 1, }; +} + +async function submitReplay(s) { + const token = localStorage.getItem('fs_token'); + if (!token || !game) return; + const payload = buildReplayPayload(s); + if (!payload) return; try { await fetch('/api/replays', { method: 'POST', @@ -467,7 +492,12 @@ function flashIllegal(cardIds) { for (const id of cardIds) { const el = cardEls.get(id); if (!el) continue; - // Store current translate so the shake keyframe can reference it. + // Remove any in-progress shake before restarting. Reading offsetWidth + // forces a synchronous layout flush so the browser sees the removal + // before we re-add the class, restarting the animation from frame 0. + el.classList.remove("illegal"); + el.style.removeProperty("--card-tx"); + void el.offsetWidth; // flush layout — do not remove el.style.setProperty("--card-tx", el.style.transform || "translate(0,0)"); el.classList.add("illegal"); el.addEventListener("animationend", () => { @@ -496,11 +526,34 @@ function attachHandlers() { syncThemeButton(); if (game) render(game.state()); }); + const doDraw = () => { const r = game.draw(); if (r.ok) render(r.snapshot); }; document.addEventListener("keydown", (e) => { - if (e.target.tagName === "INPUT") return; - if (e.key === "z" || e.key === "Z") doUndo(); - if (e.key === "n" || e.key === "N") startGame(randomSeed()); + const tag = e.target?.tagName; + if (e.target?.isContentEditable || tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; + if (e.key === "z" || e.key === "Z" || e.key === "u" || e.key === "U") { + e.preventDefault(); + doUndo(); + return; + } + if (e.key === "n" || e.key === "N") { + startGame(randomSeed()); + return; + } + if (!e.repeat && (e.code === "Space" || e.key === " ")) { + e.preventDefault(); + doDraw(); + } + }); + + // Pause the game timer while the tab is hidden so background time doesn't + // inflate the player's recorded game duration. + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + stopTimer(); + } else if (snap && !snap.is_won && !snap.is_auto_completable) { + startTimer(); + } }); board.addEventListener("pointerdown", onPointerDown); @@ -706,7 +759,7 @@ function onPointerCancel() { // ── Click / dblclick ────────────────────────────────────────────────────────── function onBoardClick(e) { - if (drag) return; + if (drag || snap?.is_won) return; const { x: bx, y: by } = boardRelative(e.clientX, e.clientY); const stock = PILE_ORIGIN.stock; if (bx >= stock.x && bx <= stock.x + CARD_W && by >= stock.y && by <= stock.y + CARD_H) { @@ -741,7 +794,7 @@ function smartMove(pileName, fromIndex) { } function onBoardDblClick(e) { - if (drag) return; + if (drag || snap?.is_won) return; const { x: bx, y: by } = boardRelative(e.clientX, e.clientY); const hit = hitTestCard(bx, by); if (!hit || !hit.card.face_up) return; @@ -782,6 +835,191 @@ async function loadAvatar() { } catch { /* not signed in — avatar stays hidden */ } } +function debugStateKey(state) { + if (!state) return "missing"; + if (Array.isArray(state.stock) || Array.isArray(state.tableaus)) { + const out = []; + const push = cards => { + for (const c of cards || []) out.push(`${c.id}:${c.face_up ? 1 : 0}`); + out.push("|"); + }; + push(state.stock); + push(state.waste); + for (const pile of state.foundations || []) push(pile); + for (const pile of state.tableaus || []) push(pile); + return out.join(""); + } + return JSON.stringify(state); +} + +function orderBaselineDebugMoves(legalMoves) { + const foundationSingles = []; + const moveKind = []; + const rest = []; + for (let i = 0; i < legalMoves.length; i++) { + const move = legalMoves[i]; + if ( + move?.kind === "move" && + typeof move.to === "string" && + move.to.startsWith("foundation-") && + move.count === 1 + ) { + foundationSingles.push(i); + } else if (move?.kind === "move") { + moveKind.push(i); + } else { + rest.push(i); + } + } + return [...foundationSingles, ...moveKind, ...rest]; +} + +function runDebugAutoplay(options = {}) { + if (!game) return { ok: false, reason: "game_not_ready", step: 0 }; + + const maxSteps = Number.isInteger(options.maxSteps) && options.maxSteps > 0 ? options.maxSteps : 220; + const maxVisitsPerState = + Number.isInteger(options.maxVisitsPerState) && options.maxVisitsPerState > 0 + ? options.maxVisitsPerState + : 2; + const policy = options.policy === "baseline" ? "baseline" : "loop_aware"; + const seen = new Map(); + + function simulatedVisitCount(legalMoveIndex) { + let saved = null; + try { + saved = game.serialize(); + } catch { + return null; + } + if (typeof saved !== "string" || saved.length === 0) return null; + + const applied = game.debug_apply_legal_move(legalMoveIndex); + if (!applied?.ok) { + try { game = SolitaireGame.from_saved(saved); } catch {} + return null; + } + const nextKey = debugStateKey(applied.snapshot); + try { + game = SolitaireGame.from_saved(saved); + } catch { + return null; + } + return seen.get(nextKey) || 0; + } + + for (let step = 0; step < maxSteps; step++) { + const snap = game.debug_snapshot(); + if (!snap?.state || !snap?.invariants) { + return { ok: false, reason: "missing_snapshot", step }; + } + if (!snap.invariants.state_ok) { + return { ok: false, reason: "invariant_failed", step, snapshot: snap }; + } + if (snap.state.is_won) { + return { ok: true, terminal: "won", step, snapshot: snap }; + } + + const key = debugStateKey(snap.state); + const visits = (seen.get(key) || 0) + 1; + seen.set(key, visits); + if (visits > maxVisitsPerState) { + return { ok: true, terminal: "cycle", step, snapshot: snap }; + } + + const legalMoves = game.debug_legal_moves(); + if (!Array.isArray(legalMoves) || legalMoves.length === 0) { + return { ok: true, terminal: "no_moves", step, snapshot: snap }; + } + + const ordered = orderBaselineDebugMoves(legalMoves); + let idx = ordered[0]; + if (policy === "loop_aware" && ordered.length > 1) { + let bestIdx = ordered[0]; + let bestVisitCount = Number.MAX_SAFE_INTEGER; + for (const candidate of ordered) { + const visitCount = simulatedVisitCount(candidate); + if (visitCount === null) continue; + if (visitCount < bestVisitCount) { + bestVisitCount = visitCount; + bestIdx = candidate; + if (visitCount === 0) break; + } + } + idx = bestIdx; + } + + const result = game.debug_apply_legal_move(idx); + if (!result?.ok) { + return { + ok: false, + reason: "apply_failed", + step, + idx, + error: result?.error ?? "unknown_error", + }; + } + if (result.snapshot) render(result.snapshot); + } + + const finalSnap = game.debug_snapshot(); + return { ok: !!finalSnap?.invariants?.state_ok, terminal: "step_budget", snapshot: finalSnap }; +} + +// ── Debug API (engine-first automation surface) ─────────────────────────────── +// Playwright and other automation harnesses use this object instead of pixel +// analysis or hardcoded coordinates. Every operation delegates to the Rust +// rules engine exported by `solitaire_wasm`. +window.__FERROUS_DEBUG__ = { + seed() { + return game ? Math.round(game.seed()) : null; + }, + state() { + return game ? game.state() : null; + }, + legalMoves() { + return game ? game.debug_legal_moves() : []; + }, + moveHistory() { + return game ? game.debug_move_history() : []; + }, + snapshot() { + return game ? game.debug_snapshot() : null; + }, + applyLegalMove(index) { + if (!game) return { ok: false, error: "game_not_ready" }; + const result = game.debug_apply_legal_move(index); + if (result?.ok && result.snapshot) render(result.snapshot); + return result; + }, + applyMove(move) { + if (!game) return { ok: false, error: "game_not_ready" }; + const payload = typeof move === "string" ? move : JSON.stringify(move); + const result = game.debug_apply_move_json(payload); + if (result?.ok && result.snapshot) render(result.snapshot); + return result; + }, + failureReport() { + if (!game) return null; + const debug = game.debug_snapshot(); + return { + seed: Math.round(game.seed()), + moveHistory: debug?.move_history ?? [], + currentState: debug?.state ?? game.state(), + stateJson: debug?.state_json ?? null, + legalMoves: debug?.legal_moves ?? [], + invariants: debug?.invariants ?? null, + }; + }, + replayPayload() { + if (!game) return null; + return buildReplayPayload(snap ?? game.state()); + }, + runAutoplay(options) { + return runDebugAutoplay(options); + }, +}; + // ── Start ───────────────────────────────────────────────────────────────────── bootstrap().catch(console.error); loadAvatar();