diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 0593e8e..25f1e13 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -31,6 +31,13 @@ jobs: test: runs-on: ubuntu-latest + # Full debuginfo made the solitaire_engine test-binary link peak past the + # runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486. + # line-tables-only keeps file:line in panic backtraces while cutting the + # link's memory footprint enough to fit the runner. + env: + CARGO_PROFILE_DEV_DEBUG: line-tables-only + steps: - name: Checkout uses: actions/checkout@v4 @@ -39,7 +46,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: toolchain: 1.95.0 - components: clippy + components: clippy, rustfmt - name: Cache cargo build uses: Swatinem/rust-cache@v2 @@ -53,6 +60,9 @@ jobs: libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \ libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev + - name: Format check + run: cargo fmt --check + # SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB), # same as the web-e2e workflow's server prebuild. - name: Clippy (deny warnings) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9c274c5..ee2dfca 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -592,11 +592,15 @@ pub struct Session { /* replayable instruction log + derived stats */ } // From `klondike` (upstream — never edit): pub enum KlondikePile { - Stock, - Waste, + Stock, // NB: no Waste variant — see below Foundation(Foundation), // 4 slots, any suit may claim any slot Tableau(Tableau), // 7 columns } +// Pile-coordinate convention: upstream has no `Waste` variant. In +// pile-coordinate space `KlondikePile::Stock` denotes the face-up +// *waste* pile (the only stock-side pile cards move out of); use +// `GameState::stock_cards()` / `waste_cards()` when the face-down +// draw stack must be distinguished. Documented on `GameState::pile`. pub enum DrawStockConfig { DrawOne, DrawThree } pub enum KlondikeInstruction { /* RotateStock, DstFoundation, ... — the serialized move format (schema v4+) */ } diff --git a/solitaire_core/src/game_state.rs b/solitaire_core/src/game_state.rs index 99a6369..fbcce7c 100644 --- a/solitaire_core/src/game_state.rs +++ b/solitaire_core/src/game_state.rs @@ -439,10 +439,7 @@ impl GameState { self.session.history().len() } - fn cards_with_face( - cards: impl IntoIterator, - face_up: bool, - ) -> Vec<(Card, bool)> { + fn cards_with_face(cards: impl IntoIterator, face_up: bool) -> Vec<(Card, bool)> { cards.into_iter().map(|card| (card, face_up)).collect() } @@ -507,8 +504,10 @@ impl GameState { Self::cards_with_face(cards.iter().cloned(), true) } KlondikePile::Tableau(tableau) => { - let mut cards = - Self::cards_with_face(state.tableau_face_down_cards(tableau).iter().cloned(), false); + let mut cards = Self::cards_with_face( + state.tableau_face_down_cards(tableau).iter().cloned(), + false, + ); cards.extend(Self::cards_with_face( state.tableau_face_up_cards(tableau).iter().cloned(), true, @@ -823,9 +822,7 @@ impl GameState { ) -> Option<(KlondikePile, KlondikePile, usize)> { let state = self.session.state().state().state(); match instruction { - KlondikeInstruction::RotateStock => { - Some((KlondikePile::Stock, KlondikePile::Stock, 1)) - } + KlondikeInstruction::RotateStock => Some((KlondikePile::Stock, KlondikePile::Stock, 1)), KlondikeInstruction::DstFoundation(dst_foundation) => { if matches!(dst_foundation.src, KlondikePile::Foundation(_)) { return None; @@ -928,10 +925,7 @@ impl GameState { /// /// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the /// current position, or [`MoveError::GameAlreadyWon`] once the game is over. - pub fn apply_instruction( - &mut self, - instruction: KlondikeInstruction, - ) -> Result<(), MoveError> { + pub fn apply_instruction(&mut self, instruction: KlondikeInstruction) -> Result<(), MoveError> { if self.is_won() { return Err(MoveError::GameAlreadyWon); } @@ -1296,12 +1290,13 @@ mod tests { game.take_from_foundation = false; assert!(!game.can_move_cards(&from, &to, 1)); - assert!( - legal_pile_moves(&game) - .iter() - .all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_)) - || !matches!(t, KlondikePile::Tableau(_))) - ); + assert!(legal_pile_moves(&game).iter().all(|(f, t, _)| !matches!( + f, + KlondikePile::Foundation(_) + ) || !matches!( + t, + KlondikePile::Tableau(_) + ))); assert!(game.move_cards(from, to, 1).is_err()); } @@ -1360,9 +1355,18 @@ mod tests { fn budget_is_passed_through_not_clamped() { // This seed is Inconclusive at 1k states but Winnable at 5k — proving the // budget reaches the solver unchanged. - let easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 1_000, 1_000); - let medium = - GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 5_000, 5_000); + let easy = GameState::solve_fresh_deal( + 0xD1FF_0000_0000_0012, + DrawStockConfig::DrawOne, + 1_000, + 1_000, + ); + let medium = GameState::solve_fresh_deal( + 0xD1FF_0000_0000_0012, + DrawStockConfig::DrawOne, + 5_000, + 5_000, + ); assert!(easy.is_err()); assert!(matches!(medium, Ok(Some(_)))); } diff --git a/solitaire_core/src/lib.rs b/solitaire_core/src/lib.rs index 89fe4e8..9e8ff74 100644 --- a/solitaire_core/src/lib.rs +++ b/solitaire_core/src/lib.rs @@ -13,7 +13,9 @@ pub mod scoring; // when decoding instructions to piles in `instruction_to_piles`) and do not // appear in any public method signature. pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit}; -pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau}; +pub use klondike::{ + DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau, +}; // Solvability check API (delegates to `card_game::Session::solve`); replaces the // former `solitaire_data::solver` wrapper module. diff --git a/solitaire_core/src/proptest_tests.rs b/solitaire_core/src/proptest_tests.rs index 03417a5..b7be613 100644 --- a/solitaire_core/src/proptest_tests.rs +++ b/solitaire_core/src/proptest_tests.rs @@ -41,13 +41,20 @@ fn all_cards(game: &GameState) -> Vec { ); } for t in &tableaux { - cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone())); + cards.extend( + game.pile(KlondikePile::Tableau(*t)) + .iter() + .map(|(c, _)| c.clone()), + ); } cards } fn draw_mode_strategy() -> impl Strategy { - prop_oneof![Just(DrawStockConfig::DrawOne), Just(DrawStockConfig::DrawThree)] + prop_oneof![ + Just(DrawStockConfig::DrawOne), + Just(DrawStockConfig::DrawThree) + ] } /// Apply a sequence of random actions to a game, silently ignoring errors. diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 8e8b63b..c670712 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -161,8 +161,10 @@ pub struct Settings { /// Identifier of the active card-art theme. Matches `meta.id` from /// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are /// always present; user-supplied themes register under their own ids. - /// Older `settings.json` files that stored `"default"` or `"classic"` - /// are migrated to `"dark"` by [`Settings::sanitized`]. + /// Older `settings.json` files that stored `"default"` (the + /// pre-rename id of the dark theme) are migrated to `"dark"` by + /// [`Settings::sanitized`]; `"classic"` is a valid player choice + /// and is never rewritten. #[serde(default = "default_theme_id")] pub selected_theme_id: String, /// Set to `true` once the achievement-onboarding info-toast has been diff --git a/solitaire_data/src/storage.rs b/solitaire_data/src/storage.rs index d734eda..4562cc6 100644 --- a/solitaire_data/src/storage.rs +++ b/solitaire_data/src/storage.rs @@ -553,8 +553,8 @@ mod tests { "saved file must use schema version 5", ); - let loaded = load_game_state_from(&path) - .expect("a valid in-progress game must load without error"); + let loaded = + load_game_state_from(&path).expect("a valid in-progress game must load without error"); // The forward instruction history round-trips, so the reconstructed board // re-serialises to byte-identical JSON. @@ -569,7 +569,11 @@ mod tests { // Derived board reads match the live game (move count + recycle count are // both rebuilt from the replayed forward history). - assert_eq!(loaded.move_count(), gs.move_count(), "move_count round-trips"); + assert_eq!( + loaded.move_count(), + gs.move_count(), + "move_count round-trips" + ); assert_eq!( loaded.recycle_count(), gs.recycle_count(), diff --git a/solitaire_data/src/sync_client.rs b/solitaire_data/src/sync_client.rs index d2a5122..a3cbd80 100644 --- a/solitaire_data/src/sync_client.rs +++ b/solitaire_data/src/sync_client.rs @@ -77,6 +77,11 @@ pub struct SolitaireServerClient { username: String, /// Shared `reqwest` client (keeps connection pools alive across calls). client: reqwest::Client, + /// Serialises token refreshes. The server rotates refresh tokens and each + /// is single-use, so two overlapping 401-retries must not both spend one: + /// the loser's (already-consumed) token would be rejected and surface as + /// a spurious "session expired" to the player. See [`Self::refresh_token`]. + refresh_lock: tokio::sync::Mutex<()>, } #[cfg(not(target_arch = "wasm32"))] @@ -90,6 +95,7 @@ impl SolitaireServerClient { base_url: base_url.into().trim_end_matches('/').to_owned(), username: username.into(), client: reqwest::Client::new(), + refresh_lock: tokio::sync::Mutex::new(()), } } @@ -172,7 +178,27 @@ impl SolitaireServerClient { /// The server rotates refresh tokens on each call: the response includes a /// new refresh token that replaces the old one. Both tokens are persisted /// to the OS keychain on success. - async fn refresh_token(&self) -> Result<(), SyncError> { + /// + /// `stale_access` is the access token that just earned the caller a 401. + /// Refreshes are serialised behind [`Self::refresh_lock`]: with single-use + /// rotated refresh tokens, two overlapping 401-retries (e.g. a replay + /// upload racing a manual sync) must not both call `/api/auth/refresh` — + /// the second call would present an already-consumed token, get rejected, + /// and force a re-login for no user-visible reason. Whoever loses the lock + /// race checks whether the stored access token has already moved past + /// `stale_access`; if so the refresh already happened and there is nothing + /// left to do. + async fn refresh_token(&self, stale_access: &str) -> Result<(), SyncError> { + let _guard = self.refresh_lock.lock().await; + + if let Ok(current) = load_access_token(&self.username) + && current != stale_access + { + // Another task refreshed while we waited on the lock; retry with + // the token it stored rather than spending the new refresh token. + return Ok(()); + } + let old_refresh = load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?; @@ -231,7 +257,7 @@ impl SyncProvider for SolitaireServerClient { if resp.status() == reqwest::StatusCode::UNAUTHORIZED { // Token expired — refresh and retry once. - self.refresh_token().await?; + self.refresh_token(&token).await?; let new_token = self.access_token()?; let resp = self .client @@ -264,7 +290,7 @@ impl SyncProvider for SolitaireServerClient { if resp.status() == reqwest::StatusCode::UNAUTHORIZED { // Token expired — refresh and retry once. - self.refresh_token().await?; + self.refresh_token(&token).await?; let new_token = self.access_token()?; let resp = self .client @@ -331,7 +357,7 @@ impl SyncProvider for SolitaireServerClient { .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - self.refresh_token().await?; + self.refresh_token(&token).await?; let new_token = self.access_token()?; let resp = self .client @@ -366,7 +392,7 @@ impl SyncProvider for SolitaireServerClient { .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - self.refresh_token().await?; + self.refresh_token(&token).await?; let new_token = self.access_token()?; let resp = self .client @@ -406,7 +432,7 @@ impl SyncProvider for SolitaireServerClient { .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - self.refresh_token().await?; + self.refresh_token(&token).await?; let new_token = self.access_token()?; let resp = self .client @@ -446,7 +472,7 @@ impl SyncProvider for SolitaireServerClient { .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - self.refresh_token().await?; + self.refresh_token(&token).await?; let new_token = self.access_token()?; let resp = self .client @@ -480,7 +506,7 @@ impl SyncProvider for SolitaireServerClient { .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - self.refresh_token().await?; + self.refresh_token(&token).await?; let new_token = self.access_token()?; let resp = self .client @@ -542,7 +568,7 @@ impl SolitaireServerClient { .map_err(|e| SyncError::Network(e.to_string()))?; if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - self.refresh_token().await?; + self.refresh_token(&token).await?; let new_token = self.access_token()?; let resp = self .client diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs index 00151ed..0c4f819 100644 --- a/solitaire_engine/src/achievement_plugin.rs +++ b/solitaire_engine/src/achievement_plugin.rs @@ -911,8 +911,7 @@ mod tests { // Put the active game in Zen mode. evaluate_on_win reads // GameStateResource.mode directly to populate last_win_is_zen. - app.world_mut().resource_mut::().0.mode = - GameMode::Zen; + app.world_mut().resource_mut::().0.mode = GameMode::Zen; app.world_mut().write_message(GameWonEvent { score: 0, diff --git a/solitaire_engine/src/assets/user_dir.rs b/solitaire_engine/src/assets/user_dir.rs index 020116a..4db9637 100644 --- a/solitaire_engine/src/assets/user_dir.rs +++ b/solitaire_engine/src/assets/user_dir.rs @@ -53,12 +53,12 @@ pub fn set_user_theme_dir(path: PathBuf) -> Result<(), PathBuf> { /// Returns the absolute path of the user-themes directory on the /// current platform. /// -/// # Panics -/// -/// Panics if [`solitaire_data::data_dir`] returns `None`, which on -/// desktop indicates a broken `$HOME` / `$XDG_*` configuration. -/// Android always returns `Some`. The panic message names the -/// supported workaround ([`set_user_theme_dir`]). +/// When [`solitaire_data::data_dir`] returns `None` (broken `$HOME` / +/// `$XDG_*` on desktop; always on wasm32, which has no filesystem) this +/// returns an empty path — callers treat that as "no user themes" and +/// the bundled default theme still works. A warning naming the +/// [`set_user_theme_dir`] workaround is logged once. Android always +/// resolves. pub fn user_theme_dir() -> PathBuf { if let Some(p) = USER_THEME_DIR_OVERRIDE.get() { return p.clone(); @@ -76,29 +76,32 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf { /// Per-target-os resolution of the platform's data dir. Delegates /// to [`solitaire_data::data_dir`] which encapsulates the /// per-target shape (desktop: `dirs::data_dir()`; android: the -/// hardcoded `/data/data//files` sandbox path). Panics -/// only when the underlying resolver returns `None`, which on -/// desktop indicates a broken `$HOME` / `$XDG_*` configuration — -/// the panic message names the supported workaround. +/// hardcoded `/data/data//files` sandbox path). +/// +/// When the resolver returns `None` — always on wasm32 (no +/// filesystem), or a broken `$HOME` / `$XDG_*` configuration on +/// desktop — this degrades to an empty path, which downstream theme +/// scanning treats as "no user themes"; the bundled default theme is +/// unaffected. CLAUDE.md §2.3 forbids panicking here: losing custom +/// themes must not take the whole game down with it. fn detected_platform_data_dir() -> PathBuf { solitaire_data::data_dir().unwrap_or_else(|| { - // On wasm32, data_dir() always returns None — there is no filesystem. - // User themes are not supported in the browser build; return an empty - // path so callers produce a benign empty dir rather than panicking. - #[cfg(target_arch = "wasm32")] - { - PathBuf::new() - } #[cfg(not(target_arch = "wasm32"))] { - panic!( - "user_theme_dir(): platform data directory is unavailable. \ - On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \ - the OS reported no Application Support / AppData path. \ - As a workaround call solitaire_engine::assets::user_dir::\ - set_user_theme_dir() before App::run()." - ) + use std::sync::Once; + static WARN_ONCE: Once = Once::new(); + WARN_ONCE.call_once(|| { + bevy::log::warn!( + "user_theme_dir(): platform data directory is unavailable; \ + user themes are disabled. On Linux check $XDG_DATA_HOME or \ + $HOME; on macOS / Windows the OS reported no Application \ + Support / AppData path. As a workaround call \ + solitaire_engine::assets::user_dir::set_user_theme_dir() \ + before App::run()." + ); + }); } + PathBuf::new() }) } diff --git a/solitaire_engine/src/auto_complete_plugin.rs b/solitaire_engine/src/auto_complete_plugin.rs index 033b77d..304adbd 100644 --- a/solitaire_engine/src/auto_complete_plugin.rs +++ b/solitaire_engine/src/auto_complete_plugin.rs @@ -174,9 +174,9 @@ mod tests { use super::*; use crate::game_plugin::GamePlugin; use crate::table_plugin::TablePlugin; - use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::{Deck, Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::GameState}; + use solitaire_core::{Foundation, KlondikePile, Tableau}; fn headless_app() -> App { let mut app = App::new(); @@ -214,7 +214,11 @@ mod tests { } g.set_test_tableau_cards( Tableau::Tableau1, - vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)], + vec![solitaire_core::Card::new( + Deck::Deck1, + Suit::Clubs, + Rank::Ace, + )], ); g.set_test_auto_completable(true); let expected = ( diff --git a/solitaire_engine/src/card_animation/interaction.rs b/solitaire_engine/src/card_animation/interaction.rs index 1827758..04b02ea 100644 --- a/solitaire_engine/src/card_animation/interaction.rs +++ b/solitaire_engine/src/card_animation/interaction.rs @@ -72,9 +72,7 @@ pub struct HoverState { /// Describes a user action that arrived while cards were still animating. #[derive(Debug, Clone)] pub enum BufferedInput { - Move { - from: MoveRequestEvent, - }, + Move { from: MoveRequestEvent }, Draw, Undo, } diff --git a/solitaire_engine/src/card_plugin/anim.rs b/solitaire_engine/src/card_plugin/anim.rs index ea7c073..2d58b58 100644 --- a/solitaire_engine/src/card_plugin/anim.rs +++ b/solitaire_engine/src/card_plugin/anim.rs @@ -10,9 +10,7 @@ use crate::animation_plugin::EffectiveSlideDuration; use crate::events::{CardFaceRevealedEvent, CardFlippedEvent}; use crate::layout::LayoutResource; use crate::resources::DragState; -use crate::ui_theme::{ - CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z, -}; +use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z}; /// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity. /// @@ -197,4 +195,3 @@ pub(super) fn update_card_shadows_on_drag( // --------------------------------------------------------------------------- // Task #28 — Hint highlight tick system // --------------------------------------------------------------------------- - diff --git a/solitaire_engine/src/card_plugin/highlights.rs b/solitaire_engine/src/card_plugin/highlights.rs index 36478a3..8152ae1 100644 --- a/solitaire_engine/src/card_plugin/highlights.rs +++ b/solitaire_engine/src/card_plugin/highlights.rs @@ -2,7 +2,6 @@ use super::*; - use bevy::color::Color; use solitaire_core::Card; use solitaire_core::game_state::GameState; @@ -272,5 +271,3 @@ pub(super) fn find_top_card_at( // --------------------------------------------------------------------------- // Task #28 — Stock-empty visual indicator // --------------------------------------------------------------------------- - - diff --git a/solitaire_engine/src/card_plugin/labels.rs b/solitaire_engine/src/card_plugin/labels.rs index 4a7c761..346c5d6 100644 --- a/solitaire_engine/src/card_plugin/labels.rs +++ b/solitaire_engine/src/card_plugin/labels.rs @@ -2,7 +2,6 @@ use super::*; - use bevy::color::Color; use bevy::sprite::Anchor; use solitaire_core::{Card, Rank, Suit}; @@ -205,4 +204,3 @@ pub(super) fn add_android_corner_label( // --------------------------------------------------------------------------- // Task #34 — Card-flip animation systems // --------------------------------------------------------------------------- - diff --git a/solitaire_engine/src/card_plugin/layout.rs b/solitaire_engine/src/card_plugin/layout.rs index 311d1e0..4d62800 100644 --- a/solitaire_engine/src/card_plugin/layout.rs +++ b/solitaire_engine/src/card_plugin/layout.rs @@ -15,10 +15,7 @@ use crate::font_plugin::FontResource; use crate::layout::{Layout, LayoutResource}; use crate::resources::GameStateResource; use crate::table_plugin::PileMarker; -use crate::ui_theme::{ - CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG, - CARD_SHADOW_PADDING_IDLE, -}; +use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG, CARD_SHADOW_PADDING_IDLE}; /// Coalesces every `WindowResized` event arriving this frame into the latest /// pending size on [`ResizeThrottle`]. @@ -347,5 +344,3 @@ pub(super) fn fill_tableau_fan_on_startup( }; crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0); } - - diff --git a/solitaire_engine/src/card_plugin/mod.rs b/solitaire_engine/src/card_plugin/mod.rs index 743a647..a13ab75 100644 --- a/solitaire_engine/src/card_plugin/mod.rs +++ b/solitaire_engine/src/card_plugin/mod.rs @@ -14,8 +14,8 @@ use std::collections::HashMap; use bevy::color::Color; use bevy::prelude::*; -use solitaire_core::{KlondikePile, Tableau}; use solitaire_core::Card; +use solitaire_core::{KlondikePile, Tableau}; use crate::card_animation::CardAnimation; use crate::events::{CardFaceRevealedEvent, CardFlippedEvent}; @@ -577,10 +577,7 @@ impl Plugin for CardPlugin { // the chain each pair is a scheduler ambiguity (#143). All // members are cheap and mostly change-gated; sequential // execution is not a cost that matters here. - .configure_sets( - Update, - LayoutSystem::UpdateOnResize.before(BoardVisuals), - ) + .configure_sets(Update, LayoutSystem::UpdateOnResize.before(BoardVisuals)) .add_systems( Update, ( @@ -597,8 +594,7 @@ impl Plugin for CardPlugin { clear_right_click_highlights_on_pause, tick_hint_highlight, update_stock_empty_indicator, - update_stock_count_badge - .run_if(resource_changed::), + update_stock_count_badge.run_if(resource_changed::), collect_resize_events, snap_cards_on_window_resize, resize_android_corner_labels, diff --git a/solitaire_engine/src/card_plugin/stock.rs b/solitaire_engine/src/card_plugin/stock.rs index 99bfebd..dc5957f 100644 --- a/solitaire_engine/src/card_plugin/stock.rs +++ b/solitaire_engine/src/card_plugin/stock.rs @@ -2,7 +2,6 @@ use super::*; - use bevy::color::Color; use solitaire_core::KlondikePile; use solitaire_core::game_state::GameState; @@ -12,10 +11,7 @@ use crate::font_plugin::FontResource; use crate::layout::{Layout, LayoutResource}; use crate::resources::GameStateResource; use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker}; -use crate::ui_theme::{ - STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, - TYPE_BODY, Z_STOCK_BADGE, -}; +use crate::ui_theme::{STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TYPE_BODY, Z_STOCK_BADGE}; /// Sprite colour applied to the stock `PileMarker` when the stock pile is empty, /// to signal to the player that there are no more cards to draw. Pure white @@ -288,4 +284,3 @@ pub(super) fn update_stock_count_badge( } } } - diff --git a/solitaire_engine/src/card_plugin/sync.rs b/solitaire_engine/src/card_plugin/sync.rs index 7816aa9..1110d06 100644 --- a/solitaire_engine/src/card_plugin/sync.rs +++ b/solitaire_engine/src/card_plugin/sync.rs @@ -6,9 +6,9 @@ use super::*; use std::collections::{HashMap, HashSet}; use bevy::color::Color; -use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::{Card, Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::GameState}; +use solitaire_core::{Foundation, KlondikePile, Tableau}; use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration}; use crate::card_animation::CardAnimation; @@ -700,4 +700,3 @@ pub(super) fn update_card_entity( commands.entity(entity).insert(new_children_key); } } - diff --git a/solitaire_engine/src/card_plugin/tests.rs b/solitaire_engine/src/card_plugin/tests.rs index d78deb7..022ab45 100644 --- a/solitaire_engine/src/card_plugin/tests.rs +++ b/solitaire_engine/src/card_plugin/tests.rs @@ -1,17 +1,16 @@ use super::*; -use crate::game_plugin::GamePlugin; -use crate::layout::TABLEAU_FAN_FRAC; -use crate::table_plugin::TablePlugin; -use solitaire_core::Deck; -use bevy::window::WindowResized; -use solitaire_core::{Card, Rank, Suit}; -use std::collections::HashSet; use crate::events::StateChangedEvent; +use crate::game_plugin::GamePlugin; use crate::layout::LayoutResource; +use crate::layout::TABLEAU_FAN_FRAC; use crate::resources::DragState; +use crate::table_plugin::TablePlugin; use crate::ui_theme::TEXT_PRIMARY_HC; +use bevy::window::WindowResized; +use solitaire_core::Deck; +use solitaire_core::{Card, Rank, Suit}; use solitaire_core::{DrawStockConfig, game_state::GameState}; - +use std::collections::HashSet; /// Convenience constructor — all unit tests use Deck1. fn make_card(suit: Suit, rank: Rank) -> Card { @@ -119,8 +118,7 @@ fn waste_draw_one_only_renders_top_card() { for _ in 0..3 { let _ = g.draw(); } - let waste_ids: HashSet = - g.waste_cards().iter().map(|c| c.0.clone()).collect(); + let waste_ids: HashSet = g.waste_cards().iter().map(|c| c.0.clone()).collect(); assert_eq!(waste_ids.len(), 3); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); @@ -163,8 +161,7 @@ fn waste_draw_three_renders_up_to_three_fanned_cards() { "need at least 3 waste cards for this test" ); - let waste_ids: HashSet = - waste_pile.iter().map(|c| c.0.clone()).collect(); + let waste_ids: HashSet = waste_pile.iter().map(|c| c.0.clone()).collect(); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); @@ -216,8 +213,7 @@ fn waste_draw_three_fans_correctly_when_pile_smaller_than_visible() { let count = waste_pile.len(); assert!(count >= 2, "need at least 2 waste cards"); - let waste_ids: HashSet = - waste_pile.iter().map(|c| c.0.clone()).collect(); + let waste_ids: HashSet = waste_pile.iter().map(|c| c.0.clone()).collect(); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); @@ -254,8 +250,7 @@ fn waste_draw_one_buffer_card_at_same_xy_as_top() { for _ in 0..3 { let _ = g.draw(); } - let waste_ids: HashSet = - g.waste_cards().iter().map(|c| c.0.clone()).collect(); + let waste_ids: HashSet = g.waste_cards().iter().map(|c| c.0.clone()).collect(); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); let waste_rendered: Vec<_> = positions @@ -745,8 +740,7 @@ fn advance_past_resize_throttle(app: &mut App) { fn fire_window_resize(app: &mut App, width: f32, height: f32) { // Any Entity will do — the snap system reads only width/height. - let window = Entity::from_raw_u32(0) - .expect("Entity::from_raw_u32(0) is a valid placeholder"); + let window = Entity::from_raw_u32(0).expect("Entity::from_raw_u32(0) is a valid placeholder"); app.world_mut().write_message(WindowResized { window, width, @@ -928,8 +922,7 @@ fn resize_in_place_updates_card_label_font_size() { // Sanity-check: the new font size matches FONT_SIZE_FRAC × the // post-resize card width, so the in-place path is using the // refreshed Layout. - let expected_layout = - crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true); + let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true); let expected = expected_layout.card_size.x * FONT_SIZE_FRAC; assert!( (after - expected).abs() < 1e-3, @@ -1046,7 +1039,8 @@ fn shadow_offset_increases_during_drag() { q.iter(app.world()) .next() .expect("fixture should spawn at least one CardEntity") - .card.clone() + .card + .clone() }; // Pick a *different* card to act as the negative control — @@ -1101,9 +1095,7 @@ fn shadow_offset_increases_during_drag() { fn shadow_offset_for_card(app: &mut App, card: &Card) -> Vec2 { // Map every CardEntity to its (Entity, card). let card_entity = { - let mut q = app - .world_mut() - .query::<(Entity, &CardEntity)>(); + let mut q = app.world_mut().query::<(Entity, &CardEntity)>(); q.iter(app.world()) .find(|(_, c)| c.card == *card) .map(|(e, _)| e) @@ -1198,8 +1190,7 @@ fn stock_badge_updates_when_stock_count_changes() { assert_eq!(stock_badge_text(&mut app), "24"); { let mut game = app.world_mut().resource_mut::(); - let mut stock: Vec = - game.0.stock_cards().into_iter().map(|(c, _)| c).collect(); + let mut stock: Vec = game.0.stock_cards().into_iter().map(|(c, _)| c).collect(); let _ = stock.pop(); game.0.set_test_stock_cards(stock); } @@ -1234,8 +1225,7 @@ fn image_set_with_distinct_back_handles() -> CardImageSet { // distinct dummy `Image`. We never render these; we only // compare ids. let mut images = Assets::::default(); - let backs: [Handle; 5] = - std::array::from_fn(|_| images.add(Image::default())); + let backs: [Handle; 5] = std::array::from_fn(|_| images.add(Image::default())); CardImageSet { faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())), backs, @@ -1493,8 +1483,7 @@ fn waste_pile_cards_have_strictly_increasing_z() { let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); - let waste_ids: HashSet = - g.waste_cards().iter().map(|c| c.0.clone()).collect(); + let waste_ids: HashSet = g.waste_cards().iter().map(|c| c.0.clone()).collect(); let mut waste_zs: Vec = positions .iter() @@ -1543,8 +1532,7 @@ fn waste_cards_do_not_overlap_stock_column_on_portrait() { let stock_x = layout.pile_positions[&KlondikePile::Stock].x; - let waste_ids: HashSet = - g.waste_cards().iter().map(|c| c.0.clone()).collect(); + let waste_ids: HashSet = g.waste_cards().iter().map(|c| c.0.clone()).collect(); let mut waste_positions: Vec<_> = card_positions(&g, &layout) .into_iter() @@ -1573,8 +1561,7 @@ fn waste_pile_draw_one_cards_have_distinct_z() { let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let positions = card_positions(&g, &layout); - let waste_ids: HashSet = - g.waste_cards().iter().map(|c| c.0.clone()).collect(); + let waste_ids: HashSet = g.waste_cards().iter().map(|c| c.0.clone()).collect(); let mut waste_zs: Vec = positions .iter() diff --git a/solitaire_engine/src/cursor_plugin.rs b/solitaire_engine/src/cursor_plugin.rs index d475006..fc6c42b 100644 --- a/solitaire_engine/src/cursor_plugin.rs +++ b/solitaire_engine/src/cursor_plugin.rs @@ -35,8 +35,8 @@ use bevy::prelude::*; use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon}; use solitaire_core::Card; -use solitaire_core::{Foundation, KlondikePile, Tableau}; use solitaire_core::{DrawStockConfig, game_state::GameState}; +use solitaire_core::{Foundation, KlondikePile, Tableau}; use crate::card_plugin::RightClickHighlight; use crate::layout::{Layout, LayoutResource}; @@ -437,7 +437,8 @@ fn tableau_or_stack_pos( base.x, base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32), ) - } else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree { + } else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree + { let pile_len = game.waste_cards().len(); let visible_start = pile_len.saturating_sub(3); let slot = index.saturating_sub(visible_start) as f32; @@ -581,7 +582,10 @@ mod tests { use crate::layout::compute_layout; use solitaire_core::{Card, Deck, Rank, Suit}; - use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}}; + use solitaire_core::{ + DrawStockConfig, + game_state::{GameMode, GameState}, + }; /// Builds an `App` with `MinimalPlugins` and the overlay system /// registered, plus the resources the system needs. Callers @@ -630,11 +634,7 @@ mod tests { // — same colour family, illegal. Tableau(2) must NOT be // highlighted. let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic); - set_tableau_top( - &mut game, - 2, - Card::new(Deck::Deck1, Suit::Clubs, Rank::Six), - ); + set_tableau_top(&mut game, 2, Card::new(Deck::Deck1, Suit::Clubs, Rank::Six)); let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five); let mut app = overlay_test_app(game); diff --git a/solitaire_engine/src/difficulty_plugin.rs b/solitaire_engine/src/difficulty_plugin.rs index fa21b57..33ae211 100644 --- a/solitaire_engine/src/difficulty_plugin.rs +++ b/solitaire_engine/src/difficulty_plugin.rs @@ -54,7 +54,10 @@ impl DifficultyIndexResource { DifficultyLevel::Hard => &mut self.hard, DifficultyLevel::Expert => &mut self.expert, DifficultyLevel::Grandmaster => &mut self.grandmaster, - DifficultyLevel::Random => unreachable!("Random has no catalog"), + // Random has no catalog today, so seeds_for() already returned + // None above; if it ever gains one, a time seed is still the + // right answer for "Random" — never a reachable panic. + DifficultyLevel::Random => return seed_from_system_time(), }; let seed = catalog[*cursor % catalog.len()]; *cursor = cursor.wrapping_add(1); diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index e8c7ada..9f61f4f 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -2,8 +2,8 @@ use bevy::prelude::Message; use solitaire_core::KlondikePile; -use solitaire_core::{Card, Suit}; use solitaire_core::game_state::GameMode; +use solitaire_core::{Card, Suit}; use solitaire_data::AchievementRecord; use solitaire_sync::SyncResponse; diff --git a/solitaire_engine/src/feedback_anim_plugin.rs b/solitaire_engine/src/feedback_anim_plugin.rs index b51f28b..873049a 100644 --- a/solitaire_engine/src/feedback_anim_plugin.rs +++ b/solitaire_engine/src/feedback_anim_plugin.rs @@ -852,7 +852,10 @@ mod tests { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugins(FeedbackAnimPlugin); - app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne))); + app.insert_resource(GameStateResource(GameState::new( + 1, + DrawStockConfig::DrawOne, + ))); app.insert_resource(SettingsResource(Settings { reduce_motion_mode: true, ..Settings::default() @@ -906,7 +909,10 @@ mod tests { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugins(FeedbackAnimPlugin); - app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne))); + app.insert_resource(GameStateResource(GameState::new( + 1, + DrawStockConfig::DrawOne, + ))); app.insert_resource(SettingsResource(Settings { reduce_motion_mode: true, ..Settings::default() diff --git a/solitaire_engine/src/game_plugin/mod.rs b/solitaire_engine/src/game_plugin/mod.rs index 1f4ee8d..249e010 100644 --- a/solitaire_engine/src/game_plugin/mod.rs +++ b/solitaire_engine/src/game_plugin/mod.rs @@ -14,8 +14,13 @@ use bevy::prelude::*; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::window::AppLifecycle; use solitaire_core::KlondikePile; -use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}}; -use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction}; +use solitaire_core::{ + DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction, +}; +use solitaire_core::{ + DrawStockConfig, + game_state::{GameMode, GameState}, +}; #[allow(deprecated)] use solitaire_data::latest_replay_path; use solitaire_data::{ @@ -188,7 +193,9 @@ impl Plugin for GamePlugin { ) } else { ( - saved.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)), + saved.unwrap_or_else(|| { + GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne) + }), None, ) }; @@ -1324,7 +1331,10 @@ fn auto_save_game_state( // or there's a pending restore the player hasn't answered — saving // the fresh-deal placeholder we seeded GameStateResource with at // startup would clobber the real saved game on disk. - if paused.is_some_and(|p| p.0) || game.0.is_won() || game.0.move_count() == 0 || pending.0.is_some() + if paused.is_some_and(|p| p.0) + || game.0.is_won() + || game.0.move_count() == 0 + || pending.0.is_some() { return; } diff --git a/solitaire_engine/src/game_plugin/tests.rs b/solitaire_engine/src/game_plugin/tests.rs index 1d9c9c0..0e88099 100644 --- a/solitaire_engine/src/game_plugin/tests.rs +++ b/solitaire_engine/src/game_plugin/tests.rs @@ -372,9 +372,7 @@ fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() { }); app.update(); - let events = app - .world() - .resource::>(); + let events = app.world().resource::>(); let mut cursor = events.get_cursor(); let fired: Vec<_> = cursor.read(events).collect(); assert!( @@ -800,7 +798,10 @@ fn replay_recording_skips_undo() { 1, "only the draw is recorded; the undo does not erase it nor add a new entry", ); - assert!(matches!(recording.moves[0], KlondikeInstruction::RotateStock)); + assert!(matches!( + recording.moves[0], + KlondikeInstruction::RotateStock + )); } /// Starting a new game wipes the recording so the next deal begins @@ -872,8 +873,8 @@ fn replay_recording_freezes_into_replay_on_game_won() { }); app.update(); - let history = load_replay_history_from(&path) - .expect("a winning replay must be persisted to ReplayPath"); + let history = + load_replay_history_from(&path).expect("a winning replay must be persisted to ReplayPath"); assert_eq!( history.replays.len(), 1, @@ -1059,7 +1060,10 @@ fn new_game_with_solver_toggle_off_random_seed_path() { app.update(); // Game state was reseeded — move_count is 0 on the new game. - assert_eq!(app.world().resource::().0.move_count(), 0); + assert_eq!( + app.world().resource::().0.move_count(), + 0 + ); } #[test] @@ -1133,7 +1137,10 @@ fn new_game_with_solver_toggle_on_retries_until_winnable() { // The chosen seed is non-deterministic (system time), // but the new game must have been started cleanly: // move_count back to 0, undo stack empty. - assert_eq!(app.world().resource::().0.move_count(), 0); + assert_eq!( + app.world().resource::().0.move_count(), + 0 + ); assert_eq!( app.world() .resource::() diff --git a/solitaire_engine/src/home_plugin.rs b/solitaire_engine/src/home_plugin.rs index 97e151c..01d2065 100644 --- a/solitaire_engine/src/home_plugin.rs +++ b/solitaire_engine/src/home_plugin.rs @@ -432,7 +432,9 @@ fn build_home_context<'a>( zen_best: stats.map_or(0, |s| s.0.zen_best_score), challenge_best: stats.map_or(0, |s| s.0.challenge_best_score), daily_today, - draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawStockConfig::DrawOne), + draw_mode: settings + .map(|s| s.0.draw_mode) + .unwrap_or(DrawStockConfig::DrawOne), font_res, difficulty_expanded, last_difficulty: settings.and_then(|s| s.0.last_difficulty), diff --git a/solitaire_engine/src/hud_plugin/fx.rs b/solitaire_engine/src/hud_plugin/fx.rs index a525a40..d58ec9c 100644 --- a/solitaire_engine/src/hud_plugin/fx.rs +++ b/solitaire_engine/src/hud_plugin/fx.rs @@ -3,8 +3,6 @@ use super::*; - - /// Auto-fade state for the action button bar. The bar fades out when /// the cursor is in the play area (below the HUD band) and back in when /// the cursor approaches the top of the window — same UX as a video @@ -49,7 +47,11 @@ const ACTION_FADE_RATE_PER_SEC: f32 = 6.0; /// `target` at a fixed rate so the visual transition is smooth across /// variable framerates. #[cfg(not(target_os = "android"))] -pub(super) fn update_action_fade(windows: Query<&Window>, time: Res