Compare commits

..

3 Commits

Author SHA1 Message Date
funman300 ff8c00d2f4 Merge pull request 'fix(engine): poll decor-view size to catch fold resizes winit misses (#130)' (#152) from fix/130-fold-stale-relayout into master
Build and Deploy / build-and-push (push) Successful in 1m56s
Test / test (push) Failing after 16m17s
Web WASM Rebuild / rebuild (push) Successful in 8m23s
Android Release / build-apk (push) Successful in 4m19s
2026-07-07 18:12:57 +00:00
funman300 38b81a4004 fix(engine): poll decor-view size to catch fold resizes winit misses
Test / test (pull_request) Successful in 23m3s
winit's Android backend does not forward content-rect changes that happen
while backgrounded (open TODOs in winit 0.30 logged on every resume), so a
fold/unfold cycle can leave Bevy rendering and laying out for the previous
screen: tableau clipped off the left edge, bottom third empty (#130).

Add a continuous decor-view size poller (JNI, every 30 frames) that, on
mismatch with the cached Window resolution: writes the real physical size
into window.resolution, emits a synthetic WindowResized in logical pixels,
and re-arms the safe-area inset poller — covering both the fold-size and
inset-staleness cases in one mechanism, without relying on
AppLifecycle::WillResume being delivered (it may never be; a new evidence
log in rearm_on_resumed settles that question on-device).

Closes #130

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:49:17 -07:00
Gitea CI ae7af9adf4 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 7m12s
Web E2E / web-e2e (push) Successful in 5m16s
2026-07-07 09:12:14 +00:00
56 changed files with 484 additions and 487 deletions
+1 -11
View File
@@ -31,13 +31,6 @@ 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
@@ -46,7 +39,7 @@ jobs:
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.95.0
components: clippy, rustfmt
components: clippy
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
@@ -60,9 +53,6 @@ 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)
+2 -6
View File
@@ -592,15 +592,11 @@ pub struct Session<G> { /* replayable instruction log + derived stats */ }
// From `klondike` (upstream — never edit):
pub enum KlondikePile {
Stock, // NB: no Waste variant — see below
Stock,
Waste,
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+) */ }
+22 -26
View File
@@ -439,7 +439,10 @@ impl GameState {
self.session.history().len()
}
fn cards_with_face(cards: impl IntoIterator<Item = Card>, face_up: bool) -> Vec<(Card, bool)> {
fn cards_with_face(
cards: impl IntoIterator<Item = Card>,
face_up: bool,
) -> Vec<(Card, bool)> {
cards.into_iter().map(|card| (card, face_up)).collect()
}
@@ -504,10 +507,8 @@ 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,
@@ -822,7 +823,9 @@ 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;
@@ -925,7 +928,10 @@ 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);
}
@@ -1290,13 +1296,12 @@ 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());
}
@@ -1355,18 +1360,9 @@ 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(_))));
}
+1 -3
View File
@@ -13,9 +13,7 @@ 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.
+2 -9
View File
@@ -41,20 +41,13 @@ fn all_cards(game: &GameState) -> Vec<Card> {
);
}
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<Value = DrawStockConfig> {
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.
+2 -4
View File
@@ -161,10 +161,8 @@ 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"` (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.
/// Older `settings.json` files that stored `"default"` or `"classic"`
/// are migrated to `"dark"` by [`Settings::sanitized`].
#[serde(default = "default_theme_id")]
pub selected_theme_id: String,
/// Set to `true` once the achievement-onboarding info-toast has been
+3 -7
View File
@@ -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,11 +569,7 @@ 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(),
+9 -35
View File
@@ -77,11 +77,6 @@ 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"))]
@@ -95,7 +90,6 @@ 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(()),
}
}
@@ -178,27 +172,7 @@ 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.
///
/// `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(());
}
async fn refresh_token(&self) -> Result<(), SyncError> {
let old_refresh =
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?;
@@ -257,7 +231,7 @@ impl SyncProvider for SolitaireServerClient {
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
// Token expired — refresh and retry once.
self.refresh_token(&token).await?;
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -290,7 +264,7 @@ impl SyncProvider for SolitaireServerClient {
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
// Token expired — refresh and retry once.
self.refresh_token(&token).await?;
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -357,7 +331,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token(&token).await?;
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -392,7 +366,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token(&token).await?;
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -432,7 +406,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token(&token).await?;
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -472,7 +446,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token(&token).await?;
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -506,7 +480,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token(&token).await?;
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
@@ -568,7 +542,7 @@ impl SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token(&token).await?;
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
+2 -1
View File
@@ -911,7 +911,8 @@ 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::<GameStateResource>().0.mode = GameMode::Zen;
app.world_mut().resource_mut::<GameStateResource>().0.mode =
GameMode::Zen;
app.world_mut().write_message(GameWonEvent {
score: 0,
+24 -27
View File
@@ -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.
///
/// 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.
/// # 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`]).
pub fn user_theme_dir() -> PathBuf {
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
return p.clone();
@@ -76,32 +76,29 @@ 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/<package>/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.
/// hardcoded `/data/data/<package>/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.
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"))]
{
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()."
);
});
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()."
)
}
PathBuf::new()
})
}
+2 -6
View File
@@ -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,11 +214,7 @@ 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 = (
@@ -72,7 +72,9 @@ 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,
}
+4 -1
View File
@@ -10,7 +10,9 @@ 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.
///
@@ -195,3 +197,4 @@ pub(super) fn update_card_shadows_on_drag(
// ---------------------------------------------------------------------------
// Task #28 — Hint highlight tick system
// ---------------------------------------------------------------------------
@@ -2,6 +2,7 @@
use super::*;
use bevy::color::Color;
use solitaire_core::Card;
use solitaire_core::game_state::GameState;
@@ -271,3 +272,5 @@ pub(super) fn find_top_card_at(
// ---------------------------------------------------------------------------
// Task #28 — Stock-empty visual indicator
// ---------------------------------------------------------------------------
@@ -2,6 +2,7 @@
use super::*;
use bevy::color::Color;
use bevy::sprite::Anchor;
use solitaire_core::{Card, Rank, Suit};
@@ -204,3 +205,4 @@ pub(super) fn add_android_corner_label(
// ---------------------------------------------------------------------------
// Task #34 — Card-flip animation systems
// ---------------------------------------------------------------------------
+6 -1
View File
@@ -15,7 +15,10 @@ 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`].
@@ -344,3 +347,5 @@ pub(super) fn fill_tableau_fan_on_startup(
};
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
}
+7 -3
View File
@@ -14,8 +14,8 @@ use std::collections::HashMap;
use bevy::color::Color;
use bevy::prelude::*;
use solitaire_core::Card;
use solitaire_core::{KlondikePile, Tableau};
use solitaire_core::Card;
use crate::card_animation::CardAnimation;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
@@ -577,7 +577,10 @@ 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,
(
@@ -594,7 +597,8 @@ 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::<GameStateResource>),
update_stock_count_badge
.run_if(resource_changed::<GameStateResource>),
collect_resize_events,
snap_cards_on_window_resize,
resize_android_corner_labels,
+6 -1
View File
@@ -2,6 +2,7 @@
use super::*;
use bevy::color::Color;
use solitaire_core::KlondikePile;
use solitaire_core::game_state::GameState;
@@ -11,7 +12,10 @@ 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
@@ -284,3 +288,4 @@ pub(super) fn update_stock_count_badge(
}
}
}
+2 -1
View File
@@ -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,3 +700,4 @@ pub(super) fn update_card_entity(
commands.entity(entity).insert(new_children_key);
}
}
+33 -20
View File
@@ -1,16 +1,17 @@
use super::*;
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 bevy::window::WindowResized;
use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use std::collections::HashSet;
use crate::events::StateChangedEvent;
use crate::layout::LayoutResource;
use crate::resources::DragState;
use crate::ui_theme::TEXT_PRIMARY_HC;
use solitaire_core::{DrawStockConfig, game_state::GameState};
/// Convenience constructor — all unit tests use Deck1.
fn make_card(suit: Suit, rank: Rank) -> Card {
@@ -118,7 +119,8 @@ fn waste_draw_one_only_renders_top_card() {
for _ in 0..3 {
let _ = g.draw();
}
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
let waste_ids: HashSet<Card> =
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);
@@ -161,7 +163,8 @@ fn waste_draw_three_renders_up_to_three_fanned_cards() {
"need at least 3 waste cards for this test"
);
let waste_ids: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect();
let waste_ids: HashSet<Card> =
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);
@@ -213,7 +216,8 @@ 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<Card> = waste_pile.iter().map(|c| c.0.clone()).collect();
let waste_ids: HashSet<Card> =
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);
@@ -250,7 +254,8 @@ fn waste_draw_one_buffer_card_at_same_xy_as_top() {
for _ in 0..3 {
let _ = g.draw();
}
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
let waste_ids: HashSet<Card> =
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
@@ -740,7 +745,8 @@ 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,
@@ -922,7 +928,8 @@ 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,
@@ -1039,8 +1046,7 @@ 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 —
@@ -1095,7 +1101,9 @@ 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)
@@ -1190,7 +1198,8 @@ fn stock_badge_updates_when_stock_count_changes() {
assert_eq!(stock_badge_text(&mut app), "24");
{
let mut game = app.world_mut().resource_mut::<GameStateResource>();
let mut stock: Vec<Card> = game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
let mut stock: Vec<Card> =
game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
let _ = stock.pop();
game.0.set_test_stock_cards(stock);
}
@@ -1225,7 +1234,8 @@ fn image_set_with_distinct_back_handles() -> CardImageSet {
// distinct dummy `Image`. We never render these; we only
// compare ids.
let mut images = Assets::<Image>::default();
let backs: [Handle<Image>; 5] = std::array::from_fn(|_| images.add(Image::default()));
let backs: [Handle<Image>; 5] =
std::array::from_fn(|_| images.add(Image::default()));
CardImageSet {
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
backs,
@@ -1483,7 +1493,8 @@ 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<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions
.iter()
@@ -1532,7 +1543,8 @@ fn waste_cards_do_not_overlap_stock_column_on_portrait() {
let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_positions: Vec<_> = card_positions(&g, &layout)
.into_iter()
@@ -1561,7 +1573,8 @@ 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<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
let waste_ids: HashSet<Card> =
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions
.iter()
+8 -8
View File
@@ -35,8 +35,8 @@
use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::Card;
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use crate::card_plugin::RightClickHighlight;
use crate::layout::{Layout, LayoutResource};
@@ -437,8 +437,7 @@ 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;
@@ -582,10 +581,7 @@ 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
@@ -634,7 +630,11 @@ 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);
+1 -4
View File
@@ -54,10 +54,7 @@ impl DifficultyIndexResource {
DifficultyLevel::Hard => &mut self.hard,
DifficultyLevel::Expert => &mut self.expert,
DifficultyLevel::Grandmaster => &mut self.grandmaster,
// 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(),
DifficultyLevel::Random => unreachable!("Random has no catalog"),
};
let seed = catalog[*cursor % catalog.len()];
*cursor = cursor.wrapping_add(1);
+1 -1
View File
@@ -2,8 +2,8 @@
use bevy::prelude::Message;
use solitaire_core::KlondikePile;
use solitaire_core::game_state::GameMode;
use solitaire_core::{Card, Suit};
use solitaire_core::game_state::GameMode;
use solitaire_data::AchievementRecord;
use solitaire_sync::SyncResponse;
+2 -8
View File
@@ -852,10 +852,7 @@ 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()
@@ -909,10 +906,7 @@ 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()
+4 -14
View File
@@ -14,13 +14,8 @@ use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use bevy::window::AppLifecycle;
use solitaire_core::KlondikePile;
use solitaire_core::{
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction,
};
use solitaire_core::{
DrawStockConfig,
game_state::{GameMode, GameState},
};
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction};
#[allow(deprecated)]
use solitaire_data::latest_replay_path;
use solitaire_data::{
@@ -193,9 +188,7 @@ 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,
)
};
@@ -1331,10 +1324,7 @@ 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;
}
+8 -15
View File
@@ -372,7 +372,9 @@ fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
});
app.update();
let events = app.world().resource::<Messages<CardFlippedEvent>>();
let events = app
.world()
.resource::<Messages<CardFlippedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect();
assert!(
@@ -798,10 +800,7 @@ 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
@@ -873,8 +872,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,
@@ -1060,10 +1059,7 @@ 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::<GameStateResource>().0.move_count(),
0
);
assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
}
#[test]
@@ -1137,10 +1133,7 @@ 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::<GameStateResource>().0.move_count(),
0
);
assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
assert_eq!(
app.world()
.resource::<GameStateResource>()
+1 -3
View File
@@ -432,9 +432,7 @@ 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),
+4 -5
View File
@@ -3,6 +3,8 @@
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
@@ -47,11 +49,7 @@ 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<Time>,
mut fade: ResMut<HudActionFade>,
) {
pub(super) fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut<HudActionFade>) {
let Ok(window) = windows.single() else {
return;
};
@@ -429,3 +427,4 @@ pub(super) fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
from.alpha + (to.alpha - from.alpha) * t,
)
}
@@ -3,6 +3,8 @@
use super::*;
/// `Changed<Interaction>` filter ensures we only react on the frame the
/// interaction state transitions, avoiding repeat events while the button
/// is held down. Each click handler fires the corresponding request event,
@@ -611,3 +613,4 @@ pub(super) fn toggle_hud_on_tap(
}
}
}
+1
View File
@@ -19,6 +19,7 @@ use interaction::*;
use spawn::*;
use updates::*;
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
// Option<Res<AvatarResource>> parameters below compile without changes.
// The resource is never inserted on wasm, so every call resolves to None.
+3 -3
View File
@@ -2,6 +2,7 @@
use super::*;
#[cfg(not(target_arch = "wasm32"))]
use crate::avatar_plugin::AvatarResource;
@@ -291,9 +292,7 @@ pub(super) fn spawn_avatar_child(
const SIZE: f32 = 32.0;
if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
// Logged-in with a downloaded avatar: keep the accent disc behind it.
commands
.entity(parent)
.insert(BackgroundColor(ACCENT_PRIMARY));
commands.entity(parent).insert(BackgroundColor(ACCENT_PRIMARY));
// Image fills the circle container; border_radius clips it to a disc.
commands.entity(parent).with_children(|b| {
b.spawn((
@@ -550,3 +549,4 @@ pub(super) fn spawn_action_button<M: Component>(
}
});
}
+10 -38
View File
@@ -40,16 +40,9 @@ fn read_hud_text<M: Component>(app: &mut App) -> String {
#[test]
fn score_reflects_game_state() {
let mut app = headless_app();
let score = app
.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(20);
let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20);
app.update();
assert_eq!(
read_hud_text::<HudScore>(&mut app),
format!("Score: {score}")
);
assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}"));
}
#[test]
@@ -199,9 +192,7 @@ fn challenge_time_color_zero_is_danger() {
fn challenge_hud_empty_when_no_daily_resource() {
// No DailyChallengeResource inserted → HudChallenge must be empty.
let mut app = headless_app();
app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.world_mut().resource_mut::<GameStateResource>().set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
}
@@ -216,9 +207,7 @@ fn challenge_hud_shows_time_limit_when_resource_present() {
target_score: None,
max_time_secs: Some(300),
});
app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.world_mut().resource_mut::<GameStateResource>().set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
}
@@ -233,9 +222,7 @@ fn challenge_hud_shows_score_goal_when_resource_present() {
target_score: Some(4000),
max_time_secs: None,
});
app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.world_mut().resource_mut::<GameStateResource>().set_changed();
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
}
@@ -251,10 +238,7 @@ fn challenge_hud_clears_on_win() {
max_time_secs: Some(300),
});
// Mark the game as won — HudChallenge should be empty.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.set_test_won(true);
app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true);
app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
}
@@ -400,10 +384,7 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
set_manual_time_step(&mut app, 0.0);
// Initial state has score=0; bumping by 50 (the threshold)
// is the smallest jump that triggers the floater.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(50);
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
app.update();
// One floater should now exist.
@@ -424,10 +405,7 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
#[test]
fn score_floater_despawns_after_full_lifetime() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(50);
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
app.update();
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
@@ -453,10 +431,7 @@ fn score_increase_below_threshold_does_not_spawn_floater() {
let mut app = headless_app();
// +5 mirrors a single tableau-to-foundation move; well below
// the 50-point threshold so the floater path stays dormant.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(5);
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5);
app.update();
assert_eq!(
count_with::<ScoreFloater>(&mut app),
@@ -532,10 +507,7 @@ fn score_change_skips_pulse_and_floater_under_reduce_motion() {
..Settings::default()
}));
// +100 would normally create both a ScorePulse and a ScoreFloater.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.force_test_score(50);
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
app.update();
assert_eq!(
count_with::<ScorePulse>(&mut app),
+2 -1
View File
@@ -3,9 +3,9 @@
use super::*;
use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::Suit;
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::auto_complete_plugin::AutoCompleteState;
@@ -609,3 +609,4 @@ pub(super) fn resize_action_bar_labels(
font.font_size = new_size;
}
}
+15 -16
View File
@@ -27,10 +27,10 @@ use bevy::prelude::*;
use bevy::window::PrimaryWindow;
#[cfg(not(target_os = "android"))]
use bevy::window::{MonitorSelection, WindowMode};
use solitaire_core::game_state::GameState;
use solitaire_core::{Card, Suit};
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::{Card, Suit};
use solitaire_core::game_state::GameState;
use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::tuning::AnimationTuning;
@@ -394,10 +394,7 @@ pub fn emit_hint_visuals(
// Find the top face-up card in the source pile and highlight it.
let source_cards = pile_cards(game, from);
let top_card = source_cards
.last()
.filter(|(_, face_up)| *face_up)
.map(|(c, _)| c.clone());
let top_card = source_cards.last().filter(|(_, face_up)| *face_up).map(|(c, _)| c.clone());
if let Some(card) = top_card {
for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
if card_entity.card == card {
@@ -834,7 +831,9 @@ fn end_drag(
let origin_cards = pile_cards(&game.0, &origin);
if !origin_cards.is_empty() {
for card in &drag.cards {
let Some(stack_index) = origin_cards.iter().position(|(c, _)| c == card) else {
let Some(stack_index) =
origin_cards.iter().position(|(c, _)| c == card)
else {
continue;
};
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
@@ -1071,7 +1070,8 @@ fn touch_end_drag(
let origin_cards = pile_cards(&game.0, &origin);
if !origin_cards.is_empty() {
for card in &drag.cards {
let Some(stack_index) = origin_cards.iter().position(|(c, _)| c == card)
let Some(stack_index) =
origin_cards.iter().position(|(c, _)| c == card)
else {
continue;
};
@@ -1174,8 +1174,7 @@ fn card_position(
y_offset -= layout.card_size.y * step;
}
Vec2::new(base.x, base.y + y_offset)
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree
{
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
// In Draw-Three mode the top 3 waste cards are fanned in X to match
// card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
// so clicking the visually rightmost (top) card actually registers — a
@@ -1249,10 +1248,7 @@ fn find_draggable_at(
}
(i, i + 1)
};
let cards: Vec<Card> = pile_cards[start..end]
.iter()
.map(|(c, _)| c.clone())
.collect();
let cards: Vec<Card> = pile_cards[start..end].iter().map(|(c, _)| c.clone()).collect();
return Some((pile, start, cards));
}
}
@@ -1554,7 +1550,8 @@ fn handle_double_tap(
return;
}
let Some((found_card, found_face_up)) = pile_cards.iter().find(|(c, _)| c == top_card)
let Some((found_card, found_face_up)) =
pile_cards.iter().find(|(c, _)| c == top_card)
else {
return;
};
@@ -1786,6 +1783,8 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
}
}
const fn tableau_number(tableau: Tableau) -> u8 {
match tableau {
Tableau::Tableau1 => 1,
+35 -35
View File
@@ -89,11 +89,9 @@ fn find_draggable_picks_waste_top_with_multiple_cards() {
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
clear_test_piles(&mut game);
let waste = vec![
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
];
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)];
game.set_test_waste_cards(waste.clone());
let top_index = waste.len() - 1; // 2 = the visible top
@@ -101,11 +99,7 @@ fn find_draggable_picks_waste_top_with_multiple_cards() {
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
assert_eq!(
result.2,
vec![waste[top_index].clone()],
"drags the top card only"
);
assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only");
}
#[test]
@@ -182,7 +176,8 @@ fn find_draggable_skips_face_down_cards() {
// 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");
}
@@ -200,7 +195,8 @@ fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() {
// 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));
@@ -217,14 +213,18 @@ fn find_draggable_returns_run_when_picking_mid_stack() {
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
game.set_test_tableau_cards(Tableau::Tableau1, vec![king, queen.clone(), jack.clone()]);
game.set_test_tableau_cards(
Tableau::Tableau1,
vec![king, queen.clone(), jack.clone()],
);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
// The Queen's geometric center (index 1) is inside the Jack's bounding box
// (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));
@@ -322,11 +322,7 @@ fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
);
let (pile, _start, ids) = result.unwrap();
assert_eq!(pile, KlondikePile::Stock);
assert_eq!(
ids,
vec![four_clubs],
"only the top card is draggable from waste"
);
assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste");
}
#[test]
@@ -562,7 +558,8 @@ fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
fn rejected_drag_animation_targets_origin_resting_position() {
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
let anim =
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
assert!(
(anim.end - target_pos).length() < 1e-6,
@@ -580,7 +577,8 @@ fn rejected_drag_animation_targets_origin_resting_position() {
fn rejected_drag_animation_starts_from_drag_position() {
let drag_pos = Vec2::new(640.0, 200.0);
let target_pos = Vec2::new(80.0, -120.0);
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
let anim =
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
assert!(
(anim.start - drag_pos).length() < 1e-6,
@@ -603,8 +601,12 @@ fn rejected_drag_animation_starts_from_drag_position() {
/// the call site honest.
#[test]
fn rejected_drag_animation_uses_correct_duration() {
let anim =
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
let anim = build_drag_reject_animation(
Vec2::new(640.0, 200.0),
DRAG_Z,
Vec2::new(80.0, -120.0),
0,
);
assert!(
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
@@ -618,8 +620,12 @@ fn rejected_drag_animation_uses_correct_duration() {
/// jittery rather than forgiving.
#[test]
fn rejected_drag_animation_uses_responsive_curve() {
let anim =
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
let anim = build_drag_reject_animation(
Vec2::new(640.0, 200.0),
DRAG_Z,
Vec2::new(80.0, -120.0),
0,
);
assert_eq!(
anim.curve,
MotionCurve::Responsive,
@@ -677,16 +683,10 @@ fn pressing_h_spawns_pending_hint_task() {
app.init_resource::<HintSolverConfig>();
app.init_resource::<crate::pending_hint::PendingHintTask>();
app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(LayoutResource(compute_layout(
Vec2::new(1280.0, 800.0),
0.0,
0.0,
true,
)));
app.insert_resource(GameStateResource(GameState::new(
42,
DrawStockConfig::DrawOne,
)));
app.insert_resource(LayoutResource(
compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
));
app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne)));
app.add_systems(Update, handle_keyboard_hint);
// Simulate the H key being pressed this frame.
+4 -2
View File
@@ -862,8 +862,10 @@ fn handle_display_name_confirm(
.leaderboard_display_name
.clone()
.unwrap_or_else(|| {
if let SyncBackend::SolitaireServer { ref username, .. } =
settings.0.sync_backend
if let SyncBackend::SolitaireServer {
ref username,
..
} = settings.0.sync_backend
{
username.chars().take(32).collect()
} else {
+1 -1
View File
@@ -27,7 +27,6 @@ use solitaire_data::{Settings, save_settings_to};
use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::splash_plugin::SplashRoot;
use crate::ui_modal::{
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header,
@@ -37,6 +36,7 @@ use crate::ui_theme::{
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
};
use crate::splash_plugin::SplashRoot;
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
// ---------------------------------------------------------------------------
+1 -4
View File
@@ -969,10 +969,7 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(GameStateResource(GameState::new(
1,
DrawStockConfig::DrawOne,
)));
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
app.update();
app
}
+17 -22
View File
@@ -178,9 +178,9 @@ mod tests {
use super::*;
use crate::events::HintVisualEvent;
use crate::input_plugin::HintSolverConfig;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
/// Build a minimal Bevy app exercising only the polling system
/// and the resources/messages it touches.
@@ -249,7 +249,10 @@ mod tests {
.into_iter()
.zip(suits.iter())
{
game.set_test_tableau_cards(tableau, vec![Card::new(Deck::Deck1, *suit, Rank::King)]);
game.set_test_tableau_cards(
tableau,
vec![Card::new(Deck::Deck1, *suit, Rank::King)],
);
}
game
}
@@ -264,11 +267,9 @@ mod tests {
let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state()));
let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut().resource_mut::<PendingHintTask>().spawn(
near_finished_state(),
cfg.moves_budget,
cfg.states_budget,
);
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while app.world().resource::<PendingHintTask>().is_pending() {
@@ -305,11 +306,9 @@ mod tests {
let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state()));
let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut().resource_mut::<PendingHintTask>().spawn(
near_finished_state(),
cfg.moves_budget,
cfg.states_budget,
);
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
assert!(
app.world().resource::<PendingHintTask>().is_pending(),
"task is in flight after spawn",
@@ -345,22 +344,18 @@ mod tests {
let cfg = *app.world().resource::<HintSolverConfig>();
// First spawn.
app.world_mut().resource_mut::<PendingHintTask>().spawn(
near_finished_state(),
cfg.moves_budget,
cfg.states_budget,
);
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
assert!(first_handle_present);
// Second spawn. The `spawn` helper drops the prior task
// before assigning the new one — at no point are two tasks
// in flight.
app.world_mut().resource_mut::<PendingHintTask>().spawn(
near_finished_state(),
cfg.moves_budget,
cfg.states_budget,
);
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
// Resource still pending (the second task), but the first
// is gone. We can't directly observe the first handle once
// it's been overwritten — what we *can* assert is that the
+7 -6
View File
@@ -47,10 +47,10 @@ use bevy::input::touch::Touches;
use bevy::math::Vec2;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::Card;
use solitaire_core::game_state::GameState;
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
use crate::events::{MoveRejectedEvent, MoveRequestEvent};
@@ -360,6 +360,9 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
}
}
/// Builds the `(destination, anchor)` list for a fresh radial open.
///
/// `half_extents` is the window half-size in world space — icons are clamped
@@ -378,10 +381,8 @@ fn build_radial_destinations(
.map(|(i, d)| {
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),
raw.x.clamp(-half_extents.x + margin, half_extents.x - margin),
raw.y.clamp(-half_extents.y + margin, half_extents.y - margin),
);
(d, clamped)
})
@@ -246,11 +246,7 @@ pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str {
/// known card, or `"--"` for an absent top card (empty pile).
pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String {
match card {
Some((c, _)) => format!(
"{}{}",
format_rank_short(c.rank()),
format_suit_glyph(c.suit())
),
Some((c, _)) => format!("{}{}", format_rank_short(c.rank()), format_suit_glyph(c.suit())),
None => "--".to_string(),
}
}
+1 -1
View File
@@ -6,8 +6,8 @@ use std::sync::Arc;
use bevy::math::Vec2;
use bevy::prelude::Resource;
use chrono::{DateTime, Utc};
use solitaire_core::Card;
use solitaire_core::KlondikePile;
use solitaire_core::Card;
use solitaire_core::game_state::GameState;
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
+100 -1
View File
@@ -84,7 +84,8 @@ impl Plugin for SafeAreaInsetsPlugin {
#[cfg(target_os = "android")]
app.init_resource::<android::SafeAreaPollTries>()
.add_systems(Update, android::refresh_insets)
.add_systems(Update, android::rearm_on_resumed);
.add_systems(Update, android::rearm_on_resumed)
.add_systems(Update, android::refresh_surface_size);
}
}
@@ -225,6 +226,7 @@ fn on_app_resumed(
mod android {
use super::{AppLifecycle, SafeAreaInsets};
use bevy::prelude::*;
use bevy::window::WindowResized;
/// Tracks how many frames `refresh_insets` has polled. Stored as a
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
@@ -299,11 +301,108 @@ mod android {
) {
for event in lifecycle.read() {
if matches!(event, AppLifecycle::WillResume) {
// Evidence line for #130: winit's Android backend has open
// TODOs around forwarding resume notifications, so whether
// this ever fires on a given device is an open question.
info!("safe_area: AppLifecycle::WillResume received; re-arming inset poll");
poll.0 = 0;
}
}
}
/// Polls the decor view's size via JNI and forces a relayout when it
/// disagrees with Bevy's cached `Window` resolution (#130).
///
/// winit's Android backend does not forward content-rect changes that
/// happen while the app is backgrounded (fold/unfold on foldables), so
/// after a fold cycle Bevy can keep rendering and laying out for the
/// previous screen's dimensions. Unlike `refresh_insets` this poller
/// never settles: it cannot rely on `AppLifecycle::WillResume` to re-arm
/// it, because that event is itself delivered through the same unreliable
/// lifecycle plumbing. A JNI round-trip every `POLL_INTERVAL_FRAMES`
/// frames is cheap.
///
/// On a mismatch it:
/// 1. writes the real size into `window.resolution` so the renderer
/// reconfigures the surface and systems reading `window.width()` see
/// the truth,
/// 2. emits a synthetic `WindowResized` (logical pixels) so
/// `on_window_resized` in `table_plugin` recomputes the board layout,
/// 3. re-arms the inset poller, because a screen change almost always
/// moves the system bars too — covering the "re-poll never fires"
/// hole left open in #116.
pub(super) fn refresh_surface_size(
mut frame: Local<u32>,
mut windows: Query<(Entity, &mut Window)>,
mut resize_events: MessageWriter<WindowResized>,
mut poll: ResMut<SafeAreaPollTries>,
) {
const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps
*frame += 1;
if !frame.is_multiple_of(POLL_INTERVAL_FRAMES) {
return;
}
let Some((entity, mut window)) = windows.iter_mut().next() else {
return;
};
let (decor_w, decor_h) = match query_decor_size() {
Ok(size) => size,
Err(e) => {
// One-time note; the bridge simply isn't up yet during the
// first frames of a launch.
if *frame == POLL_INTERVAL_FRAMES {
warn!("safe_area: decor size query failed (will retry): {e}");
}
return;
}
};
if decor_w == 0 || decor_h == 0 {
return; // decor view not laid out yet
}
// Reads go through `Deref` and do not trip change detection; only
// mutate `window` once a mismatch is confirmed.
let cached_w = window.resolution.physical_width();
let cached_h = window.resolution.physical_height();
if decor_w == cached_w && decor_h == cached_h {
return;
}
info!(
"safe_area: decor view is {decor_w}x{decor_h} but cached resolution is \
{cached_w}x{cached_h}; forcing relayout (fold/unfold missed by winit?)"
);
window.resolution.set_physical_resolution(decor_w, decor_h);
let scale = window.scale_factor();
resize_events.write(WindowResized {
window: entity,
width: decor_w as f32 / scale,
height: decor_h as f32 / scale,
});
poll.0 = 0;
}
/// Physical pixel size of the activity's decor view — the ground truth
/// for the surface we are actually being displayed on, independent of
/// whatever winit last told Bevy.
fn query_decor_size() -> Result<(u32, u32), String> {
use solitaire_data::android_jni;
android_jni::with_activity_env(|env, activity| {
let window = env
.call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
.l()?;
let decor = env
.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
.l()?;
let w = env.call_method(&decor, "getWidth", "()I", &[])?.i()?;
let h = env.call_method(&decor, "getHeight", "()I", &[])?.i()?;
Ok((w.max(0) as u32, h.max(0) as u32))
})
}
fn query_insets() -> Result<SafeAreaInsets, String> {
use solitaire_data::android_jni;
+3 -1
View File
@@ -90,7 +90,9 @@ mod tests {
.and_then(|prefix| prefix.split_whitespace().last())
.and_then(|n| n.parse::<usize>().ok());
parsed.unwrap_or_else(|| {
panic!("ambiguity panic message no longer parseable (Bevy upgrade?): {msg}")
panic!(
"ambiguity panic message no longer parseable (Bevy upgrade?): {msg}"
)
})
}
};
+33 -11
View File
@@ -37,9 +37,9 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::Card;
use solitaire_core::game_state::GameState;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::card_plugin::CardEntityIndex;
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
@@ -487,10 +487,8 @@ fn handle_selection_keys(
1
};
let start = source_cards.len().saturating_sub(count);
let lifted_cards: Vec<Card> = source_cards[start..]
.iter()
.map(|(c, _)| c.clone())
.collect();
let lifted_cards: Vec<Card> =
source_cards[start..].iter().map(|(c, _)| c.clone()).collect();
let Some((bottom, _)) = source_cards.get(start) else {
return;
};
@@ -599,7 +597,10 @@ fn face_up_run_len(cards: &[(Card, bool)]) -> usize {
/// This is intentionally separated from [`best_destination`] so the Enter
/// handler can attempt a foundation move first and fall through to a
/// multi-card stack move rather than accepting a single-card tableau move.
fn try_foundation_dest(card: &Card, game: &GameState) -> Option<KlondikePile> {
fn try_foundation_dest(
card: &Card,
game: &GameState,
) -> Option<KlondikePile> {
let source = game.pile_containing_card(card.clone())?;
for foundation in [
Foundation::Foundation1,
@@ -696,7 +697,13 @@ fn update_selection_highlight(
if let Some(ref pile) = source_pile
&& let Some(card) = top_face_up_card(pile, &game.0)
{
spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, source_color);
spawn_highlight_on_card(
&mut commands,
&card_index,
&card,
card_size,
source_color,
);
}
// Destination highlight while lifted.
@@ -707,7 +714,13 @@ fn update_selection_highlight(
// in destination-pick mode and the focused index is observable
// via the resource.
if let Some(card) = top_face_up_card(dest, &game.0) {
spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, dest_color);
spawn_highlight_on_card(
&mut commands,
&card_index,
&card,
card_size,
dest_color,
);
}
}
}
@@ -1034,7 +1047,10 @@ mod tests {
press_key(&mut app, KeyCode::Tab);
app.update();
let selected = app.world().resource::<SelectionState>().selected_pile;
let selected = app
.world()
.resource::<SelectionState>()
.selected_pile;
// The cycle order starts at Waste, but Waste is empty so the next
// available pile (Tableau(0)) is selected.
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
@@ -1200,10 +1216,16 @@ mod tests {
drag.active_touch_id = None;
}
let before = app.world().resource::<SelectionState>().selected_pile;
let before = app
.world()
.resource::<SelectionState>()
.selected_pile;
press_key(&mut app, KeyCode::Tab);
app.update();
let after = app.world().resource::<SelectionState>().selected_pile;
let after = app
.world()
.resource::<SelectionState>()
.selected_pile;
assert_eq!(
before, after,
+4 -2
View File
@@ -1500,8 +1500,10 @@ mod tests {
#[test]
fn zen_win_event_updates_zen_best_score_only() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.mode =
solitaire_core::game_state::GameMode::Zen;
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.mode = solitaire_core::game_state::GameMode::Zen;
app.world_mut().write_message(GameWonEvent {
score: 1800,
+6 -4
View File
@@ -301,9 +301,9 @@ fn push_on_exit(
exit_events.clear();
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
let result = rt.0.block_on(async {
tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await
});
let result = rt
.0
.block_on(async { tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await });
match result {
Ok(Ok(_)) | Ok(Err(SyncError::UnsupportedPlatform)) => {}
Ok(Err(e)) => warn!("sync push on exit failed: {e}"),
@@ -667,7 +667,9 @@ mod tests {
);
// In-memory contract: replays[0].share_url is now Some(url).
let live = app.world().resource::<ReplayHistoryResource>();
let live = app
.world()
.resource::<ReplayHistoryResource>();
assert_eq!(
live.0.replays.first().and_then(|r| r.share_url.clone()),
Some(url.clone()),
+11 -12
View File
@@ -7,8 +7,8 @@
use bevy::prelude::*;
use bevy::window::WindowResized;
use solitaire_core::KlondikePile;
use solitaire_core::Suit;
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::Suit;
use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
@@ -385,11 +385,7 @@ fn on_window_resized(
>,
mut marker_outlines: Query<
&mut Sprite,
(
Without<PileMarker>,
Without<TableBackground>,
Without<Text2d>,
),
(Without<PileMarker>, Without<TableBackground>, Without<Text2d>),
>,
mut marker_labels: Query<&mut TextFont, With<Text2d>>,
) {
@@ -441,7 +437,8 @@ fn on_window_resized(
// must be re-derived here too or a resize (fold/unfold, rotation)
// leaves them at the stale size — visible as oversized grey
// frames on empty piles.
let outline_size = new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
let outline_size =
new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
let font_size = new_layout.card_size.x * 0.28;
for child in children.into_iter().flatten() {
if let Ok(mut outline) = marker_outlines.get_mut(*child) {
@@ -596,6 +593,8 @@ fn pile_cards(
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -704,10 +703,7 @@ mod tests {
}
}
assert_eq!(outlines, 12, "all 12 markers carry an outline child");
assert!(
labels >= 11,
"tableau + foundation markers carry watermarks"
);
assert!(labels >= 11, "tableau + foundation markers carry watermarks");
}
#[test]
@@ -943,7 +939,10 @@ mod tests {
#[test]
fn suit_symbol_all_four_are_distinct() {
let symbols: Vec<&str> = Suit::SUITS.iter().map(suit_symbol).collect();
let symbols: Vec<&str> = Suit::SUITS
.iter()
.map(suit_symbol)
.collect();
let unique: std::collections::HashSet<&&str> = symbols.iter().collect();
assert_eq!(unique.len(), 4, "all four suit symbols must be distinct");
}
+27 -60
View File
@@ -116,32 +116,24 @@ impl Plugin for ThemePlugin {
}
}
/// Resolves the manifest URL for `theme_id`: the bundled themes
/// (`"dark"`, `"classic"`) load from the embedded source; any other id
/// is a user theme served from the `themes://` directory source.
fn theme_manifest_url(theme_id: &str) -> String {
bundled_theme_url(theme_id)
.map(str::to_string)
.unwrap_or_else(|| format!("themes://{theme_id}/theme.ron"))
}
/// Kicks off the initial theme load — the one named by
/// `Settings::selected_theme_id` if available, falling back to the
/// fresh-install default from `solitaire_data::Settings`. The actual
/// rasterisation runs asynchronously on the asset task pool; the sync
/// system below picks up the `LoadedWithDependencies` event when every
/// face + back is ready.
/// embedded default. The actual rasterisation runs asynchronously on
/// the asset task pool; the sync system below picks up the
/// `LoadedWithDependencies` event when every face + back is ready.
fn load_initial_theme(
asset_server: Res<AssetServer>,
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
mut commands: Commands,
) {
let default_id = solitaire_data::Settings::default().selected_theme_id;
let id = settings
.as_deref()
.map(|s| s.0.selected_theme_id.as_str())
.unwrap_or(&default_id);
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(id));
.unwrap_or("classic");
let url = bundled_theme_url(id)
.map(str::to_string)
.unwrap_or_else(|| format!("themes://{id}/theme.ron"));
let handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle));
}
@@ -170,7 +162,10 @@ fn react_to_settings_theme_change(
return;
}
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(new_id));
let url = bundled_theme_url(new_id)
.map(str::to_string)
.unwrap_or_else(|| format!("themes://{new_id}/theme.ron"));
let handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle));
}
@@ -257,11 +252,12 @@ fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet
image_set.theme_back = Some(theme.back.clone());
}
/// Switches the active theme to `theme_id` — the embedded source for
/// the bundled `"dark"` / `"classic"` themes, `themes://` for user
/// themes. Returns the new `Handle<CardTheme>` so callers can poll
/// `Assets<CardTheme>` if they want to wait for the load before
/// changing UI state.
/// Switches the active theme to the one served at
/// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>`
/// so callers can poll `Assets<CardTheme>` if they want to wait for
/// the load before changing UI state.
///
/// The handle is also written to the [`ActiveTheme`] resource — the
/// per-frame sync system picks up the `LoadedWithDependencies` event
@@ -272,7 +268,8 @@ pub fn set_theme(
asset_server: &AssetServer,
theme_id: &str,
) -> Handle<CardTheme> {
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(theme_id));
let url = format!("themes://{theme_id}/theme.ron");
let handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle.clone()));
handle
}
@@ -480,43 +477,13 @@ mod tests {
}
#[test]
fn theme_manifest_url_routes_bundled_ids_to_embedded_source() {
// The bundled themes live in the binary, not in the user
// themes directory — resolving them to `themes://` would
// NotFound and silently leave the previous theme active.
assert_eq!(
theme_manifest_url("dark"),
crate::assets::DARK_THEME_MANIFEST_URL
);
assert_eq!(
theme_manifest_url("classic"),
crate::assets::CLASSIC_THEME_MANIFEST_URL
);
}
#[test]
fn theme_manifest_url_routes_user_ids_to_themes_source() {
// We assert the URL shape so a future refactor doesn't
// accidentally change the path layout of the user-themes
// source.
assert_eq!(
theme_manifest_url("user_uploaded"),
"themes://user_uploaded/theme.ron"
);
}
#[test]
fn initial_theme_fallback_matches_settings_default() {
// `load_initial_theme` falls back to the data-crate default
// when `SettingsResource` is absent. That default must always
// name a bundled theme, or a fresh minimal setup would resolve
// to a nonexistent user theme (see the v0.33.0 "classic"
// fallback drift in CHANGELOG.md).
let default_id = solitaire_data::Settings::default().selected_theme_id;
assert!(
bundled_theme_url(&default_id).is_some(),
"default theme id {default_id:?} must be a bundled theme"
);
fn set_theme_url_format_matches_themes_source() {
// The format string is the only behavioural surface of
// set_theme that doesn't require an App. We assert the URL
// shape so a future refactor doesn't accidentally change the
// path layout.
let url = format!("themes://{}/theme.ron", "user_uploaded");
assert_eq!(url, "themes://user_uploaded/theme.ron");
}
/// Test 1: the bundled dark theme always has embedded SVG bytes
@@ -28,8 +28,8 @@
use bevy::ecs::message::MessageReader;
use bevy::prelude::*;
use solitaire_core::Card;
use solitaire_core::KlondikePile;
use solitaire_core::Card;
use crate::card_plugin::CardEntity;
use crate::events::StateChangedEvent;
+4 -1
View File
@@ -304,7 +304,10 @@ impl HighContrastBackground {
/// [`BORDER_SUBTLE_HC`]. Currently used by the WIN MOVE scrub-bar
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
/// than to a neutral gray.
pub const fn with_hc(default_color: Color, hc_color: Color) -> Self {
pub const fn with_hc(
default_color: Color,
hc_color: Color,
) -> Self {
Self {
default_color,
hc_color,
+12 -12
View File
@@ -1649,62 +1649,62 @@ function __wbg_get_imports() {
return ret;
},
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 61868, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 61918, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7314, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7364, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6);
return ret;
},
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7);
return ret;
},
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8);
return ret;
},
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9);
return ret;
},
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7312, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7362, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1);
return ret;
},
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7313, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7363, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c);
return ret;
},
Binary file not shown.
Binary file not shown.
+15 -26
View File
@@ -19,14 +19,11 @@
//! is the contract.
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use solitaire_core::error::MoveError;
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{
DrawStockConfig,
game_state::{GameMode, GameState},
};
use solitaire_core::{KlondikeInstruction, KlondikePile};
use serde::{Deserialize, Serialize};
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::error::MoveError;
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
use wasm_bindgen::prelude::*;
/// Mirrors `solitaire_data::Replay` v3.
@@ -148,8 +145,8 @@ impl ReplayPlayer {
let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
self.game.pile(t).iter().map(CardSnapshot::from).collect()
};
let foundations: [Vec<CardSnapshot>; 4] =
solitaire_core::FOUNDATIONS.map(|f| pile_cards(KlondikePile::Foundation(f)));
let foundations: [Vec<CardSnapshot>; 4] = solitaire_core::FOUNDATIONS
.map(|f| pile_cards(KlondikePile::Foundation(f)));
let tableaus: [Vec<CardSnapshot>; 7] =
solitaire_core::TABLEAUS.map(|t| pile_cards(KlondikePile::Tableau(t)));
StateSnapshot {
@@ -345,7 +342,8 @@ fn legal_moves_for_game(game: &GameState) -> Vec<DebugMove> {
fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport {
let stock = game.stock_cards();
let waste = game.waste_cards();
let foundations = solitaire_core::FOUNDATIONS.map(|f| game.pile(KlondikePile::Foundation(f)));
let foundations =
solitaire_core::FOUNDATIONS.map(|f| game.pile(KlondikePile::Foundation(f)));
let tableaus = solitaire_core::TABLEAUS.map(|t| game.pile(KlondikePile::Tableau(t)));
let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new();
@@ -405,8 +403,7 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
false
});
let soft_lock =
!game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty();
let soft_lock = !game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty();
let state_ok = duplicate_cards.is_empty()
&& missing_cards.is_empty()
@@ -468,7 +465,8 @@ impl SolitaireGame {
.iter()
.map(CardSnapshot::from)
.collect(),
foundations: solitaire_core::FOUNDATIONS.map(|f| cards(KlondikePile::Foundation(f))),
foundations: solitaire_core::FOUNDATIONS
.map(|f| cards(KlondikePile::Foundation(f))),
tableaus: solitaire_core::TABLEAUS.map(|t| cards(KlondikePile::Tableau(t))),
}
}
@@ -890,9 +888,7 @@ mod tests {
}
let idx = pick_move_index(&legal_moves).unwrap_or_default();
if let Err(e) = game.apply_legal_move_native(idx) {
panic!(
"failed to advance game before replay export (seed={seed}, step={step}, idx={idx}): {e}"
);
panic!("failed to advance game before replay export (seed={seed}, step={step}, idx={idx}): {e}");
}
}
@@ -1077,14 +1073,9 @@ mod tests {
let stock_before = game.game.stock_cards().len();
let waste_before = game.game.waste_cards().len();
assert!(
stock_before > 0,
"seed {seed}: stock must be non-empty at start"
);
assert!(stock_before > 0, "seed {seed}: stock must be non-empty at start");
game.game
.draw()
.expect("draw must succeed when stock is non-empty");
game.game.draw().expect("draw must succeed when stock is non-empty");
assert_eq!(
game.game.stock_cards().len(),
@@ -1113,9 +1104,7 @@ mod tests {
"seed {seed}: stock must have at least 3 cards for this test"
);
game.game
.draw()
.expect("draw must succeed when stock has cards");
game.game.draw().expect("draw must succeed when stock has cards");
let expected_drawn = stock_before.min(3);
assert_eq!(
+2 -1
View File
@@ -41,7 +41,8 @@ pub fn start() {
// texture-dimension limit is now taken from the adapter (see
// the RenderPlugin below), so this is purely a quality/perf
// choice, no longer a crash-avoidance hack.
resolution: WindowResolution::default().with_scale_factor_override(1.0),
resolution: WindowResolution::default()
.with_scale_factor_override(1.0),
..default()
}),
..default()