diff --git a/.gitignore b/.gitignore index 51d6747..1cf071f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,11 @@ agentdb.rvf.lock # IDE project files .idea/ +# Browser e2e harness artifacts +solitaire_server/e2e/node_modules/ +solitaire_server/e2e/playwright-report/ +solitaire_server/e2e/test-results/ + # Android signing keystores — never commit *.jks *.jks.bak diff --git a/README.md b/README.md index 40db664..ba40680 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,28 @@ cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_se # Lint cargo clippy --workspace --all-targets -- -D warnings + +# Browser e2e smoke (starts solitaire_server automatically) +cd solitaire_server/e2e +npm ci +npx playwright install chromium +npm test + +# Seed-batch cycle regression gate (thresholded) +npm run review:cycles:regression + +# Loop-aware candidate benchmark (writes test-results/cycle-candidate.json) +npm run review:cycles:candidate ``` +For layered engine-vs-UI automation design (Rust unit tests, wasm debug-API +integration tests, and Playwright UI validation), see +[docs/testing-architecture.md](docs/testing-architecture.md). + +For Quaternions (`klondike` / `card_game`) dependency upgrades, use +[`scripts/update_quaternions_deps.sh`](scripts/update_quaternions_deps.sh) and +the runbook in [docs/card-game-integration.md](docs/card-game-integration.md). + ## Credits Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem diff --git a/docs/card-game-integration.md b/docs/card-game-integration.md index 6ad1f38..82d81d7 100644 --- a/docs/card-game-integration.md +++ b/docs/card-game-integration.md @@ -101,6 +101,11 @@ Our 767-line `solitaire_core::solver` reimplements the full game rules to run th ### 5. JSON Serialisation / Persistence `solitaire_core::GameState` serialises the full mid-game state to JSON via `serde` so the engine can save on exit and restore on launch. `KlondikeState` derives `Clone` + `Eq` + `Hash` but not `Serialize` / `Deserialize`. No upstream changes are needed — this is handled externally. +**Current verification (2026-06-01):** `klondike v0.3.0` and `card_game v0.4.0` +crate manifests expose no `serde` dependency/feature, and source exports no +serde derives for instruction/state snapshot types. Keep Ferrous' +`SavedInstruction` bridge in place. + **Session history:** `StateSnapshot` stores the pre-move game state and instruction. On load, the session is reconstructed from the serialised snapshot history — no full replay from seed needed. **In our wrapper:** Serialise the `solitaire_core` wrapper struct using newtypes. Define `SavedInstruction` (a `Serialize + Deserialize` mirror of `KlondikeInstruction`) and `SavedStateSnapshot`. Reconstruct `SessionState` from the deserialised history. Schema version field lives on our wrapper. @@ -147,6 +152,29 @@ Steps in dependency order. Upstream issues #10, #11, and the solver are all merg --- +## Quaternions Upgrade Runbook + +Use this sequence whenever upgrading `klondike` / `card_game` from the +Quaternions registry: + +1. Review upstream changes/releases: + - + - +2. Run: + ```bash + scripts/update_quaternions_deps.sh + ``` +3. If the script passes, inspect the resulting `Cargo.lock` diff and land the + upgrade with the normal PR flow. + +The script enforces: +- lockfile update to requested versions +- `cargo test --workspace` +- `cargo clippy --workspace -- -D warnings` +- deterministic replay/debug-API smoke tests in `solitaire_wasm` + +--- + ## What Does NOT Need to Change - The `solitaire_engine` Bevy layer — it works against `solitaire_core` types; changes are isolated to `solitaire_core`. diff --git a/solitaire_engine/Cargo.toml b/solitaire_engine/Cargo.toml index 757456b..6f6c29b 100644 --- a/solitaire_engine/Cargo.toml +++ b/solitaire_engine/Cargo.toml @@ -52,3 +52,4 @@ web-sys = { version = "0.3", features = ["Storage", "Window"] } [dev-dependencies] async-trait = { workspace = true } tempfile = { workspace = true } +solitaire_core = { workspace = true, features = ["test-support"] } diff --git a/solitaire_engine/src/audio_plugin.rs b/solitaire_engine/src/audio_plugin.rs index 23f51ac..ff8a18c 100644 --- a/solitaire_engine/src/audio_plugin.rs +++ b/solitaire_engine/src/audio_plugin.rs @@ -373,9 +373,7 @@ fn play_on_draw( // When the stock pile is empty the draw action recycles the waste pile // back to stock. Play the flip sound at half volume to give audible // feedback that distinguishes a recycle from a normal draw. - let stock_len = game - .as_ref() - .map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound + let stock_len = game.as_ref().map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound if is_recycle(stock_len) { let mut data = lib.flip.clone(); diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index fd1dd2c..effd899 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -16,10 +16,9 @@ use bevy::color::Color; use bevy::prelude::*; use bevy::sprite::Anchor; use bevy::window::WindowResized; +use klondike::{Foundation, KlondikePile, Tableau}; use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::game_state::{DrawMode, GameState}; -use klondike::{Foundation, KlondikePile, Tableau}; - use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration}; use crate::card_animation::CardAnimation; @@ -2355,16 +2354,16 @@ fn update_tableau_fan_frac( Tableau::Tableau6, Tableau::Tableau7, ] - .into_iter() - .map(|tableau| { - game.0 - .pile(klondike::KlondikePile::Tableau(tableau)) - .into_iter() - .filter(|c| c.face_up) - .count() - }) - .max() - .unwrap_or(0); + .into_iter() + .map(|tableau| { + game.0 + .pile(klondike::KlondikePile::Tableau(tableau)) + .into_iter() + .filter(|c| c.face_up) + .count() + }) + .max() + .unwrap_or(0); let card_h = layout.0.card_size.y; let avail = layout.0.available_tableau_height; @@ -2575,8 +2574,7 @@ mod tests { "need at least 3 waste cards for this test" ); - let waste_ids: std::collections::HashSet = - waste_pile.iter().map(|c| c.id).collect(); + let waste_ids: std::collections::HashSet = waste_pile.iter().map(|c| c.id).collect(); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); @@ -2628,8 +2626,7 @@ mod tests { let count = waste_pile.len(); assert!(count >= 2, "need at least 2 waste cards"); - let waste_ids: std::collections::HashSet = - waste_pile.iter().map(|c| c.id).collect(); + let waste_ids: std::collections::HashSet = waste_pile.iter().map(|c| c.id).collect(); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); diff --git a/solitaire_engine/src/cursor_plugin.rs b/solitaire_engine/src/cursor_plugin.rs index c96bb3e..2435806 100644 --- a/solitaire_engine/src/cursor_plugin.rs +++ b/solitaire_engine/src/cursor_plugin.rs @@ -331,7 +331,11 @@ fn update_drop_target_overlays( /// for everything else it is card-sized. Replicated here rather than /// imported because `pile_drop_rect` is private to `input_plugin` and /// this overlay is the only other consumer. -fn drop_overlay_rect(pile: &KlondikePile, layout: &Layout, game: &GameState) -> Option<(Vec2, Vec2)> { +fn drop_overlay_rect( + pile: &KlondikePile, + layout: &Layout, + game: &GameState, +) -> Option<(Vec2, Vec2)> { let centre = layout.pile_positions.get(pile).copied()?; if matches!(pile, KlondikePile::Tableau(_)) { let card_count = game.pile(*pile).len(); @@ -619,7 +623,7 @@ mod tests { drag.committed = true; } - #[test] + #[test] fn drop_target_overlay_does_not_spawn_for_invalid_destination() { // 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black) // — same colour family, illegal. Tableau(2) must NOT be @@ -658,5 +662,4 @@ mod tests { "Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}" ); } - - } +} diff --git a/solitaire_engine/src/daily_challenge_plugin.rs b/solitaire_engine/src/daily_challenge_plugin.rs index 75b77ee..0398af4 100644 --- a/solitaire_engine/src/daily_challenge_plugin.rs +++ b/solitaire_engine/src/daily_challenge_plugin.rs @@ -13,10 +13,10 @@ use bevy::input::ButtonInput; use bevy::prelude::*; -use chrono::{DateTime, Duration, Local, NaiveDate, Utc}; -use solitaire_data::{daily_seed_for, save_progress_to}; #[cfg(not(target_arch = "wasm32"))] use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; +use chrono::{DateTime, Duration, Local, NaiveDate, Utc}; +use solitaire_data::{daily_seed_for, save_progress_to}; #[cfg(not(target_arch = "wasm32"))] use solitaire_sync::ChallengeGoal; @@ -354,7 +354,6 @@ fn check_date_rollover( } } - #[cfg(test)] #[allow(dead_code)] mod tests { diff --git a/solitaire_engine/src/feedback_anim_plugin.rs b/solitaire_engine/src/feedback_anim_plugin.rs index 27719aa..fc19a15 100644 --- a/solitaire_engine/src/feedback_anim_plugin.rs +++ b/solitaire_engine/src/feedback_anim_plugin.rs @@ -849,8 +849,8 @@ mod tests { #[test] fn shake_anim_skipped_under_reduce_motion() { use bevy::ecs::message::Messages; - use solitaire_core::game_state::{DrawMode, GameState}; use klondike::Tableau; + use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_data::Settings; let mut app = App::new(); diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index 691d702..85c34c2 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -13,8 +13,8 @@ use chrono::Utc; use bevy::prelude::*; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::window::AppLifecycle; -use solitaire_core::game_state::{DrawMode, GameMode, GameState}; use klondike::KlondikePile; +use solitaire_core::game_state::{DrawMode, GameMode, GameState}; use solitaire_core::solver::{SolverConfig, SolverResult, try_solve}; #[allow(deprecated)] use solitaire_data::latest_replay_path; @@ -521,10 +521,7 @@ fn handle_new_game( // hides that information and reads naturally as "dealt from the // deck." Skipped when LayoutResource isn't present (headless tests). if let Some(layout) = layout.as_ref() - && let Some(stock) = layout - .0 - .pile_positions - .get(&klondike::KlondikePile::Stock) + && let Some(stock) = layout.0.pile_positions.get(&klondike::KlondikePile::Stock) { for mut tx in &mut card_transforms { tx.translation.x = stock.x; @@ -1047,17 +1044,11 @@ fn foundation_slot(foundation: klondike::Foundation) -> Option { /// previous heuristic incorrectly did (Quat hit this with 4 cards /// remaining and the game just sat there). pub fn has_legal_moves(game: &GameState) -> bool { - - // Drawing from a non-empty stock, and recycling a non-empty waste back to // stock, are always legal moves in standard Klondike (unlimited recycles). // A game can only be genuinely stuck when both stock AND waste are exhausted. - let stock_empty = game - .stock_cards() - .is_empty(); - let waste_empty = game - .waste_cards() - .is_empty(); + let stock_empty = game.stock_cards().is_empty(); + let waste_empty = game.waste_cards().is_empty(); if !stock_empty || !waste_empty { return true; } @@ -1191,7 +1182,10 @@ fn handle_game_over_input( if keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape) { // confirmed: true — the game is already stuck; no abandon-confirmation needed. - new_game.write(NewGameRequestEvent { confirmed: true, ..default() }); + new_game.write(NewGameRequestEvent { + confirmed: true, + ..default() + }); } else if keys.just_pressed(KeyCode::KeyU) { for entity in &screens { commands.entity(entity).despawn(); @@ -1219,7 +1213,10 @@ fn handle_game_over_button_input( } if new_game_buttons.iter().any(|i| *i == Interaction::Pressed) { // confirmed: true — the game is already stuck; no abandon-confirmation needed. - new_game.write(NewGameRequestEvent { confirmed: true, ..default() }); + new_game.write(NewGameRequestEvent { + confirmed: true, + ..default() + }); } else if undo_buttons.iter().any(|i| *i == Interaction::Pressed) { for entity in &screens { commands.entity(entity).despawn(); @@ -1388,9 +1385,11 @@ mod tests { #[test] fn new_game_request_reseeds() { let mut app = test_app(1); - let before: Vec = app.world().resource::().0.pile(KlondikePile::Tableau( - Tableau::Tableau1, - )) + let before: Vec = app + .world() + .resource::() + .0 + .pile(KlondikePile::Tableau(Tableau::Tableau1)) .iter() .map(|c| c.id) .collect(); @@ -1402,9 +1401,11 @@ mod tests { }); app.update(); - let after: Vec = app.world().resource::().0.pile(KlondikePile::Tableau( - Tableau::Tableau1, - )) + let after: Vec = app + .world() + .resource::() + .0 + .pile(KlondikePile::Tableau(Tableau::Tableau1)) .iter() .map(|c| c.id) .collect(); @@ -1415,17 +1416,25 @@ mod tests { fn settings_changed_updates_take_from_foundation_flag() { let mut app = test_app(1); assert!( - app.world().resource::().0.take_from_foundation, + app.world() + .resource::() + .0 + .take_from_foundation, "fresh game should inherit default take_from_foundation=true", ); let mut settings = solitaire_data::Settings::default(); settings.take_from_foundation = false; app.world_mut() - .write_message(crate::settings_plugin::SettingsChangedEvent(settings.clone())); + .write_message(crate::settings_plugin::SettingsChangedEvent( + settings.clone(), + )); app.update(); assert!( - !app.world().resource::().0.take_from_foundation, + !app.world() + .resource::() + .0 + .take_from_foundation, "settings event must forward take_from_foundation=false into live game state", ); @@ -1434,7 +1443,10 @@ mod tests { .write_message(crate::settings_plugin::SettingsChangedEvent(settings)); app.update(); assert!( - app.world().resource::().0.take_from_foundation, + app.world() + .resource::() + .0 + .take_from_foundation, "settings event must forward take_from_foundation=true into live game state", ); } @@ -1557,7 +1569,7 @@ mod tests { ); } - /// auto_save_game_state writes to disk once the accumulator crosses 30 s. + /// auto_save_game_state writes to disk once the accumulator crosses 30 s. /// /// The timer is pre-seeded just past the threshold and the test /// re-arms it before each `app.update()` in a small bounded loop: @@ -1634,20 +1646,23 @@ mod tests { // Build a tableau with two face-up cards. { let mut gs = app.world_mut().resource_mut::(); - gs.0.set_test_tableau_cards(Tableau::Tableau1, vec![ - Card { - id: 910, - suit: Suit::Clubs, - rank: Rank::King, - face_up: true, - }, - Card { - id: 911, - suit: Suit::Hearts, - rank: Rank::Queen, - face_up: true, - }, - ]); + gs.0.set_test_tableau_cards( + Tableau::Tableau1, + vec![ + Card { + id: 910, + suit: Suit::Clubs, + rank: Rank::King, + face_up: true, + }, + Card { + id: 911, + suit: Suit::Hearts, + rank: Rank::Queen, + face_up: true, + }, + ], + ); gs.0.set_test_tableau_cards( Tableau::Tableau2, vec![Card { @@ -1782,7 +1797,7 @@ mod tests { ); } - #[test] + #[test] fn has_legal_moves_detects_non_top_face_up_card_as_source() { // Regression: the bug only checked t.cards.last() (top face-up card). // If the only legal move involves a face-up card that is NOT the top @@ -1936,16 +1951,16 @@ mod tests { ); } - /// Verify that the game-over overlay contains the expected header text and + /// Verify that the game-over overlay contains the expected header text and /// action-hint strings so players understand why the overlay appeared and /// what keys to press. - // ----------------------------------------------------------------------- + // ----------------------------------------------------------------------- // Task #56 — Escape dismisses GameOverScreen and starts new game // ----------------------------------------------------------------------- /// Pressing Escape while `GameOverScreen` is visible must fire /// `NewGameRequestEvent` — identical behaviour to pressing N. - // ----------------------------------------------------------------------- + // ----------------------------------------------------------------------- // Task #48 — Undo with empty stack fires InfoToastEvent // ----------------------------------------------------------------------- @@ -1988,7 +2003,7 @@ mod tests { /// When a King lands on a foundation that already holds Ace through /// Queen, exactly one `FoundationCompletedEvent` must fire and carry /// the matching slot + suit. - /// Moving a card to a tableau pile must never produce a + /// Moving a card to a tableau pile must never produce a /// `FoundationCompletedEvent`, even if the source tableau happened /// to have been a King. #[test] @@ -2051,7 +2066,7 @@ mod tests { /// At 12 cards on a foundation (Ace–Jack on the pile, Queen in /// flight), the event must NOT fire — the flourish is only for the /// final 13th completion. - /// A successful undo must NOT fire an `InfoToastEvent`. + /// A successful undo must NOT fire an `InfoToastEvent`. #[test] fn undo_after_draw_does_not_fire_info_toast() { let mut app = test_app(42); @@ -2086,7 +2101,7 @@ mod tests { /// Drive a fresh game through a draw + a tableau→foundation move, /// then assert the recording resource captured both, in order, with /// the correct shape. - /// Invalid moves must not appear in the recording — the recording is + /// Invalid moves must not appear in the recording — the recording is /// "what successfully happened", not "what was requested". #[test] fn replay_does_not_record_rejected_moves() { @@ -2359,7 +2374,10 @@ mod tests { Tableau::Tableau7, ] { assert_eq!( - app.world().resource::().0.pile(KlondikePile::Tableau(tableau)), + app.world() + .resource::() + .0 + .pile(KlondikePile::Tableau(tableau)), expected.pile(KlondikePile::Tableau(tableau)), "tableau column {tableau:?} must match the unfiltered seed", ); diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index ad91040..976f774 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -2404,7 +2404,10 @@ fn update_selection_hud( /// When the slot has a claimed suit (any card has landed) the announcement is /// "▶ {Suit} Foundation"; while the slot is empty it falls back to a /// "▶ Foundation N" placeholder labelled by the 1-based slot index. -fn foundation_selection_label(slot: Foundation, game: &solitaire_core::game_state::GameState) -> String { +fn foundation_selection_label( + slot: Foundation, + game: &solitaire_core::game_state::GameState, +) -> String { let claimed = game .pile(KlondikePile::Foundation(slot)) .first() diff --git a/solitaire_engine/src/layout.rs b/solitaire_engine/src/layout.rs index ff3de96..f14a5d4 100644 --- a/solitaire_engine/src/layout.rs +++ b/solitaire_engine/src/layout.rs @@ -269,7 +269,10 @@ pub fn compute_layout( 5 => Tableau::Tableau6, _ => Tableau::Tableau7, }; - pile_positions.insert(KlondikePile::Tableau(tableau), Vec2::new(col_x(i), tableau_y)); + pile_positions.insert( + KlondikePile::Tableau(tableau), + Vec2::new(col_x(i), tableau_y), + ); } // Adaptive tableau fan fraction. On height-limited (desktop) windows the @@ -339,7 +342,9 @@ mod tests { Tableau::Tableau7, ] { assert!( - layout.pile_positions.contains_key(&KlondikePile::Tableau(tableau)), + layout + .pile_positions + .contains_key(&KlondikePile::Tableau(tableau)), "missing tableau {tableau:?}" ); } @@ -758,7 +763,11 @@ mod tests { let window = Vec2::new(360.0, 800.0); let without = compute_layout(window, 0.0, 0.0, true); let with_inset = compute_layout(window, 0.0, 48.0, true); - for pile in [KlondikePile::Stock, KlondikePile::Tableau(Tableau::Tableau1), KlondikePile::Tableau(Tableau::Tableau7)] { + for pile in [ + KlondikePile::Stock, + KlondikePile::Tableau(Tableau::Tableau1), + KlondikePile::Tableau(Tableau::Tableau7), + ] { assert!( (without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3, "{pile:?} x-position must not change with safe_area_bottom", diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index baa0178..753cce5 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -153,7 +153,6 @@ pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin}; pub use selection_plugin::{ KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState, }; -pub use touch_selection_plugin::{TouchSelectionPlugin, TouchSelectionState}; pub use settings_plugin::{ PendingWindowGeometry, SFX_STEP, SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS, @@ -179,6 +178,7 @@ pub use theme::{ pub use time_attack_plugin::{ TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, }; +pub use touch_selection_plugin::{TouchSelectionPlugin, TouchSelectionState}; pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin}; pub use ui_modal::{ ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard, ModalHeader, ModalScrim, diff --git a/solitaire_engine/src/pending_hint.rs b/solitaire_engine/src/pending_hint.rs index a7c571e..1926f88 100644 --- a/solitaire_engine/src/pending_hint.rs +++ b/solitaire_engine/src/pending_hint.rs @@ -26,8 +26,8 @@ use bevy::prelude::*; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; -use solitaire_core::game_state::GameState; use klondike::KlondikePile; +use solitaire_core::game_state::GameState; use solitaire_core::solver::{SolverConfig, SolverResult, try_solve_from_state}; use crate::card_plugin::CardEntity; @@ -101,7 +101,10 @@ struct HintTask { enum HintTaskOutput { /// Solver verdict was `Winnable`; here is the first move on the /// solution path. - SolverMove { from: KlondikePile, to: KlondikePile }, + SolverMove { + from: KlondikePile, + to: KlondikePile, + }, /// Solver was `Unwinnable` or `Inconclusive`. The poll system /// runs the legacy heuristic against the live `GameState` so the /// H key always produces feedback while any legal move exists. diff --git a/solitaire_engine/src/replay_overlay/format.rs b/solitaire_engine/src/replay_overlay/format.rs index d5fc753..be8fae3 100644 --- a/solitaire_engine/src/replay_overlay/format.rs +++ b/solitaire_engine/src/replay_overlay/format.rs @@ -97,7 +97,11 @@ pub(crate) fn format_move_body(m: &ReplayMove) -> String { match m { ReplayMove::StockClick => "stock cycle".to_string(), ReplayMove::Move { from, to, .. } => { - format!("{} \u{2192} {}", format_saved_pile(from), format_saved_pile(to)) + format!( + "{} \u{2192} {}", + format_saved_pile(from), + format_saved_pile(to) + ) } } } diff --git a/solitaire_engine/src/replay_overlay/mod.rs b/solitaire_engine/src/replay_overlay/mod.rs index 96c8903..aaa3fc0 100644 --- a/solitaire_engine/src/replay_overlay/mod.rs +++ b/solitaire_engine/src/replay_overlay/mod.rs @@ -25,15 +25,14 @@ mod format; mod input; -mod update; #[cfg(test)] mod tests; +mod update; pub(crate) use self::format::*; pub(crate) use self::input::*; pub(crate) use self::update::*; -use bevy::prelude::*; use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent}; use crate::font_plugin::FontResource; use crate::platform::SHOW_KEYBOARD_ACCELERATORS; @@ -44,6 +43,7 @@ use crate::ui_theme::{ STATE_SUCCESS, STATE_SUCCESS_HC, TEXT_PRIMARY, TEXT_PRIMARY_HC, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY, }; +use bevy::prelude::*; // --------------------------------------------------------------------------- // Z-index — see `ui_theme::Z_MODAL_SCRIM` (200) for the next layer above. @@ -316,7 +316,6 @@ pub struct ReplayOverlayScrubNotch; #[derive(Component, Debug)] pub struct ReplayOverlayScrubNotchLabel; - /// Marker on the keybind-hint footer row at the bottom edge of the /// banner. Carries two `Text` children: a vim-style mode indicator /// (`▌ NORMAL │ replay`) on the left and the keybind hint @@ -1270,4 +1269,3 @@ fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option { let frac = (idx as f32 / total as f32).clamp(0.0, 1.0); Some(frac * 100.0) } - diff --git a/solitaire_engine/src/replay_overlay/tests.rs b/solitaire_engine/src/replay_overlay/tests.rs index 5853750..23ba5f6 100644 --- a/solitaire_engine/src/replay_overlay/tests.rs +++ b/solitaire_engine/src/replay_overlay/tests.rs @@ -854,8 +854,7 @@ fn scrub_notch_labels_carry_helper_strings() { let mut texts = scrub_notch_label_texts(&mut app); texts.sort(); - let mut expected: Vec = - scrub_notch_labels().iter().map(|s| s.to_string()).collect(); + let mut expected: Vec = scrub_notch_labels().iter().map(|s| s.to_string()).collect(); expected.sort(); assert_eq!( texts, expected, @@ -1106,10 +1105,22 @@ fn move_log_active_row_text(app: &mut App) -> String { #[test] fn format_pile_uses_one_indexed_lowercase_names() { assert_eq!(format_pile(&KlondikePile::Stock), "waste"); - assert_eq!(format_pile(&KlondikePile::Foundation(Foundation::Foundation1)), "foundation 1"); - assert_eq!(format_pile(&KlondikePile::Foundation(Foundation::Foundation3)), "foundation 3"); - assert_eq!(format_pile(&KlondikePile::Tableau(Tableau::Tableau1)), "tableau 1"); - assert_eq!(format_pile(&KlondikePile::Tableau(Tableau::Tableau7)), "tableau 7"); + assert_eq!( + format_pile(&KlondikePile::Foundation(Foundation::Foundation1)), + "foundation 1" + ); + assert_eq!( + format_pile(&KlondikePile::Foundation(Foundation::Foundation3)), + "foundation 3" + ); + assert_eq!( + format_pile(&KlondikePile::Tableau(Tableau::Tableau1)), + "tableau 1" + ); + assert_eq!( + format_pile(&KlondikePile::Tableau(Tableau::Tableau7)), + "tableau 7" + ); } /// Move-body formatter renders `StockClick` as a label and diff --git a/solitaire_engine/src/replay_overlay/update.rs b/solitaire_engine/src/replay_overlay/update.rs index 27bc180..97de662 100644 --- a/solitaire_engine/src/replay_overlay/update.rs +++ b/solitaire_engine/src/replay_overlay/update.rs @@ -1,10 +1,10 @@ use bevy::prelude::*; -use super::*; use super::format::{ - format_active_move_row, format_foundations_row, format_kth_next_row, - format_kth_recent_row, format_move_log_header, format_progress, format_stock_waste_row, + format_active_move_row, format_foundations_row, format_kth_next_row, format_kth_recent_row, + format_move_log_header, format_progress, format_stock_waste_row, }; +use super::*; use crate::layout::LayoutResource; use crate::replay_playback::ReplayPlaybackState; use crate::resources::GameStateResource; diff --git a/solitaire_engine/src/replay_playback.rs b/solitaire_engine/src/replay_playback.rs index 5c99bf1..f80be35 100644 --- a/solitaire_engine/src/replay_playback.rs +++ b/solitaire_engine/src/replay_playback.rs @@ -268,11 +268,12 @@ pub fn step_replay_playback( } match &replay.moves[*cursor] { ReplayMove::Move { from, to, count } => { - let (Ok(from), Ok(to)) = ( - KlondikePile::try_from(*from), - KlondikePile::try_from(*to), - ) else { - warn!("skipping replay move with invalid pile encoding at cursor {}", *cursor); + let (Ok(from), Ok(to)) = (KlondikePile::try_from(*from), KlondikePile::try_from(*to)) + else { + warn!( + "skipping replay move with invalid pile encoding at cursor {}", + *cursor + ); *cursor += 1; return false; }; @@ -379,10 +380,9 @@ fn tick_replay_playback( while *secs_to_next <= 0.0 && *cursor < replay.moves.len() { match &replay.moves[*cursor] { ReplayMove::Move { from, to, count } => { - if let (Ok(from), Ok(to)) = ( - KlondikePile::try_from(*from), - KlondikePile::try_from(*to), - ) { + if let (Ok(from), Ok(to)) = + (KlondikePile::try_from(*from), KlondikePile::try_from(*to)) + { moves_writer.write(MoveRequestEvent { from, to, diff --git a/solitaire_engine/src/resources.rs b/solitaire_engine/src/resources.rs index 1f98f6d..c74fd35 100644 --- a/solitaire_engine/src/resources.rs +++ b/solitaire_engine/src/resources.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use bevy::math::Vec2; use bevy::prelude::Resource; use chrono::{DateTime, Utc}; -use solitaire_core::game_state::GameState; use klondike::KlondikePile; +use solitaire_core::game_state::GameState; /// Wraps the currently active `GameState`. Single source of truth for the in-progress game. #[derive(Resource, Debug, Clone)] diff --git a/solitaire_engine/src/selection_plugin.rs b/solitaire_engine/src/selection_plugin.rs index c65f3c1..6a8e361 100644 --- a/solitaire_engine/src/selection_plugin.rs +++ b/solitaire_engine/src/selection_plugin.rs @@ -202,7 +202,10 @@ fn cycled_piles() -> Vec { /// /// If `current` is `None` the first available pile is returned. /// If `available` is empty, `None` is returned. -pub fn cycle_next_pile(available: &[KlondikePile], current: Option<&KlondikePile>) -> Option { +pub fn cycle_next_pile( + available: &[KlondikePile], + current: Option<&KlondikePile>, +) -> Option { if available.is_empty() { return None; } @@ -235,7 +238,11 @@ pub fn cycle_next_pile(available: &[KlondikePile], current: Option<&KlondikePile /// /// Both `current` and `next` must be `Some`; if either is `None` this returns /// `false`. -fn did_wrap(available: &[KlondikePile], current: Option<&KlondikePile>, next: Option<&KlondikePile>) -> bool { +fn did_wrap( + available: &[KlondikePile], + current: Option<&KlondikePile>, + next: Option<&KlondikePile>, +) -> bool { let (Some(cur), Some(nxt)) = (current, next) else { return false; }; @@ -386,9 +393,7 @@ fn handle_selection_keys( KlondikePile::Tableau(Tableau::Tableau7), ]; all.into_iter() - .filter(|p| { - pile_cards(&game.0, p).last().is_some_and(|c| c.face_up) - }) + .filter(|p| pile_cards(&game.0, p).last().is_some_and(|c| c.face_up)) .collect() }; @@ -717,10 +722,7 @@ fn update_selection_highlight( /// Returns the top face-up card on `pile`, or `None` if the pile is /// empty or its top card is face-down. -fn top_face_up_card( - pile: &KlondikePile, - game: &GameState, -) -> Option { +fn top_face_up_card(pile: &KlondikePile, game: &GameState) -> Option { pile_cards(game, pile).last().filter(|c| c.face_up).cloned() } @@ -1162,14 +1164,17 @@ mod tests { // DragState must mirror the lifted cards and carry the keyboard sentinel. let drag = app.world().resource::(); assert_eq!(drag.cards, vec![100]); - assert_eq!(drag.origin_pile, Some(KlondikePile::Tableau(Tableau::Tableau1))); + assert_eq!( + drag.origin_pile, + Some(KlondikePile::Tableau(Tableau::Tableau1)) + ); assert_eq!(drag.active_touch_id, Some(KEYBOARD_DRAG_TOUCH_ID)); } /// Test 3 — Arrow keys in `Lifted` cycle through *legal* destinations /// only (foundations and tableaus that pass `can_place_on_*`), and /// wrap at the end of the list. - /// Test 4 — Enter while `Lifted` with a destination focused fires + /// Test 4 — Enter while `Lifted` with a destination focused fires /// exactly one `MoveRequestEvent` and resets the state machine to /// `Idle` with `DragState` cleared. #[test] diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index 428093f..3e2453e 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -33,9 +33,9 @@ use crate::events::{ use crate::font_plugin::FontResource; use crate::progress_plugin::ProgressResource; use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; -use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair}; #[cfg(not(target_arch = "wasm32"))] use crate::theme::{ImportError, import_theme, refresh_registry}; +use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair}; use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton}; use crate::ui_modal::{ ButtonVariant, ModalButton, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button, diff --git a/solitaire_engine/src/theme/registry.rs b/solitaire_engine/src/theme/registry.rs index c032fb1..5e1ce74 100644 --- a/solitaire_engine/src/theme/registry.rs +++ b/solitaire_engine/src/theme/registry.rs @@ -22,9 +22,9 @@ use std::path::Path; use bevy::log::warn; -use bevy::prelude::{App, Plugin, Resource}; #[cfg(not(target_arch = "wasm32"))] use bevy::prelude::Startup; +use bevy::prelude::{App, Plugin, Resource}; use serde::Deserialize; use super::ThemeMeta; diff --git a/solitaire_server/src/lib.rs b/solitaire_server/src/lib.rs index 12b2b93..e52d87f 100644 --- a/solitaire_server/src/lib.rs +++ b/solitaire_server/src/lib.rs @@ -249,11 +249,11 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router { const CSP: &str = concat!( "default-src 'self'; ", - "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; ", + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://analytics.aleshym.co; ", "style-src 'self' 'unsafe-inline'; ", "font-src 'self'; ", - "img-src 'self' data:; ", - "connect-src 'self'; ", + "img-src 'self' data: https://analytics.aleshym.co; ", + "connect-src 'self' https://analytics.aleshym.co; ", "object-src 'none'; ", "frame-ancestors 'none'", ); diff --git a/solitaire_server/src/sync.rs b/solitaire_server/src/sync.rs index 3734eb3..0761d36 100644 --- a/solitaire_server/src/sync.rs +++ b/solitaire_server/src/sync.rs @@ -196,14 +196,11 @@ async fn update_leaderboard_if_opted_in( user_id: &str, payload: &SyncPayload, ) -> Result<(), AppError> { - let opted_in = sqlx::query!( - "SELECT leaderboard_opt_in FROM users WHERE id = ?", - user_id - ) - .fetch_optional(pool) - .await? - .map(|r| r.leaderboard_opt_in) - .unwrap_or(0); + let opted_in = sqlx::query!("SELECT leaderboard_opt_in FROM users WHERE id = ?", user_id) + .fetch_optional(pool) + .await? + .map(|r| r.leaderboard_opt_in) + .unwrap_or(0); if opted_in != 1 { return Ok(());