Compare commits

...

14 Commits

Author SHA1 Message Date
funman300 2c2f0b592a feat(core): Spider rules as a second card_game::Game implementation
Test / test (pull_request) Successful in 7m37s
Two-deck (104-card) Spider on the upstream Stack/Pile containers,
exercising the multi-deck Card encoding for the first time:

- SpiderGame: 10-pile deal (4x6 + 6x5), build-down-any-suit,
  same-suit-run pickup, deal-10 gated on no empty pile, automatic
  K->A run removal, win at 8 runs
- SpiderSuits difficulty (1/2/4 suits over the same 104 cards);
  1- and 2-suit games contain identical Card values by construction
  (documented — engine entity mapping will need positional keys)
- Seeded deals via inline SplitMix64 + Fisher-Yates (core has no
  rand dep; Spider's seed space is deliberately self-contained)
- SpiderGameState session wrapper mirroring GameState conventions:
  Result<_, MoveError> mutations, upstream undo/score bookkeeping,
  Microsoft-style scoring (500 base, -1/move, +100/run, -1/undo)
- 17 unit tests + card-conservation/validity proptest over random
  legal walks; stacked-deal win-path test included

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:20:51 -07:00
funman300 6f27e775f2 chore(deploy): mount theme store at /data/theme_store
Points THEME_STORE_DIR at the existing solitaire-db PVC so catalog
content survives pod restarts. Scan is startup-only; restart the
deployment after adding zips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:47:28 -07:00
Gitea CI 358bbc7eb5 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m23s
Web E2E / web-e2e (push) Successful in 3m57s
2026-07-07 21:17:01 +00:00
funman300 444f8d7e33 Merge pull request 'feat: in-game theme store — server catalog + verified downloads + install UI' (#154) from feat/theme-store into master
Build and Deploy / build-and-push (push) Successful in 6m2s
Test / test (push) Failing after 8m38s
Web E2E / web-e2e (push) Successful in 6m55s
Web WASM Rebuild / rebuild (push) Successful in 9m25s
2026-07-07 20:23:52 +00:00
funman300 a80547c514 Merge pull request 'fix: July 7 code-review remediation (M1–M3, L1–L3)' (#153) from fix/review-2026-07-07 into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 5m56s
Test / test (push) Successful in 13m51s
Web E2E / web-e2e (push) Successful in 4m18s
2026-07-07 20:16:19 +00:00
funman300 d87397b382 feat: in-game theme store — server catalog + verified downloads + install UI
Test / test (pull_request) Successful in 10m15s
Phase 1+2 of the theme-store roadmap: a free catalog served by
solitaire_server and an in-app browse/install flow, making custom
themes installable on Android for the first time (the manual
drop-a-zip flow can't reach the app-private themes dir there).

- solitaire_sync: ThemeCatalogEntry/ThemeCatalogResponse wire types
  (additive module; SyncPayload and SyncProvider untouched)
- solitaire_server: THEME_STORE_DIR scan at startup (meta-only
  theme.ron parse, sha256, 20 MiB cap, best-effort per archive);
  public GET /api/themes, /api/themes/{id}/download, /{id}/preview;
  compose volume + README_SERVER docs
- solitaire_data: ThemeStoreClient — catalog fetch + download with
  mandatory size/sha256 verification before bytes are released
- solitaire_engine: ThemeStorePlugin — 'Browse theme store' button in
  Settings → Cosmetic, modal catalog (leaderboard-style rebuild),
  download on AsyncComputeTaskPool, atomic .tmp+rename write into
  user_theme_dir, then the existing hardened import_theme pipeline and
  an in-place registry refresh

New deps: sha2 (workspace, server+data); ron/zip reused in server;
serde_json added to solitaire_sync dev-deps for DTO round-trip tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:12:41 -07:00
funman300 018b69285d fix(engine): unify light/dark theme manifest URL resolution
Test / test (pull_request) Successful in 13m4s
- set_theme resolved every id to themes:// — the bundled dark/classic
  themes live at embedded://, so switching to them via the public API
  would NotFound and silently keep the old theme. All three load paths
  now share one theme_manifest_url resolver.
- load_initial_theme's settings-absent fallback was "classic", a
  leftover from v0.33 when classic was the default; it now derives from
  Settings::default() so it can't drift from the data crate again, with
  a test pinning the default id to a bundled theme.
- settings.rs doc no longer claims "classic" is migrated to "dark";
  only the pre-rename "default" id is rewritten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:25:35 -07:00
Gitea CI a4ad848c93 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 5m28s
Web E2E / web-e2e (push) Successful in 4m12s
2026-07-07 18:54:30 +00:00
funman300 abf1312cf5 fix(data): serialise token refreshes so overlapping 401s can't force a logout
Test / test (pull_request) Successful in 15m33s
Refresh tokens are single-use since the rotation change (PR #136). Two
in-flight requests hitting 401 together (replay upload racing a manual
sync) both called /api/auth/refresh; the second presented an already-
consumed token, got rejected, and surfaced as a spurious 'session
expired' re-login prompt.

refresh_token() now takes the stale access token that earned the 401
and runs behind a tokio::sync::Mutex; a loser of the lock race that
finds the stored token already rotated returns Ok and retries with it
instead of spending the new refresh token.

Untested caveat: exercising the race in a unit test needs an in-memory
auth_tokens double (the real keyring is unavailable on headless Linux
runners) — noted for a future test-support addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:04:30 -07:00
funman300 8b09c51271 docs(architecture): correct KlondikePile sketch — upstream has no Waste variant
§8 documented a Waste variant that doesn't exist in klondike 0.4; the
real convention (Stock denotes the waste pile in pile-coordinate space)
is now stated where the enum is sketched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:01:42 -07:00
funman300 f61573513c fix(engine): remove latent unreachable! panic in difficulty seed cursor
The Random arm was only unreachable because seeds_for(Random) returns
None in a different function — a future catalog change would turn it
into a shipped runtime panic. Returning a system-time seed is the
correct Random behaviour either way. CLAUDE.md §2.3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:01:25 -07:00
funman300 757c35e4a0 fix(engine): degrade gracefully when the platform data dir is missing
user_theme_dir() panicked on desktop when dirs::data_dir() returned
None (broken $HOME / $XDG_*). Return an empty path instead — exactly
what the wasm32 branch already did — so theme discovery reports 'no
user themes' and the bundled default keeps working. Warns once with
the set_user_theme_dir() workaround. CLAUDE.md §2.3: no panic! in
runtime logic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:00:20 -07:00
funman300 113a933170 style: cargo fmt under rustfmt 1.9 and gate formatting in CI
The repo was formatted under an older stable; rustfmt 1.9 (Rust 1.95)
wraps signatures and call sites differently, so every touched file was
picking up unrelated formatting hunks. One mechanical pass, and a
'cargo fmt --check' step in the test workflow (same pinned 1.95.0
toolchain) so drift can't accumulate again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:59:20 -07:00
funman300 18bb1fa0be fix(ci): cap test-build debuginfo so the linker fits runner memory
Two runs (447, 486) died with 'ld terminated with signal 9' linking the
solitaire_engine test binary — the runner OOMs on full dev debuginfo for
the Bevy dependency graph. line-tables-only preserves file:line panic
backtraces at a fraction of the link footprint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:58:43 -07:00
78 changed files with 3157 additions and 375 deletions
+11 -1
View File
@@ -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)
+6 -2
View File
@@ -592,11 +592,15 @@ pub struct Session<G> { /* 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+) */ }
Generated
+8
View File
@@ -7351,13 +7351,16 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"sha2",
"solitaire_core",
"solitaire_server",
"solitaire_sync",
"sqlx",
"tempfile",
"thiserror 2.0.18",
"tokio",
"uuid",
"zip",
]
[[package]]
@@ -7402,10 +7405,13 @@ dependencies = [
"chrono",
"dotenvy",
"jsonwebtoken",
"ron",
"serde",
"serde_json",
"sha2",
"solitaire_sync",
"sqlx",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tower",
@@ -7414,6 +7420,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"zip",
]
[[package]]
@@ -7422,6 +7429,7 @@ version = "0.1.0"
dependencies = [
"chrono",
"serde",
"serde_json",
"thiserror 2.0.18",
"uuid",
]
+1
View File
@@ -47,6 +47,7 @@ dirs = "6"
keyring = "4"
keyring-core = "1"
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
sha2 = "0.10"
arboard = { version = "3", default-features = false }
jni = { version = "0.21", default-features = false }
+20
View File
@@ -44,6 +44,26 @@ docker compose up -d
```
## Theme store
The server can offer card-art themes for in-game download. Drop theme
`.zip` archives (the same format the game's Settings → Import accepts:
a `theme.ron` manifest plus 53 SVGs) into the directory named by
`THEME_STORE_DIR` (default: `theme_store/` next to the binary), then
restart the server — the catalog is scanned once at startup. An
optional `<theme-id>.png` in the same directory becomes the theme's
store preview.
Endpoints (public, no auth):
- `GET /api/themes` — catalog JSON (id, name, author, size, sha256)
- `GET /api/themes/<id>/download` — the archive
- `GET /api/themes/<id>/preview` — the preview PNG, if present
Archives that are oversized (> 20 MiB), unreadable, or have a
malformed `theme.ron` are skipped with a warning in the server log;
they never fail startup.
## Admin — Password Reset
If a player loses access to their account, the server binary includes a
+5
View File
@@ -34,6 +34,11 @@ spec:
key: jwt-secret
- name: SERVER_PORT
value: "8080"
# Theme-store catalog directory on the persistent volume.
# Scanned once at startup — after dropping new theme zips
# into /data/theme_store, restart the deployment.
- name: THEME_STORE_DIR
value: /data/theme_store
volumeMounts:
- name: db-data
mountPath: /data
+5
View File
@@ -6,8 +6,13 @@ services:
# Override DATABASE_URL so the DB always lands in the persistent volume,
# regardless of what .env contains.
DATABASE_URL: sqlite:///data/solitaire.db
# Theme-store catalog directory (scanned once at startup; the
# host ./theme_store folder is where the operator drops theme
# zips + preview PNGs).
THEME_STORE_DIR: /theme_store
volumes:
- ./data:/data
- ./theme_store:/theme_store:ro
restart: unless-stopped
expose:
- "${SERVER_PORT:-8080}"
+26 -22
View File
@@ -439,10 +439,7 @@ 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()
}
@@ -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(_))));
}
+11 -1
View File
@@ -3,6 +3,7 @@ pub mod error;
pub mod game_state;
pub mod klondike_adapter;
pub mod scoring;
pub mod spider;
// Re-export the upstream types that cross the solitaire_core API boundary so
// downstream crates (engine, wasm) can import from one place without a direct
@@ -13,12 +14,21 @@ 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.
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
// Spider rules (second `card_game::Game` implementation; engine UI is a
// later phase — nothing outside solitaire_core consumes these yet).
pub use spider::{
SpiderConfig, SpiderGame, SpiderGameState, SpiderInstruction, SpiderScoring, SpiderStats,
SpiderSuits,
};
/// All four foundation slots, in slot order.
///
/// Canonical iteration source for `Foundation` — upstream `klondike` has no
+9 -2
View File
@@ -41,13 +41,20 @@ 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.
+952
View File
@@ -0,0 +1,952 @@
//! Spider solitaire rules — a second [`card_game::Game`] implementation
//! alongside the upstream Klondike.
//!
//! Built directly on the upstream `card_game` containers: two decks
//! ([`Deck::Deck1`] + [`Deck::Deck2`], 104 cards) dealt into ten
//! [`Pile`]s, exercising the multi-deck [`Card`] encoding and the
//! `Stack`/`Pile` public API that `klondike` uses internally.
//!
//! ## Rules implemented
//!
//! - 10 tableau piles: the first 4 receive 6 cards, the last 6 receive
//! 5 (top card face-up) — 54 dealt, 50 in stock.
//! - Build down regardless of suit; only same-suit descending runs may
//! be picked up and moved.
//! - Empty piles accept any card or movable run.
//! - The stock deals one card to every pile (10 total), only while no
//! pile is empty.
//! - A completed K→A same-suit run is removed automatically; the game
//! is won when all 8 runs are removed.
//! - Difficulty via suit count ([`SpiderSuits`]): 1, 2, or 4 suits
//! spread over the same 104 cards.
//!
//! ## Determinism
//!
//! Deals are seeded with an inline SplitMix64 + FisherYates shuffle:
//! `solitaire_core` has no `rand` dependency (and adding one needs
//! explicit approval), so Spider's seed space is deliberately
//! self-contained rather than shared with Klondike's `StdRng` seeds.
//! The same seed + suit count always produces the same deal.
//!
//! ## Card identity caveat (engine integration, later phase)
//!
//! [`Card`] packs deck/suit/rank into one byte, and 1- and 2-suit
//! games need more than four copies of a suit, so *identical* `Card`
//! values legitimately coexist (e.g. eight `♠A` in a 1-suit game,
//! spread over `Deck1..Deck4` twice). Rules only compare suit/rank so
//! core is unaffected, but the engine's `Card → Entity` mapping is
//! keyed by card value and will need positional keys before a Spider
//! UI lands.
use card_game::{Card, Deck, Game, Pile, Rank, Session, SessionConfig, Stack, Suit};
use serde::{Deserialize, Serialize};
use crate::error::MoveError;
/// Number of tableau piles.
pub const SPIDER_TABLEAUS: usize = 10;
/// Total cards in play (two decks).
pub const SPIDER_DECK_SIZE: usize = 104;
/// Cards left in the stock after the opening deal (5 deals × 10).
const STOCK_SIZE: usize = 50;
/// Cards in one completed run (K → A).
const RUN_LEN: usize = 13;
/// Runs required to win.
const TOTAL_RUNS: u8 = 8;
/// Face-down cards in the deepest opening pile.
const MAX_FACE_DOWN: usize = 5;
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
/// Spider difficulty: how many distinct suits the 104 cards span.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum SpiderSuits {
/// All spades — the beginner layout (default).
#[default]
One,
/// Spades + hearts.
Two,
/// Full two-deck spread, the classic four-suit game.
Four,
}
impl SpiderSuits {
/// Suits used at this difficulty.
fn suits(self) -> &'static [Suit] {
match self {
Self::One => &[Suit::Spades],
Self::Two => &[Suit::Spades, Suit::Hearts],
Self::Four => &[Suit::Spades, Suit::Hearts, Suit::Clubs, Suit::Diamonds],
}
}
}
/// Scoring knobs, mirroring the shape of `klondike::ScoringConfig`.
///
/// Defaults follow the familiar Microsoft formula: start at 500, 1
/// per move, +100 per completed run (the upstream session adds
/// `undos × undo_penalty` on top).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpiderScoring {
/// Score a fresh deal starts from.
pub base: i32,
/// Added per move (conventionally negative).
pub move_penalty: i32,
/// Added per completed K→A run.
pub run_bonus: i32,
}
impl Default for SpiderScoring {
fn default() -> Self {
Self {
base: 500,
move_penalty: -1,
run_bonus: 100,
}
}
}
/// Full Spider rules configuration (the `Game::Config` type).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SpiderConfig {
/// Suit-count difficulty.
pub suits: SpiderSuits,
/// Scoring knobs.
pub scoring: SpiderScoring,
}
// ---------------------------------------------------------------------------
// Stats
// ---------------------------------------------------------------------------
/// Per-game counters (the `Game::Stats` type). Cumulative — they are
/// not rolled back by undo, matching upstream `KlondikeStats`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SpiderStats {
moves: u32,
deals: u32,
runs_completed: u32,
}
impl SpiderStats {
/// Total instructions processed (moves + deals).
pub const fn moves(&self) -> u32 {
self.moves
}
/// Stock deals performed.
pub const fn deals(&self) -> u32 {
self.deals
}
/// K→A runs completed over the whole game (not undone-adjusted).
pub const fn runs_completed(&self) -> u32 {
self.runs_completed
}
}
// ---------------------------------------------------------------------------
// Instruction
// ---------------------------------------------------------------------------
/// One atomic Spider action (the `Game::Instruction` type).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SpiderInstruction {
/// Deal one card from the stock onto every tableau pile.
Deal,
/// Move the top `count` face-up cards (a same-suit descending run)
/// from pile `from` to pile `to`. Pile indices are `0..10`.
Move {
/// Source pile index.
from: u8,
/// Destination pile index.
to: u8,
/// Number of cards in the moved run (≥ 1).
count: u8,
},
}
// ---------------------------------------------------------------------------
// Game state
// ---------------------------------------------------------------------------
/// Pure Spider game position: ten tableaus, the stock, and the count
/// of completed runs. Everything else (undo, score bookkeeping) lives
/// in the wrapping [`Session`].
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SpiderGame {
tableaus: [Pile<MAX_FACE_DOWN, SPIDER_DECK_SIZE>; SPIDER_TABLEAUS],
stock: Stack<STOCK_SIZE>,
completed_runs: u8,
}
/// SplitMix64 step — small, well-known, and dependency-free. Spider
/// only needs a deterministic shuffle, not cryptographic quality.
const fn splitmix64(state: u64) -> (u64, u64) {
let state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
(state, z ^ (z >> 31))
}
/// In-place FisherYates driven by SplitMix64. The modulo bias is
/// astronomically small for n ≤ 104 and irrelevant for gameplay.
fn shuffle(cards: &mut [Card], seed: u64) {
let mut state = seed;
for i in (1..cards.len()).rev() {
let (next_state, r) = splitmix64(state);
state = next_state;
#[allow(clippy::cast_possible_truncation)]
let j = (r % (i as u64 + 1)) as usize;
cards.swap(i, j);
}
}
/// Builds the 104-card Spider deck for a suit-count difficulty.
///
/// Deck ids spread copies apart where possible (`Deck1..Deck4`), but
/// 1- and 2-suit games necessarily contain identical `Card` values —
/// see the module docs.
fn build_deck(suits: SpiderSuits) -> Vec<Card> {
let suit_set = suits.suits();
let copies = SPIDER_DECK_SIZE / (suit_set.len() * RUN_LEN);
let decks = [Deck::Deck1, Deck::Deck2, Deck::Deck3, Deck::Deck4];
let mut cards = Vec::with_capacity(SPIDER_DECK_SIZE);
for copy in 0..copies {
for &suit in suit_set {
for rank in Rank::RANKS {
cards.push(Card::new(decks[copy % decks.len()], suit, rank));
}
}
}
cards
}
impl SpiderGame {
/// Deals a new seeded game at the given suit difficulty.
pub fn with_seed(seed: u64, suits: SpiderSuits) -> Self {
let mut deck = build_deck(suits);
shuffle(&mut deck, seed);
let mut cards = deck.into_iter();
let tableaus = core::array::from_fn(|index| {
// Piles 03 open with 5 face-down cards, piles 49 with 4;
// one face-up card lands on each afterwards.
let down_count = if index < 4 { 5 } else { 4 };
let stack: Stack<MAX_FACE_DOWN> = cards.by_ref().take(down_count).collect();
let mut pile = Pile::new_face_down(stack);
if let Some(card) = cards.next() {
pile.push(card);
}
pile
});
let stock: Stack<STOCK_SIZE> = cards.collect();
Self {
tableaus,
stock,
completed_runs: 0,
}
}
/// Face-up cards of pile `index` (bottom → top). Empty slice for
/// out-of-range indices.
pub fn tableau_face_up(&self, index: usize) -> &[Card] {
self.tableaus.get(index).map_or(&[], |pile| pile.face_up())
}
/// Face-down cards of pile `index` (bottom → top).
pub fn tableau_face_down(&self, index: usize) -> &[Card] {
self.tableaus
.get(index)
.map_or(&[], |pile| pile.face_down())
}
/// Cards remaining in the stock.
pub fn stock_len(&self) -> usize {
self.stock.len()
}
/// Completed K→A runs removed from play so far.
pub const fn completed_runs(&self) -> u8 {
self.completed_runs
}
/// Length of the longest movable run on top of pile `index`: the
/// maximal same-suit, strictly-descending face-up suffix.
fn movable_run_len(&self, index: usize) -> usize {
let Some(pile) = self.tableaus.get(index) else {
return 0;
};
let up = pile.face_up();
let mut len = usize::from(!up.is_empty());
while len < up.len() {
let above = &up[up.len() - len];
let below = &up[up.len() - len - 1];
let descends =
below.suit() == above.suit() && below.rank() as u8 == above.rank() as u8 + 1;
if !descends {
break;
}
len += 1;
}
len
}
/// Whether `Move { from, to, count }` is legal in this position.
fn is_move_valid(&self, from: u8, to: u8, count: u8) -> bool {
let (from, to, count) = (from as usize, to as usize, count as usize);
if from == to || from >= SPIDER_TABLEAUS || to >= SPIDER_TABLEAUS || count == 0 {
return false;
}
if count > self.movable_run_len(from) {
return false;
}
let src_up = self.tableaus[from].face_up();
// Bottom card of the moved run; `count <= movable_run_len <=
// src_up.len()` guarantees the index is in range.
let Some(moved_bottom) = src_up.get(src_up.len() - count) else {
return false;
};
match self.tableaus[to].face_up().last() {
// Build down regardless of suit.
Some(dest_top) => dest_top.rank() as u8 == moved_bottom.rank() as u8 + 1,
// Empty pile accepts anything (face-down remnant can't
// exist without a face-up card — Pile flips eagerly).
None => self.tableaus[to].is_empty(),
}
}
/// Whether the stock may deal: cards remain and no pile is empty.
fn is_deal_valid(&self) -> bool {
!self.stock.is_empty() && self.tableaus.iter().all(|pile| !pile.is_empty())
}
/// Removes a completed K→A same-suit run from the top of pile
/// `index`, if one is present. Returns `true` when a run was
/// removed (and the next face-down card, if any, was flipped).
fn sweep_completed_run(&mut self, index: usize) -> bool {
let Some(pile) = self.tableaus.get_mut(index) else {
return false;
};
let up = pile.face_up();
if up.len() < RUN_LEN {
return false;
}
let run = &up[up.len() - RUN_LEN..];
let suit = run[0].suit();
let is_complete = run.iter().enumerate().all(|(offset, card)| {
card.suit() == suit && card.rank() as u8 == (RUN_LEN - offset) as u8
});
if !is_complete {
return false;
}
let start = up.len() - RUN_LEN;
let (_removed, _flipped) = pile.take_range_flip_up(start..);
true
}
}
impl Game for SpiderGame {
type Score = i32;
type Stats = SpiderStats;
type Config = SpiderConfig;
type Instruction = SpiderInstruction;
fn score(&self, stats: &Self::Stats, config: &Self::Config) -> Self::Score {
let scoring = &config.scoring;
let moves = i32::try_from(stats.moves).unwrap_or(i32::MAX);
let runs = i32::from(self.completed_runs);
scoring.base + moves.saturating_mul(scoring.move_penalty) + runs * scoring.run_bonus
}
fn possible_instructions(
&self,
config: &Self::Config,
) -> impl Iterator<Item = Self::Instruction> + use<> {
let mut out = Vec::new();
if self.is_deal_valid() {
out.push(SpiderInstruction::Deal);
}
for from in 0..SPIDER_TABLEAUS {
let max_run = self.movable_run_len(from);
for count in 1..=max_run {
for to in 0..SPIDER_TABLEAUS {
#[allow(clippy::cast_possible_truncation)]
let instruction = SpiderInstruction::Move {
from: from as u8,
to: to as u8,
count: count as u8,
};
if self.is_move_valid(from as u8, to as u8, count as u8) {
out.push(instruction);
}
}
}
}
let _ = config;
out.into_iter()
}
fn is_instruction_valid(&self, _config: &Self::Config, instruction: Self::Instruction) -> bool {
match instruction {
SpiderInstruction::Deal => self.is_deal_valid(),
SpiderInstruction::Move { from, to, count } => self.is_move_valid(from, to, count),
}
}
fn process_instruction(
&mut self,
stats: &mut Self::Stats,
config: &Self::Config,
instruction: Self::Instruction,
) {
// The trait offers no error channel; an invalid instruction is
// a no-op rather than a panic (error policy: no panics in game
// logic). Callers route through `SpiderGameState`, which
// validates first and surfaces `MoveError`.
if !self.is_instruction_valid(config, instruction) {
return;
}
stats.moves += 1;
match instruction {
SpiderInstruction::Deal => {
stats.deals += 1;
for index in 0..SPIDER_TABLEAUS {
match self.stock.pop() {
Some(card) => self.tableaus[index].push(card),
None => break,
}
}
for index in 0..SPIDER_TABLEAUS {
if self.sweep_completed_run(index) {
self.completed_runs += 1;
stats.runs_completed += 1;
}
}
}
SpiderInstruction::Move { from, to, count } => {
let (from, to, count) = (from as usize, to as usize, count as usize);
let src_len = self.tableaus[from].face_up().len();
let (cards, _flipped) = self.tableaus[from].take_range_flip_up(src_len - count..);
self.tableaus[to].extend(cards);
if self.sweep_completed_run(to) {
self.completed_runs += 1;
stats.runs_completed += 1;
}
}
}
}
fn is_win(&self) -> bool {
self.completed_runs >= TOTAL_RUNS
}
}
// ---------------------------------------------------------------------------
// Session wrapper
// ---------------------------------------------------------------------------
/// Spider counterpart of [`crate::game_state::GameState`]: owns the
/// upstream [`Session`] (undo history + score/undo bookkeeping) and
/// exposes `Result<_, MoveError>` mutations, mirroring the Klondike
/// wrapper's conventions.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpiderGameState {
seed: u64,
session: Session<SpiderGame>,
}
impl SpiderGameState {
/// New seeded game with the default (1-suit) difficulty.
pub fn new(seed: u64) -> Self {
Self::new_with_suits(seed, SpiderSuits::default())
}
/// New seeded game at an explicit suit difficulty.
pub fn new_with_suits(seed: u64, suits: SpiderSuits) -> Self {
let config = SessionConfig {
inner: SpiderConfig {
suits,
scoring: SpiderScoring::default(),
},
// Undoing costs one move, matching the familiar Spider
// scoring; the upstream formula applies `undos × penalty`.
undo_penalty: -1,
..SessionConfig::default()
};
Self {
seed,
session: Session::new(SpiderGame::with_seed(seed, suits), config),
}
}
/// The deal seed this game was created from.
pub const fn seed(&self) -> u64 {
self.seed
}
/// Suit difficulty of this game.
pub fn suits(&self) -> SpiderSuits {
self.session.config().inner.suits
}
/// Current position (read-only).
pub fn game(&self) -> &SpiderGame {
self.session.state().state()
}
/// In-play score, clamped at 0 like the Klondike wrapper.
pub fn score(&self) -> i32 {
self.session
.state()
.score(self.session.stats(), self.session.config())
.max(0)
}
/// Whether all 8 runs are complete.
pub fn is_won(&self) -> bool {
self.session.is_win()
}
/// Total instructions applied (deals + moves), from history.
pub fn move_count(&self) -> u32 {
u32::try_from(self.session.history().len()).unwrap_or(u32::MAX)
}
/// Successful undos this session.
pub fn undo_count(&self) -> u32 {
self.session.stats().undos()
}
/// Legal instructions in the current position.
pub fn possible_instructions(&self) -> Vec<SpiderInstruction> {
self.session.possible_instructions().collect()
}
/// Applies one instruction. `Err` on illegal instructions and
/// finished games; the session records the undo snapshot.
pub fn apply_instruction(&mut self, instruction: SpiderInstruction) -> Result<(), MoveError> {
if self.is_won() {
return Err(MoveError::GameAlreadyWon);
}
let config = &self.session.config().inner;
if !self
.session
.state()
.state()
.is_instruction_valid(config, instruction)
{
return Err(MoveError::RuleViolation("move violates rules".into()));
}
self.session.process_instruction(instruction);
Ok(())
}
/// Restores the previous snapshot. Mirrors the Klondike wrapper's
/// guards; the upstream session counts the undo for scoring.
pub fn undo(&mut self) -> Result<(), MoveError> {
if self.is_won() {
return Err(MoveError::GameAlreadyWon);
}
if self.session.history().is_empty() {
return Err(MoveError::UndoStackEmpty);
}
self.session.undo();
Ok(())
}
}
// ---------------------------------------------------------------------------
// Test support
// ---------------------------------------------------------------------------
#[cfg(any(test, feature = "test-support"))]
impl SpiderGame {
/// Builds an arbitrary position for tests: per-pile
/// `(face_down, face_up)` card lists plus stock and completed-run
/// count. No card-count invariants are enforced — stacked
/// positions for rule tests routinely use partial layouts.
pub fn from_test_layout(
piles: [(Vec<Card>, Vec<Card>); SPIDER_TABLEAUS],
stock: Vec<Card>,
completed_runs: u8,
) -> Self {
let mut iter = piles.into_iter();
let tableaus = core::array::from_fn(|_| {
// `piles` has exactly SPIDER_TABLEAUS entries.
let (down, up) = iter.next().unwrap_or_default();
let mut pile = Pile::new_face_down(down.into_iter().collect());
pile.extend(up);
pile
});
Self {
tableaus,
stock: stock.into_iter().collect(),
completed_runs,
}
}
}
#[cfg(any(test, feature = "test-support"))]
impl SpiderGameState {
/// Wraps an arbitrary position in a fresh session (empty history).
pub fn from_test_game(game: SpiderGame, suits: SpiderSuits) -> Self {
let config = SessionConfig {
inner: SpiderConfig {
suits,
scoring: SpiderScoring::default(),
},
undo_penalty: -1,
..SessionConfig::default()
};
Self {
seed: 0,
session: Session::new(game, config),
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// `Card::new` shorthand for stacked positions.
fn card(suit: Suit, rank: Rank) -> Card {
Card::new(Deck::Deck1, suit, rank)
}
/// K→A same-suit run, bottom (King) first — the face-up order a
/// completed run occupies on a pile.
fn full_run(suit: Suit) -> Vec<Card> {
let mut ranks: Vec<Rank> = Rank::RANKS.to_vec();
ranks.reverse();
ranks.into_iter().map(|rank| card(suit, rank)).collect()
}
fn empty_layout() -> [(Vec<Card>, Vec<Card>); SPIDER_TABLEAUS] {
core::array::from_fn(|_| (Vec::new(), Vec::new()))
}
/// A layout where every pile is non-empty (single junk card), so
/// deal-validity tests can toggle exactly one condition.
fn junk_layout() -> [(Vec<Card>, Vec<Card>); SPIDER_TABLEAUS] {
core::array::from_fn(|_| (Vec::new(), vec![card(Suit::Clubs, Rank::King)]))
}
// -- dealing ----------------------------------------------------------
#[test]
fn opening_deal_shape_is_4x6_6x5_with_50_in_stock() {
let game = SpiderGame::with_seed(42, SpiderSuits::Four);
for index in 0..SPIDER_TABLEAUS {
let expected_down = if index < 4 { 5 } else { 4 };
assert_eq!(game.tableau_face_down(index).len(), expected_down);
assert_eq!(game.tableau_face_up(index).len(), 1, "one card face-up");
}
assert_eq!(game.stock_len(), STOCK_SIZE);
assert_eq!(game.completed_runs(), 0);
}
#[test]
fn same_seed_same_deal_different_seed_different_deal() {
let a = SpiderGame::with_seed(7, SpiderSuits::Four);
let b = SpiderGame::with_seed(7, SpiderSuits::Four);
let c = SpiderGame::with_seed(8, SpiderSuits::Four);
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn suit_difficulty_controls_suit_spread() {
let one = build_deck(SpiderSuits::One);
let two = build_deck(SpiderSuits::Two);
let four = build_deck(SpiderSuits::Four);
for deck in [&one, &two, &four] {
assert_eq!(deck.len(), SPIDER_DECK_SIZE);
}
assert!(one.iter().all(|c| c.suit() == Suit::Spades));
assert!(
two.iter()
.all(|c| matches!(c.suit(), Suit::Spades | Suit::Hearts))
);
for suit in Suit::SUITS {
assert_eq!(
four.iter().filter(|c| c.suit() == suit).count(),
2 * RUN_LEN,
"four-suit deck has exactly two copies per suit"
);
}
}
// -- move rules -------------------------------------------------------
#[test]
fn single_card_builds_down_regardless_of_suit() {
let mut layout = empty_layout();
layout[0].1 = vec![card(Suit::Hearts, Rank::Five)];
layout[1].1 = vec![card(Suit::Spades, Rank::Six)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
assert!(game.is_move_valid(0, 1, 1), "5♥ onto 6♠ is legal");
assert!(!game.is_move_valid(1, 0, 1), "6♠ onto 5♥ is not");
}
#[test]
fn only_same_suit_runs_are_movable_as_a_group() {
let mut layout = empty_layout();
// Pile 0: 7♠ 6♠ (movable run of 2). Pile 1: 7♥ 6♠ (mixed).
layout[0].1 = vec![
card(Suit::Spades, Rank::Seven),
card(Suit::Spades, Rank::Six),
];
layout[1].1 = vec![
card(Suit::Hearts, Rank::Seven),
card(Suit::Spades, Rank::Six),
];
// Destinations: 8♣ (for the pair), 7♦ (for a lone six).
layout[2].1 = vec![card(Suit::Clubs, Rank::Eight)];
layout[3].1 = vec![card(Suit::Diamonds, Rank::Seven)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
assert!(game.is_move_valid(0, 2, 2), "same-suit pair moves");
assert!(!game.is_move_valid(1, 2, 2), "mixed-suit pair does not");
assert!(game.is_move_valid(1, 3, 1), "its top card alone does");
}
#[test]
fn empty_pile_accepts_any_run_and_occupied_gap_rules_hold() {
let mut layout = empty_layout();
layout[0].1 = vec![
card(Suit::Spades, Rank::Nine),
card(Suit::Spades, Rank::Eight),
];
// Pile 1 deliberately left empty.
layout[2].1 = vec![card(Suit::Hearts, Rank::Nine)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
assert!(game.is_move_valid(0, 1, 2), "run onto empty pile");
assert!(game.is_move_valid(2, 1, 1), "single onto empty pile");
assert!(
!game.is_move_valid(0, 2, 2),
"9♠8♠ cannot land on 9♥ (needs a 10)"
);
}
#[test]
fn invalid_moves_rejected_out_of_range_zero_count_self_move() {
let mut layout = empty_layout();
layout[0].1 = vec![card(Suit::Spades, Rank::Five)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
assert!(!game.is_move_valid(0, 0, 1), "self-move");
assert!(!game.is_move_valid(0, 1, 0), "zero count");
assert!(!game.is_move_valid(0, 10, 1), "destination out of range");
assert!(!game.is_move_valid(10, 0, 1), "source out of range");
assert!(!game.is_move_valid(0, 1, 2), "count exceeds run");
}
// -- dealing from stock -------------------------------------------------
#[test]
fn deal_requires_stock_and_no_empty_pile() {
let stock = vec![card(Suit::Spades, Rank::Ace); 10];
let game = SpiderGame::from_test_layout(junk_layout(), stock.clone(), 0);
assert!(game.is_deal_valid());
let mut with_gap = junk_layout();
with_gap[3].1.clear();
let game = SpiderGame::from_test_layout(with_gap, stock, 0);
assert!(!game.is_deal_valid(), "empty pile blocks the deal");
let game = SpiderGame::from_test_layout(junk_layout(), Vec::new(), 0);
assert!(!game.is_deal_valid(), "empty stock blocks the deal");
}
#[test]
fn deal_puts_one_card_on_every_pile() {
let mut state = SpiderGameState::new_with_suits(3, SpiderSuits::Two);
let before: Vec<usize> = (0..SPIDER_TABLEAUS)
.map(|i| state.game().tableau_face_up(i).len())
.collect();
state
.apply_instruction(SpiderInstruction::Deal)
.expect("deal is legal on a fresh game");
for (index, previous) in before.iter().enumerate() {
assert_eq!(state.game().tableau_face_up(index).len(), previous + 1);
}
assert_eq!(state.game().stock_len(), STOCK_SIZE - SPIDER_TABLEAUS);
}
#[test]
fn stock_supports_exactly_five_deals() {
let mut state = SpiderGameState::new_with_suits(11, SpiderSuits::One);
for _ in 0..5 {
state
.apply_instruction(SpiderInstruction::Deal)
.expect("five deals must all be legal on untouched piles");
}
assert_eq!(state.game().stock_len(), 0);
assert!(matches!(
state.apply_instruction(SpiderInstruction::Deal),
Err(MoveError::RuleViolation(_))
));
}
// -- run completion & win ----------------------------------------------
#[test]
fn completing_a_run_removes_it_and_flips_the_card_beneath() {
let mut layout = empty_layout();
// Pile 0: one face-down card under K..2 of spades; the ace
// arrives from pile 1.
let mut run = full_run(Suit::Spades);
let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
layout[0] = (vec![card(Suit::Hearts, Rank::Nine)], run);
layout[1].1 = vec![ace];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);
state
.apply_instruction(SpiderInstruction::Move {
from: 1,
to: 0,
count: 1,
})
.expect("ace onto two completes the run");
assert_eq!(state.game().completed_runs(), 1);
assert_eq!(
state.game().tableau_face_up(0),
&[card(Suit::Hearts, Rank::Nine)],
"run removed and the buried card flipped face-up"
);
assert!(state.game().tableau_face_up(1).is_empty());
}
#[test]
fn eighth_run_wins_the_game() {
let mut layout = empty_layout();
let mut run = full_run(Suit::Spades);
let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
layout[0].1 = run;
layout[1].1 = vec![ace];
let game = SpiderGame::from_test_layout(layout, Vec::new(), TOTAL_RUNS - 1);
let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);
assert!(!state.is_won());
state
.apply_instruction(SpiderInstruction::Move {
from: 1,
to: 0,
count: 1,
})
.expect("winning move is legal");
assert!(state.is_won());
assert!(matches!(
state.apply_instruction(SpiderInstruction::Deal),
Err(MoveError::GameAlreadyWon)
));
}
// -- session wrapper -----------------------------------------------------
#[test]
fn undo_restores_position_and_counts() {
let mut state = SpiderGameState::new_with_suits(21, SpiderSuits::Two);
let fresh = state.game().clone();
assert!(matches!(state.undo(), Err(MoveError::UndoStackEmpty)));
state
.apply_instruction(SpiderInstruction::Deal)
.expect("deal");
assert_ne!(*state.game(), fresh);
state.undo().expect("one snapshot to restore");
assert_eq!(*state.game(), fresh);
assert_eq!(state.undo_count(), 1);
assert_eq!(state.move_count(), 0, "undo pops the history entry");
}
#[test]
fn score_follows_base_move_penalty_and_run_bonus() {
let mut state = SpiderGameState::new_with_suits(5, SpiderSuits::One);
assert_eq!(state.score(), 500, "fresh game starts at base");
state
.apply_instruction(SpiderInstruction::Deal)
.expect("deal");
assert_eq!(state.score(), 499, "one move costs one point");
}
#[test]
fn rule_violation_surfaces_move_error() {
let mut state = SpiderGameState::new_with_suits(9, SpiderSuits::One);
let result = state.apply_instruction(SpiderInstruction::Move {
from: 0,
to: 0,
count: 1,
});
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
}
// -- generated instructions ----------------------------------------------
#[test]
fn possible_instructions_are_all_valid_and_include_deal() {
let state = SpiderGameState::new_with_suits(13, SpiderSuits::Four);
let config = SpiderConfig::default();
let instructions = state.possible_instructions();
assert!(
instructions.contains(&SpiderInstruction::Deal),
"fresh game has no empty pile, so Deal must be offered"
);
for instruction in instructions {
assert!(state.game().is_instruction_valid(&config, instruction));
}
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
/// Total cards across tableaus + stock + removed runs must always
/// equal 104, and every generated instruction must validate — for
/// any seed, difficulty, and random walk through legal moves.
fn card_conservation(game: &SpiderGame) -> usize {
let on_piles: usize = (0..SPIDER_TABLEAUS)
.map(|i| game.tableau_face_up(i).len() + game.tableau_face_down(i).len())
.sum();
on_piles + game.stock_len() + usize::from(game.completed_runs()) * RUN_LEN
}
proptest! {
#[test]
fn random_walks_conserve_cards_and_stay_valid(
seed in any::<u64>(),
suit_pick in 0u8..3,
steps in 0usize..40,
choices in proptest::collection::vec(any::<u32>(), 40),
) {
let suits = match suit_pick {
0 => SpiderSuits::One,
1 => SpiderSuits::Two,
_ => SpiderSuits::Four,
};
let mut state = SpiderGameState::new_with_suits(seed, suits);
prop_assert_eq!(card_conservation(state.game()), SPIDER_DECK_SIZE);
for choice in choices.iter().take(steps) {
let legal = state.possible_instructions();
if legal.is_empty() {
break;
}
let instruction = legal[*choice as usize % legal.len()];
prop_assert!(state.apply_instruction(instruction).is_ok());
prop_assert_eq!(card_conservation(state.game()), SPIDER_DECK_SIZE);
}
}
}
}
+7
View File
@@ -24,6 +24,9 @@ uuid = { workspace = true }
dirs = { workspace = true }
reqwest = { workspace = true }
tokio = { workspace = true }
# Theme-store downloads are verified against the catalog's SHA-256
# before the archive is handed to the engine's theme importer.
sha2 = { workspace = true }
# `keyring-core` is the typed Entry/Error API used by
# `auth_tokens`. The crate's own dependency tree pulls in
@@ -47,6 +50,10 @@ sqlx = { workspace = true }
jsonwebtoken = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
# theme_store_round_trip builds a theme zip in a tempdir for the
# in-process store server.
zip = { workspace = true }
tempfile = { workspace = true }
[lints]
workspace = true
+5
View File
@@ -161,6 +161,11 @@ pub use sync_client::LocalOnlyProvider;
#[cfg(not(target_arch = "wasm32"))]
pub use sync_client::{SolitaireServerClient, provider_for_backend};
#[cfg(not(target_arch = "wasm32"))]
pub mod theme_store_client;
#[cfg(not(target_arch = "wasm32"))]
pub use theme_store_client::{ThemeStoreClient, ThemeStoreError};
pub mod replay;
pub use replay::{
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
+4 -2
View File
@@ -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
+7 -3
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,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(),
+35 -9
View File
@@ -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
+200
View File
@@ -0,0 +1,200 @@
//! HTTP client for the server's theme-store endpoints.
//!
//! Fetches the catalog (`GET /api/themes`) and downloads theme
//! archives, verifying each download's size and SHA-256 against the
//! catalog entry before returning the bytes. The caller (the engine's
//! theme-store UI) hands verified bytes to the theme importer, which
//! independently re-validates the archive's structure — the store
//! pipeline never trusts a byte the importer hasn't checked.
//!
//! Native-only: gated out on wasm32 alongside the other `reqwest`
//! consumers in this crate.
use sha2::{Digest, Sha256};
use solitaire_sync::{ThemeCatalogEntry, ThemeCatalogResponse};
use thiserror::Error;
/// Hard cap on a theme archive download, matching the engine
/// importer's `MAX_ARCHIVE_BYTES` — anything larger can never import,
/// so downloading it is pure waste.
pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
/// Errors surfaced by [`ThemeStoreClient`].
#[derive(Debug, Error)]
pub enum ThemeStoreError {
/// The request could not be sent or the response body not read.
#[error("network error: {0}")]
Network(String),
/// The server answered with a non-success status code.
#[error("server returned HTTP {0}")]
Http(u16),
/// The catalog JSON did not parse.
#[error("malformed catalog: {0}")]
MalformedCatalog(String),
/// The archive is larger than [`MAX_THEME_DOWNLOAD_BYTES`] or its
/// catalog-declared size.
#[error("archive size {got} exceeds the expected {expected} bytes")]
Oversized { expected: u64, got: u64 },
/// The downloaded bytes do not hash to the catalog's SHA-256 —
/// the file changed on the server or was corrupted in transit.
#[error("archive checksum mismatch (expected {expected}, got {got})")]
ChecksumMismatch { expected: String, got: String },
}
/// Client for one server's theme store.
///
/// Unauthenticated: the catalog and downloads are public endpoints,
/// so unlike `SolitaireServerClient` there is no token handling.
pub struct ThemeStoreClient {
/// Base URL of the server, trailing slash stripped.
base_url: String,
client: reqwest::Client,
}
impl ThemeStoreClient {
/// Construct a client for the server at `base_url`
/// (e.g. `"https://solitaire.example.com"`).
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_owned(),
client: reqwest::Client::new(),
}
}
/// Fetch the theme catalog, sorted by display name (server-side).
pub async fn fetch_catalog(&self) -> Result<Vec<ThemeCatalogEntry>, ThemeStoreError> {
let resp = self
.client
.get(format!("{}/api/themes", self.base_url))
.send()
.await
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(ThemeStoreError::Http(resp.status().as_u16()));
}
let catalog: ThemeCatalogResponse = resp
.json()
.await
.map_err(|e| ThemeStoreError::MalformedCatalog(e.to_string()))?;
Ok(catalog.themes)
}
/// Download `entry`'s archive and verify it against the catalog's
/// size and SHA-256. Returns the verified `.zip` bytes, ready for
/// the engine's theme importer.
pub async fn download_theme(
&self,
entry: &ThemeCatalogEntry,
) -> Result<Vec<u8>, ThemeStoreError> {
// The catalog itself could name an absurd size; refuse before
// buffering anything.
if entry.size_bytes > MAX_THEME_DOWNLOAD_BYTES {
return Err(ThemeStoreError::Oversized {
expected: MAX_THEME_DOWNLOAD_BYTES,
got: entry.size_bytes,
});
}
let resp = self
.client
.get(format!("{}{}", self.base_url, entry.download_url))
.send()
.await
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(ThemeStoreError::Http(resp.status().as_u16()));
}
let bytes = resp
.bytes()
.await
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
verify_archive(&bytes, entry)?;
Ok(bytes.to_vec())
}
}
/// Checks downloaded `bytes` against the catalog `entry`'s declared
/// size and SHA-256. Pure so it can be unit-tested without a server.
fn verify_archive(bytes: &[u8], entry: &ThemeCatalogEntry) -> Result<(), ThemeStoreError> {
if bytes.len() as u64 != entry.size_bytes {
return Err(ThemeStoreError::Oversized {
expected: entry.size_bytes,
got: bytes.len() as u64,
});
}
let got = hex_digest(bytes);
// Case-insensitive: the server emits lowercase hex, but a
// hand-authored catalog mirror shouldn't fail on case alone.
if !got.eq_ignore_ascii_case(&entry.sha256) {
return Err(ThemeStoreError::ChecksumMismatch {
expected: entry.sha256.clone(),
got,
});
}
Ok(())
}
/// Lowercase-hex SHA-256 of `bytes`. Mirrors the server's digest
/// encoding in `solitaire_server::theme_store`.
fn hex_digest(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
out.push_str(&format!("{byte:02x}"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn entry_for(bytes: &[u8]) -> ThemeCatalogEntry {
ThemeCatalogEntry {
id: "neon".into(),
name: "Neon".into(),
author: "Test".into(),
version: "1.0.0".into(),
card_aspect: (2, 3),
size_bytes: bytes.len() as u64,
sha256: hex_digest(bytes),
download_url: "/api/themes/neon/download".into(),
preview_url: None,
}
}
#[test]
fn verify_accepts_matching_bytes() {
let bytes = b"theme archive bytes";
assert!(verify_archive(bytes, &entry_for(bytes)).is_ok());
}
#[test]
fn verify_accepts_uppercase_catalog_hash() {
let bytes = b"theme archive bytes";
let mut entry = entry_for(bytes);
entry.sha256 = entry.sha256.to_uppercase();
assert!(verify_archive(bytes, &entry).is_ok());
}
#[test]
fn verify_rejects_size_mismatch() {
let bytes = b"theme archive bytes";
let mut entry = entry_for(bytes);
entry.size_bytes += 1;
assert!(matches!(
verify_archive(bytes, &entry),
Err(ThemeStoreError::Oversized { .. })
));
}
#[test]
fn verify_rejects_checksum_mismatch() {
let bytes = b"theme archive bytes";
let mut entry = entry_for(bytes);
entry.sha256 = "0".repeat(64);
assert!(matches!(
verify_archive(bytes, &entry),
Err(ThemeStoreError::ChecksumMismatch { .. })
));
}
}
@@ -0,0 +1,118 @@
//! End-to-end theme-store test: a real `solitaire_server` router
//! serving a scanned catalog over a localhost TCP socket, consumed by
//! [`solitaire_data::ThemeStoreClient`].
//!
//! Mirrors the `sync_round_trip` harness: `TcpListener` on port 0,
//! router in a background `tokio::spawn`, no explicit shutdown.
#![cfg(not(target_arch = "wasm32"))]
use std::io::Write as _;
use std::path::{Path, PathBuf};
use solitaire_data::ThemeStoreClient;
use solitaire_server::theme_store::ThemeStore;
/// Minimal theme archive: the catalog scan only reads the `meta`
/// block, so no face SVGs are needed.
fn write_store_zip(dir: &Path, file_name: &str, id: &str, name: &str) -> PathBuf {
let manifest = format!(
r#"(
meta: (
id: "{id}",
name: "{name}",
author: "Round Trip",
version: "1.0.0",
card_aspect: (2, 3),
),
faces: {{}},
back: "back.svg",
)"#
);
let path = dir.join(file_name);
let file = std::fs::File::create(&path).expect("create zip");
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default();
writer.start_file("theme.ron", options).expect("start_file");
writer
.write_all(manifest.as_bytes())
.expect("write manifest");
writer.finish().expect("finish zip");
path
}
/// Spawn the test server with a theme store scanned from `store_dir`
/// and return its base URL.
async fn spawn_store_server(store_dir: &Path) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind test listener");
let addr = listener.local_addr().expect("listener has no local addr");
let pool = solitaire_server::build_test_pool().await;
let app =
solitaire_server::build_test_router_with_theme_store(pool, ThemeStore::scan(store_dir));
tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app).await {
eprintln!("test server crashed: {e}");
}
});
format!("http://{addr}")
}
/// Catalog fetch → download → verified bytes match the file the
/// server scanned. This is the exact path the engine's store UI runs.
#[tokio::test]
async fn catalog_fetch_and_verified_download_round_trip() {
let dir = tempfile::tempdir().expect("tempdir");
let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
let base_url = spawn_store_server(dir.path()).await;
let client = ThemeStoreClient::new(&base_url);
let catalog = client.fetch_catalog().await.expect("fetch catalog");
assert_eq!(catalog.len(), 1);
let entry = &catalog[0];
assert_eq!(entry.id, "neon");
let bytes = client.download_theme(entry).await.expect("download");
assert_eq!(bytes, std::fs::read(&zip_path).expect("read zip"));
}
/// A catalog entry whose hash no longer matches the served file must
/// be rejected client-side — the importer never sees unverified bytes.
#[tokio::test]
async fn download_with_stale_catalog_hash_is_rejected() {
let dir = tempfile::tempdir().expect("tempdir");
write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
let base_url = spawn_store_server(dir.path()).await;
let client = ThemeStoreClient::new(&base_url);
let mut entry = client.fetch_catalog().await.expect("fetch catalog")[0].clone();
entry.sha256 = "0".repeat(64);
let err = client
.download_theme(&entry)
.await
.expect_err("mismatched hash must fail");
assert!(
matches!(
err,
solitaire_data::ThemeStoreError::ChecksumMismatch { .. }
),
"expected ChecksumMismatch, got: {err:?}"
);
}
/// An empty (or absent) store directory serves an empty catalog — the
/// client sees a store with nothing in it, not an error.
#[tokio::test]
async fn empty_store_yields_empty_catalog() {
let dir = tempfile::tempdir().expect("tempdir");
let base_url = spawn_store_server(dir.path()).await;
let client = ThemeStoreClient::new(&base_url);
let catalog = client.fetch_catalog().await.expect("fetch catalog");
assert!(catalog.is_empty());
}
+1 -2
View File
@@ -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::<GameStateResource>().0.mode =
GameMode::Zen;
app.world_mut().resource_mut::<GameStateResource>().0.mode = GameMode::Zen;
app.world_mut().write_message(GameWonEvent {
score: 0,
+27 -24
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.
///
/// # 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/<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.
/// 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.
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()
})
}
+6 -2
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,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 = (
@@ -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,
}
+1 -4
View File
@@ -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
// ---------------------------------------------------------------------------
@@ -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
// ---------------------------------------------------------------------------
@@ -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
// ---------------------------------------------------------------------------
+1 -6
View File
@@ -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);
}
+3 -7
View File
@@ -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::<GameStateResource>),
update_stock_count_badge.run_if(resource_changed::<GameStateResource>),
collect_resize_events,
snap_cards_on_window_resize,
resize_android_corner_labels,
+1 -6
View File
@@ -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(
}
}
}
+1 -2
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,4 +700,3 @@ pub(super) fn update_card_entity(
commands.entity(entity).insert(new_children_key);
}
}
+21 -34
View File
@@ -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<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);
@@ -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<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);
@@ -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<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);
@@ -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<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
@@ -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::<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);
}
@@ -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::<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,
@@ -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<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()
@@ -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<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()
@@ -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<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()
+2
View File
@@ -25,6 +25,7 @@ use crate::{
#[cfg(not(target_arch = "wasm32"))]
use crate::{
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
ThemeStorePlugin,
};
/// Groups all Ferrous Solitaire gameplay plugins.
@@ -126,6 +127,7 @@ impl Plugin for CoreGamePlugin {
.add_plugins(AudioPlugin)
.add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(SyncSetupPlugin)
.add_plugins(ThemeStorePlugin)
.add_plugins(AnalyticsPlugin)
.add_plugins(LeaderboardPlugin);
}
+8 -8
View File
@@ -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);
+4 -1
View File
@@ -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);
+7 -1
View File
@@ -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;
@@ -134,6 +134,12 @@ pub struct ManualSyncRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct SyncConfigureRequestEvent;
/// Request to open the in-game theme-store modal. Fired by the
/// "Browse theme store" button in the Settings cosmetic section;
/// handled by `theme_store_plugin`.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ThemeStoreOpenRequestEvent;
/// Request to disconnect from the current sync backend, clear stored
/// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect"
/// button in the Settings sync section.
+8 -2
View File
@@ -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()
+14 -4
View File
@@ -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;
}
+15 -8
View File
@@ -372,9 +372,7 @@ 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!(
@@ -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::<GameStateResource>().0.move_count(), 0);
assert_eq!(
app.world().resource::<GameStateResource>().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::<GameStateResource>().0.move_count(), 0);
assert_eq!(
app.world().resource::<GameStateResource>().0.move_count(),
0
);
assert_eq!(
app.world()
.resource::<GameStateResource>()
+3 -1
View File
@@ -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),
+5 -4
View File
@@ -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<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;
};
@@ -427,4 +429,3 @@ pub(super) fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
from.alpha + (to.alpha - from.alpha) * t,
)
}
@@ -3,8 +3,6 @@
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,
@@ -613,4 +611,3 @@ pub(super) fn toggle_hud_on_tap(
}
}
}
-1
View File
@@ -19,7 +19,6 @@ 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,7 +2,6 @@
use super::*;
#[cfg(not(target_arch = "wasm32"))]
use crate::avatar_plugin::AvatarResource;
@@ -292,7 +291,9 @@ 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((
@@ -549,4 +550,3 @@ pub(super) fn spawn_action_button<M: Component>(
}
});
}
+38 -10
View File
@@ -40,9 +40,16 @@ 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]
@@ -192,7 +199,9 @@ 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), "");
}
@@ -207,7 +216,9 @@ 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");
}
@@ -222,7 +233,9 @@ 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");
}
@@ -238,7 +251,10 @@ 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), "");
}
@@ -384,7 +400,10 @@ 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.
@@ -405,7 +424,10 @@ 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);
@@ -431,7 +453,10 @@ 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),
@@ -507,7 +532,10 @@ 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),
+1 -2
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,4 +609,3 @@ pub(super) fn resize_action_bar_labels(
font.font_size = new_size;
}
}
+16 -15
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::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::{Card, Suit};
use solitaire_core::game_state::GameState;
use solitaire_core::{Card, Suit};
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::tuning::AnimationTuning;
@@ -394,7 +394,10 @@ 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 {
@@ -831,9 +834,7 @@ 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);
@@ -1070,8 +1071,7 @@ 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,7 +1174,8 @@ 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
@@ -1248,7 +1249,10 @@ 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));
}
}
@@ -1550,8 +1554,7 @@ 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;
};
@@ -1783,8 +1786,6 @@ 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,9 +89,11 @@ 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
@@ -99,7 +101,11 @@ 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]
@@ -176,8 +182,7 @@ 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");
}
@@ -195,8 +200,7 @@ 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));
@@ -213,18 +217,14 @@ 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,7 +322,11 @@ 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]
@@ -558,8 +562,7 @@ 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,
@@ -577,8 +580,7 @@ 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,
@@ -601,12 +603,8 @@ 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 \
@@ -620,12 +618,8 @@ 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,
@@ -683,10 +677,16 @@ 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.
+2 -4
View File
@@ -862,10 +862,8 @@ 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 {
+4
View File
@@ -54,6 +54,8 @@ pub mod sync_plugin;
pub mod sync_setup_plugin;
pub mod table_plugin;
pub mod theme;
#[cfg(not(target_arch = "wasm32"))]
pub mod theme_store_plugin;
pub mod time_attack_plugin;
pub mod touch_selection_plugin;
pub mod ui_focus;
@@ -176,6 +178,8 @@ pub use theme::{
ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry,
ThemeRegistryPlugin, set_theme,
};
#[cfg(not(target_arch = "wasm32"))]
pub use theme_store_plugin::{ThemeStorePlugin, ThemeStoreScreen};
pub use time_attack_plugin::{
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
};
+1 -1
View File
@@ -27,6 +27,7 @@ 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,
@@ -36,7 +37,6 @@ 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};
// ---------------------------------------------------------------------------
+4 -1
View File
@@ -969,7 +969,10 @@ 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
}
+22 -17
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,10 +249,7 @@ 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
}
@@ -267,9 +264,11 @@ 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() {
@@ -306,9 +305,11 @@ 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",
@@ -344,18 +345,22 @@ 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
+6 -7
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,9 +360,6 @@ 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
@@ -381,8 +378,10 @@ 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,7 +246,11 @@ 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::KlondikePile;
use solitaire_core::Card;
use solitaire_core::KlondikePile;
use solitaire_core::game_state::GameState;
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
+1 -3
View File
@@ -90,9 +90,7 @@ 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}")
})
}
};
+11 -33
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,8 +487,10 @@ 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;
};
@@ -597,10 +599,7 @@ 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,
@@ -697,13 +696,7 @@ 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.
@@ -714,13 +707,7 @@ 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);
}
}
}
@@ -1047,10 +1034,7 @@ 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)));
@@ -1216,16 +1200,10 @@ 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,
@@ -407,6 +407,10 @@ pub(super) fn handle_settings_buttons(
| SettingsButton::DeleteAccount => {
// Handled by `handle_sync_buttons`.
}
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::OpenThemeStore => {
// Handled by `handle_sync_buttons`.
}
SettingsButton::Done => {
screen.0 = false;
}
@@ -423,6 +427,9 @@ pub(super) fn handle_sync_buttons(
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
#[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter<
crate::events::ThemeStoreOpenRequestEvent,
>,
mut screen: ResMut<SettingsScreen>,
) {
for (interaction, button) in &interaction_query {
@@ -439,6 +446,13 @@ pub(super) fn handle_sync_buttons(
screen.0 = false;
configure_sync.write(SyncConfigureRequestEvent);
}
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::OpenThemeStore => {
// Same shape as ConnectSync: close settings first so the
// store modal's own-scrim guard doesn't block.
screen.0 = false;
open_theme_store.write(crate::events::ThemeStoreOpenRequestEvent);
}
SettingsButton::DisconnectSync => {
logout_sync.write(SyncLogoutRequestEvent);
}
@@ -250,6 +250,9 @@ enum SettingsButton {
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
#[cfg(not(target_arch = "wasm32"))]
ScanThemes,
/// Open the in-game theme-store modal (closes Settings first).
#[cfg(not(target_arch = "wasm32"))]
OpenThemeStore,
SyncNow,
/// Open the sync-server Connect modal (shown when backend = Local).
ConnectSync,
@@ -313,6 +316,8 @@ impl SettingsButton {
SettingsButton::SelectTheme(_) => 85,
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::ScanThemes => 86,
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::OpenThemeStore => 87,
// Sync section
SettingsButton::SyncNow => 90,
SettingsButton::ConnectSync => 91,
@@ -371,6 +376,7 @@ impl Plugin for SettingsPlugin {
.add_message::<SyncLogoutRequestEvent>()
.add_message::<DeleteAccountRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>()
.add_message::<crate::events::ThemeStoreOpenRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<MouseWheel>()
.add_message::<TouchInput>()
@@ -239,6 +239,8 @@ pub(super) fn spawn_settings_panel(
}
#[cfg(not(target_arch = "wasm32"))]
import_themes_row(body, font_res);
#[cfg(not(target_arch = "wasm32"))]
theme_store_row(body, font_res);
// --- Privacy (only shown when a Matomo URL is configured) ---
if settings.matomo_url.is_some() {
@@ -1193,6 +1195,31 @@ pub(super) fn import_themes_row(
});
}
/// "Theme store" row: one pill button that closes Settings and opens
/// the in-game theme-store modal (`theme_store_plugin`). Sits directly
/// under the Import row so both install paths live together.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn theme_store_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) {
parent
.spawn((
FocusRow,
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
..default()
},
))
.with_children(|row| {
pill_button(
row,
SettingsButton::OpenThemeStore,
"Browse theme store",
"Download themes from your sync server without leaving the game.",
font_res,
);
});
}
pub(super) fn icon_button(
parent: &mut ChildSpawnerCommands,
label: &str,
+2 -4
View File
@@ -1500,10 +1500,8 @@ 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,
+4 -6
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,9 +667,7 @@ 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()),
+12 -11
View File
@@ -7,8 +7,8 @@
use bevy::prelude::*;
use bevy::window::WindowResized;
use solitaire_core::KlondikePile;
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::Suit;
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
@@ -385,7 +385,11 @@ 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>>,
) {
@@ -437,8 +441,7 @@ 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) {
@@ -593,8 +596,6 @@ fn pile_cards(
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -703,7 +704,10 @@ 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]
@@ -939,10 +943,7 @@ 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");
}
+60 -27
View File
@@ -116,24 +116,32 @@ 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
/// 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.
/// 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.
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("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);
.unwrap_or(&default_id);
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(id));
commands.insert_resource(ActiveTheme(handle));
}
@@ -162,10 +170,7 @@ fn react_to_settings_theme_change(
return;
}
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);
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(new_id));
commands.insert_resource(ActiveTheme(handle));
}
@@ -252,12 +257,11 @@ 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 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.
/// 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.
///
/// The handle is also written to the [`ActiveTheme`] resource — the
/// per-frame sync system picks up the `LoadedWithDependencies` event
@@ -268,8 +272,7 @@ pub fn set_theme(
asset_server: &AssetServer,
theme_id: &str,
) -> Handle<CardTheme> {
let url = format!("themes://{theme_id}/theme.ron");
let handle: Handle<CardTheme> = asset_server.load(url);
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(theme_id));
commands.insert_resource(ActiveTheme(handle.clone()));
handle
}
@@ -477,13 +480,43 @@ mod tests {
}
#[test]
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");
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"
);
}
/// Test 1: the bundled dark theme always has embedded SVG bytes
+657
View File
@@ -0,0 +1,657 @@
//! In-game theme store: browse the sync server's theme catalog and
//! install themes without leaving the app.
//!
//! Settings → Cosmetic → "Browse theme store" fires
//! [`crate::events::ThemeStoreOpenRequestEvent`]; this plugin opens a
//! modal listing the catalog (fetched on [`AsyncComputeTaskPool`]) and
//! handles per-theme Install buttons. An install downloads the archive
//! (SHA-256-verified by [`ThemeStoreClient`]), writes it atomically
//! into `user_theme_dir()`, and runs it through the same hardened
//! [`import_theme`] pipeline as a manually dropped zip — the store
//! never bypasses the importer's validation.
//!
//! Native-only: downloads need `reqwest`/Tokio and the importer is
//! filesystem-based; the plugin is gated out on wasm32 alongside
//! `SyncPlugin`.
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use thiserror::Error;
use solitaire_data::theme_store_client::{ThemeStoreClient, ThemeStoreError};
use solitaire_data::{Settings, SyncBackend};
use solitaire_sync::ThemeCatalogEntry;
use crate::assets::user_theme_dir;
use crate::events::{InfoToastEvent, ThemeStoreOpenRequestEvent, WarningToastEvent};
use crate::font_plugin::FontResource;
use crate::resources::TokioRuntimeResource;
use crate::settings_plugin::{SettingsPanel, SettingsResource};
use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry};
use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
Z_MODAL_PANEL,
};
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// Everything that can go wrong between clicking Install and the theme
/// appearing in the picker.
#[derive(Debug, Error)]
enum ThemeInstallError {
/// Catalog fetch or archive download failed (network, HTTP status,
/// size/checksum verification).
#[error(transparent)]
Download(#[from] ThemeStoreError),
/// The verified archive could not be written into the themes dir.
#[error("could not save archive: {0}")]
Io(#[from] std::io::Error),
/// The downloaded archive failed importer validation.
#[error(transparent)]
Import(#[from] ImportError),
}
// ---------------------------------------------------------------------------
// Components & resources
// ---------------------------------------------------------------------------
/// Marker on the theme-store modal's scrim root.
#[derive(Component)]
pub struct ThemeStoreScreen;
/// Marker on the modal's Close button.
#[derive(Component)]
struct ThemeStoreCloseButton;
/// Per-row Install button carrying its catalog entry.
#[derive(Component)]
struct ThemeStoreInstallButton(ThemeCatalogEntry);
/// Catalog fetch lifecycle. The modal renders directly from this state
/// and is rebuilt (despawn + respawn, mirroring the leaderboard panel)
/// whenever it changes.
#[derive(Resource, Default)]
enum CatalogState {
/// No fetch has run since the modal was last opened.
#[default]
Idle,
Loading,
Loaded(Vec<ThemeCatalogEntry>),
Error(String),
}
/// In-flight catalog fetch, polled without blocking.
#[derive(Resource, Default)]
struct CatalogTask(Option<Task<Result<Vec<ThemeCatalogEntry>, ThemeStoreError>>>);
/// Result of one download + import: `Ok(Some(id))` on a fresh install,
/// `Ok(None)` when the theme was already installed (id collision) —
/// informational, not an error.
type InstallResult = Result<Option<String>, ThemeInstallError>;
/// In-flight download + import. `String` is the theme id, for toasts.
#[derive(Resource, Default)]
struct InstallTask(Option<(String, Task<InstallResult>)>);
/// Server base URL captured when the modal opens. Catalog
/// `download_url`s are server-relative, so installs join them onto
/// this. `None` while the modal has never been opened.
#[derive(Resource, Default)]
struct StoreBaseUrl(Option<String>);
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Bevy plugin for the in-game theme store. See the module docs.
pub struct ThemeStorePlugin;
impl Plugin for ThemeStorePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<CatalogState>()
.init_resource::<CatalogTask>()
.init_resource::<InstallTask>()
.init_resource::<StoreBaseUrl>()
.add_message::<ThemeStoreOpenRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<WarningToastEvent>()
.add_systems(
Update,
(
handle_open_request,
poll_catalog_task,
handle_install_buttons,
poll_install_task,
handle_close_button,
)
.chain(),
);
}
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Resolves the store's server base URL from the player's sync
/// backend. The store rides the same self-hosted server as sync;
/// without one configured there is nothing to browse.
fn store_base_url(settings: &Settings) -> Option<String> {
match &settings.sync_backend {
SyncBackend::SolitaireServer { url, .. } => Some(url.clone()),
_ => None,
}
}
/// Opens the store modal and kicks off the catalog fetch.
///
/// Mirrors `open_sync_setup_modal`: the Settings button closes the
/// settings panel in the same frame it fires the event, and despawns
/// are deferred, so the settings scrim is excluded from the
/// another-modal-is-open guard.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn handle_open_request(
mut events: MessageReader<ThemeStoreOpenRequestEvent>,
existing: Query<(), With<ThemeStoreScreen>>,
other_modal_scrims: Query<
(),
(
With<ModalScrim>,
Without<ThemeStoreScreen>,
Without<SettingsPanel>,
),
>,
settings: Option<Res<SettingsResource>>,
rt: Option<Res<TokioRuntimeResource>>,
mut catalog_state: ResMut<CatalogState>,
mut catalog_task: ResMut<CatalogTask>,
mut store_base: ResMut<StoreBaseUrl>,
mut warning_toast: MessageWriter<WarningToastEvent>,
mut commands: Commands,
font_res: Option<Res<FontResource>>,
) {
if events.is_empty() {
return;
}
events.clear();
if !existing.is_empty() || !other_modal_scrims.is_empty() {
return;
}
let Some(base_url) = settings.as_deref().and_then(|s| store_base_url(&s.0)) else {
warning_toast.write(WarningToastEvent(
"Theme store needs a server — connect one in Settings → Sync.".to_string(),
));
return;
};
let Some(rt) = rt else {
warning_toast.write(WarningToastEvent(
"Theme store unavailable — no network runtime.".to_string(),
));
return;
};
store_base.0 = Some(base_url.clone());
*catalog_state = CatalogState::Loading;
let rt = rt.0.clone();
catalog_task.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await })
}));
spawn_store_modal(&mut commands, &catalog_state, None, font_res.as_deref());
}
/// Polls the catalog fetch; on completion updates [`CatalogState`] and
/// rebuilds the modal if it is still open.
fn poll_catalog_task(
mut catalog_task: ResMut<CatalogTask>,
mut catalog_state: ResMut<CatalogState>,
screens: Query<Entity, With<ThemeStoreScreen>>,
registry: Option<Res<ThemeRegistry>>,
mut commands: Commands,
font_res: Option<Res<FontResource>>,
) {
let Some(task) = catalog_task.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
catalog_task.0 = None;
*catalog_state = match result {
Ok(entries) => CatalogState::Loaded(entries),
Err(e) => {
warn!("theme store: catalog fetch failed: {e}");
CatalogState::Error(e.to_string())
}
};
rebuild_open_modal(
&screens,
&mut commands,
&catalog_state,
registry.as_deref(),
font_res.as_deref(),
);
}
/// Starts a download + import task when an Install button is pressed.
/// One install at a time; repeat clicks while busy are ignored.
fn handle_install_buttons(
interactions: Query<(&Interaction, &ThemeStoreInstallButton), Changed<Interaction>>,
rt: Option<Res<TokioRuntimeResource>>,
store_base: Res<StoreBaseUrl>,
mut install_task: ResMut<InstallTask>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
for (interaction, button) in &interactions {
if *interaction != Interaction::Pressed || install_task.0.is_some() {
continue;
}
let (Some(rt), Some(base_url)) = (rt.as_ref(), store_base.0.clone()) else {
continue;
};
let entry = button.0.clone();
info_toast.write(InfoToastEvent(format!("Downloading '{}'…", entry.name)));
let rt = rt.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
let bytes = rt
.block_on(async { ThemeStoreClient::new(base_url).download_theme(&entry).await })?;
install_verified_archive(&entry.id, &bytes)
});
install_task.0 = Some((button.0.id.clone(), task));
}
}
/// Downloads land here (already SHA-256-verified): write the archive
/// atomically into the user themes dir (`.tmp` + rename, §2.5), then
/// run the standard importer. Returns `Ok(None)` when the theme was
/// already installed.
fn install_verified_archive(id: &str, bytes: &[u8]) -> Result<Option<String>, ThemeInstallError> {
let themes_dir = user_theme_dir();
std::fs::create_dir_all(&themes_dir)?;
let final_path = themes_dir.join(format!("{id}.zip"));
let tmp_path = themes_dir.join(format!("{id}.zip.tmp"));
std::fs::write(&tmp_path, bytes)?;
std::fs::rename(&tmp_path, &final_path)?;
match import_theme(&final_path) {
Ok(theme_id) => Ok(Some(theme_id.as_str().to_string())),
Err(ImportError::IdCollision { .. }) => Ok(None),
Err(e) => {
// Don't leave a zip that fails validation lying around for
// the folder-scan flow to re-try on every scan.
let _ = std::fs::remove_file(&final_path);
Err(e.into())
}
}
}
/// Polls the install task; on completion refreshes the theme registry,
/// toasts the outcome, and rebuilds the modal so the row flips to
/// "Installed".
#[allow(clippy::too_many_arguments)]
fn poll_install_task(
mut install_task: ResMut<InstallTask>,
mut registry: Option<ResMut<ThemeRegistry>>,
catalog_state: Res<CatalogState>,
screens: Query<Entity, With<ThemeStoreScreen>>,
mut info_toast: MessageWriter<InfoToastEvent>,
mut warning_toast: MessageWriter<WarningToastEvent>,
mut commands: Commands,
font_res: Option<Res<FontResource>>,
) {
let Some((id, task)) = install_task.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
let id = id.clone();
install_task.0 = None;
match result {
Ok(Some(theme_id)) => {
if let Some(registry) = registry.as_deref_mut() {
refresh_registry(registry, &user_theme_dir());
}
info_toast.write(InfoToastEvent(format!(
"Theme '{theme_id}' installed — pick it in Settings → Cosmetic."
)));
}
Ok(None) => {
info_toast.write(InfoToastEvent(format!("Theme '{id}' already installed.")));
}
Err(e) => {
warn!("theme store: install of '{id}' failed: {e}");
warning_toast.write(WarningToastEvent(format!("Install failed: {e}")));
}
}
rebuild_open_modal(
&screens,
&mut commands,
&catalog_state,
registry.as_deref(),
font_res.as_deref(),
);
}
/// Despawns the store modal when Close is pressed.
fn handle_close_button(
interactions: Query<&Interaction, (Changed<Interaction>, With<ThemeStoreCloseButton>)>,
screens: Query<Entity, With<ThemeStoreScreen>>,
mut commands: Commands,
) {
for interaction in &interactions {
if *interaction != Interaction::Pressed {
continue;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
}
// ---------------------------------------------------------------------------
// Modal rendering
// ---------------------------------------------------------------------------
/// Despawn + respawn the modal for every open screen (Bevy despawns
/// are deferred, so this is safe within one frame).
fn rebuild_open_modal(
screens: &Query<Entity, With<ThemeStoreScreen>>,
commands: &mut Commands,
catalog_state: &CatalogState,
registry: Option<&ThemeRegistry>,
font_res: Option<&FontResource>,
) {
for entity in screens {
commands.entity(entity).despawn();
spawn_store_modal(commands, catalog_state, registry, font_res);
}
}
/// Spawns the store modal for the current catalog state.
fn spawn_store_modal(
commands: &mut Commands,
catalog_state: &CatalogState,
registry: Option<&ThemeRegistry>,
font_res: Option<&FontResource>,
) {
let body_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_BODY,
..default()
};
let caption_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
let scrim = spawn_modal(commands, ThemeStoreScreen, Z_MODAL_PANEL + 1, |card| {
spawn_modal_header(card, "Theme Store", font_res);
match catalog_state {
CatalogState::Idle | CatalogState::Loading => {
card.spawn((
Text::new("Loading catalog…"),
body_font.clone(),
TextColor(TEXT_SECONDARY),
));
}
CatalogState::Error(msg) => {
card.spawn((
Text::new(format!("Could not load the catalog: {msg}")),
body_font.clone(),
TextColor(TEXT_SECONDARY),
));
}
CatalogState::Loaded(entries) if entries.is_empty() => {
card.spawn((
Text::new("The server has no themes yet."),
body_font.clone(),
TextColor(TEXT_SECONDARY),
));
}
CatalogState::Loaded(entries) => {
for entry in entries {
let installed =
registry.is_some_and(|registry| registry.find(&entry.id).is_some());
spawn_store_row(card, entry, installed, &body_font, &caption_font, font_res);
}
}
}
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
ThemeStoreCloseButton,
"Close",
None,
ButtonVariant::Primary,
font_res,
);
});
});
commands.entity(scrim).insert(ScrimDismissible);
}
/// One catalog row: name + author/size caption on the left, Install
/// button (or "Installed" caption) on the right.
fn spawn_store_row(
parent: &mut ChildSpawnerCommands,
entry: &ThemeCatalogEntry,
installed: bool,
body_font: &TextFont,
caption_font: &TextFont,
font_res: Option<&FontResource>,
) {
parent
.spawn((
Node {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
padding: UiRect::vertical(VAL_SPACE_2),
border: UiRect::bottom(Val::Px(1.0)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
))
.with_children(|row| {
row.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
..default()
})
.with_children(|col| {
col.spawn((
Text::new(entry.name.clone()),
body_font.clone(),
TextColor(TEXT_PRIMARY),
));
col.spawn((
Text::new(format!(
"by {} — {} KB",
entry.author,
entry.size_bytes.div_ceil(1024)
)),
caption_font.clone(),
TextColor(TEXT_SECONDARY),
));
});
if installed {
row.spawn((
Text::new("Installed"),
caption_font.clone(),
TextColor(TEXT_SECONDARY),
));
} else {
spawn_modal_button(
row,
ThemeStoreInstallButton(entry.clone()),
"Install",
None,
ButtonVariant::Secondary,
font_res,
);
}
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::message::Messages;
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(ThemeStorePlugin);
app.update();
app
}
fn settings_with_server() -> SettingsResource {
SettingsResource(Settings {
sync_backend: SyncBackend::SolitaireServer {
url: "http://127.0.0.1:1".to_string(),
username: "tester".to_string(),
avatar_url: None,
},
..Settings::default()
})
}
fn screen_count(app: &mut App) -> usize {
app.world_mut()
.query::<&ThemeStoreScreen>()
.iter(app.world())
.count()
}
#[test]
fn store_base_url_requires_server_backend() {
assert_eq!(store_base_url(&Settings::default()), None);
assert_eq!(
store_base_url(&settings_with_server().0).as_deref(),
Some("http://127.0.0.1:1")
);
}
#[test]
fn open_request_without_server_warns_and_spawns_nothing() {
let mut app = headless_app();
// Default settings → SyncBackend::Local → no store.
app.insert_resource(SettingsResource(Settings::default()));
app.world_mut()
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
.write(ThemeStoreOpenRequestEvent);
app.update();
assert_eq!(screen_count(&mut app), 0);
assert!(
!app.world()
.resource::<Messages<WarningToastEvent>>()
.is_empty(),
"player must be told why the store did not open"
);
}
#[test]
fn open_request_without_runtime_warns_and_spawns_nothing() {
let mut app = headless_app();
app.insert_resource(settings_with_server());
// No TokioRuntimeResource inserted.
app.world_mut()
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
.write(ThemeStoreOpenRequestEvent);
app.update();
assert_eq!(screen_count(&mut app), 0);
assert!(
!app.world()
.resource::<Messages<WarningToastEvent>>()
.is_empty()
);
}
#[test]
fn open_request_with_server_spawns_modal_in_loading_state() {
let mut app = headless_app();
app.insert_resource(settings_with_server());
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
app.world_mut()
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
.write(ThemeStoreOpenRequestEvent);
app.update();
assert_eq!(screen_count(&mut app), 1);
assert!(matches!(
*app.world().resource::<CatalogState>(),
CatalogState::Loading | CatalogState::Error(_)
));
assert_eq!(
app.world().resource::<StoreBaseUrl>().0.as_deref(),
Some("http://127.0.0.1:1")
);
}
#[test]
fn second_open_request_does_not_stack_a_second_modal() {
let mut app = headless_app();
app.insert_resource(settings_with_server());
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
for _ in 0..2 {
app.world_mut()
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
.write(ThemeStoreOpenRequestEvent);
app.update();
}
assert_eq!(screen_count(&mut app), 1);
}
#[test]
fn failed_catalog_fetch_rebuilds_modal_with_error_state() {
let mut app = headless_app();
app.insert_resource(settings_with_server());
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
app.world_mut()
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
.write(ThemeStoreOpenRequestEvent);
app.update();
// 127.0.0.1:1 refuses connections; the fetch task must resolve
// to an error within a bounded number of frames and the modal
// must survive the rebuild.
for _ in 0..200 {
app.update();
if matches!(
*app.world().resource::<CatalogState>(),
CatalogState::Error(_)
) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(matches!(
*app.world().resource::<CatalogState>(),
CatalogState::Error(_)
));
assert_eq!(screen_count(&mut app), 1);
}
}
@@ -28,8 +28,8 @@
use bevy::ecs::message::MessageReader;
use bevy::prelude::*;
use solitaire_core::KlondikePile;
use solitaire_core::Card;
use solitaire_core::KlondikePile;
use crate::card_plugin::CardEntity;
use crate::events::StateChangedEvent;
+1 -4
View File
@@ -304,10 +304,7 @@ 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,
+7 -1
View File
@@ -29,9 +29,15 @@ tower-http = { version = "0.6", features = ["fs"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
dotenvy = { workspace = true }
# Theme store: scans a directory of theme .zip archives at startup,
# reading each archive's theme.ron manifest and hashing the bytes.
ron = { workspace = true }
zip = { workspace = true }
sha2 = { workspace = true }
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
tower = { version = "0.5", features = ["util"] }
tempfile = { workspace = true }
[lints]
workspace = true
+19
View File
@@ -11,6 +11,7 @@ pub mod leaderboard;
pub mod middleware;
pub mod replays;
pub mod sync;
pub mod theme_store;
pub use auth::reset_password;
@@ -86,6 +87,10 @@ pub struct AppState {
pub pool: SqlitePool,
/// HS256 signing secret for JWT access and refresh tokens.
pub jwt_secret: String,
/// Theme-store catalog scanned once at startup.
/// [`theme_store::ThemeStore::empty`] when the server runs without
/// a store directory.
pub theme_store: Arc<theme_store::ThemeStore>,
}
/// Construct the full Axum [`Router`].
@@ -125,9 +130,20 @@ pub async fn build_test_pool() -> SqlitePool {
/// integration tests do not need to set `JWT_SECRET` in the environment.
#[doc(hidden)]
pub fn build_test_router(pool: SqlitePool) -> Router {
build_test_router_with_theme_store(pool, theme_store::ThemeStore::empty())
}
/// [`build_test_router`] with a caller-supplied theme store, for
/// integration tests exercising the theme endpoints.
#[doc(hidden)]
pub fn build_test_router_with_theme_store(
pool: SqlitePool,
theme_store: theme_store::ThemeStore,
) -> Router {
let state = AppState {
pool,
jwt_secret: "test_secret_32_chars_minimum_ok!".to_string(),
theme_store: Arc::new(theme_store),
};
build_router_inner(state, false)
}
@@ -195,6 +211,9 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
.route("/api/daily-challenge", get(challenge::daily_challenge))
.route("/api/replays/recent", get(replays::recent))
.route("/api/replays/{id}", get(replays::get_by_id))
.route("/api/themes", get(theme_store::list))
.route("/api/themes/{id}/download", get(theme_store::download))
.route("/api/themes/{id}/preview", get(theme_store::preview))
.route("/health", get(health))
.nest_service("/avatars", ServeDir::new("avatars"));
+15 -2
View File
@@ -31,12 +31,13 @@
//! echo "new_password" | ./solitaire_server --reset-password alice
//! ```
use solitaire_server::{AppState, build_router};
use solitaire_server::{AppState, build_router, theme_store};
use sqlx::{SqlitePool, sqlite::SqliteConnectOptions};
use std::{
io::{self, BufRead},
net::SocketAddr,
str::FromStr,
sync::Arc,
};
const JWT_SECRET_MIN_BYTES: usize = 32;
@@ -142,7 +143,19 @@ async fn run_server() {
tracing::info!("database ready at {db_url}");
let state = AppState { pool, jwt_secret };
// Theme store: optional; a missing directory just means an empty
// catalog. Scanned once — add themes, then restart to publish.
let theme_store_dir = std::env::var(theme_store::THEME_STORE_DIR_ENV)
.unwrap_or_else(|_| theme_store::DEFAULT_THEME_STORE_DIR.into());
let theme_store = Arc::new(theme_store::ThemeStore::scan(std::path::Path::new(
&theme_store_dir,
)));
let state = AppState {
pool,
jwt_secret,
theme_store,
};
let app = build_router(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
+366
View File
@@ -0,0 +1,366 @@
//! Theme-store catalog and file-serving endpoints.
//!
//! The store is filesystem-driven: the operator drops theme `.zip`
//! archives (the same format the engine's theme importer consumes)
//! into the directory named by the `THEME_STORE_DIR` environment
//! variable (default `theme_store/`), plus an optional `<id>.png`
//! preview per theme. [`ThemeStore::scan`] runs once at startup —
//! adding a theme means dropping the files and restarting the server.
//!
//! Endpoints (all public, no auth — the catalog is free):
//!
//! - `GET /api/themes` — [`ThemeCatalogResponse`] JSON
//! - `GET /api/themes/{id}/download` — the theme `.zip`
//! - `GET /api/themes/{id}/preview` — the preview PNG (404 when absent)
use std::collections::HashMap;
use std::io::{Cursor, Read};
use std::path::{Path, PathBuf};
use axum::Json;
use axum::extract::{Path as UrlPath, State};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use solitaire_sync::{ThemeCatalogEntry, ThemeCatalogResponse};
use tracing::{info, warn};
use crate::AppState;
use crate::error::AppError;
/// Environment variable naming the theme-store directory.
pub const THEME_STORE_DIR_ENV: &str = "THEME_STORE_DIR";
/// Default theme-store directory, relative to the server working dir —
/// same convention as the `avatars/` upload directory.
pub const DEFAULT_THEME_STORE_DIR: &str = "theme_store";
/// Archives larger than this are skipped at scan time with a warning.
/// Mirrors the engine importer's `MAX_ARCHIVE_BYTES` (20 MiB) — a zip
/// whose *compressed* size already exceeds the client's uncompressed
/// limit can never import, so serving it only wastes bandwidth.
pub const MAX_STORE_ARCHIVE_BYTES: u64 = 20 * 1024 * 1024;
/// Upper bound on the bytes read out of an archive's `theme.ron`
/// entry at scan time, guarding the catalog scan against a
/// zip-bombed manifest. Real manifests are a few KB.
const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
/// `theme.ron`'s outer shape, `meta` block only — the 53 face entries
/// are irrelevant to the catalog and are validated client-side by the
/// importer at install time.
#[derive(Debug, Deserialize)]
struct ManifestMetaOnly {
meta: StoreThemeMeta,
}
/// Mirror of the `meta` block of a theme manifest. The canonical
/// definition is `solitaire_engine::theme::ThemeMeta`; the server
/// cannot depend on the engine crate (it links Bevy), so the shape is
/// re-declared here. Keep the two in lockstep.
#[derive(Debug, Deserialize)]
struct StoreThemeMeta {
id: String,
name: String,
author: String,
version: String,
card_aspect: (u32, u32),
}
/// In-memory catalog built by scanning the theme-store directory once
/// at startup. Shared via [`AppState`].
#[derive(Debug, Default)]
pub struct ThemeStore {
entries: Vec<ThemeCatalogEntry>,
zip_paths: HashMap<String, PathBuf>,
preview_paths: HashMap<String, PathBuf>,
}
impl ThemeStore {
/// An empty store — used by tests and when the store directory is
/// absent (the server runs fine without a theme store).
pub fn empty() -> Self {
Self::default()
}
/// Scans `dir` for `*.zip` theme archives and builds the catalog.
///
/// Best-effort per archive: unreadable files, oversized archives,
/// missing/malformed manifests, and duplicate ids are skipped with
/// a warning rather than failing startup — one bad upload must not
/// take the store (or the server) down.
pub fn scan(dir: &Path) -> Self {
let mut store = Self::default();
let read_dir = match std::fs::read_dir(dir) {
Ok(read_dir) => read_dir,
Err(e) => {
info!(
"theme store: directory {} not readable ({e}); serving an empty catalog",
dir.display()
);
return store;
}
};
let mut zips: Vec<PathBuf> = read_dir
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "zip"))
.collect();
// Deterministic catalog regardless of directory iteration order.
zips.sort();
for zip_path in zips {
match scan_archive(&zip_path) {
Ok((meta, size_bytes, sha256)) => {
if store.zip_paths.contains_key(&meta.id) {
warn!(
"theme store: skipping {} — duplicate theme id {:?}",
zip_path.display(),
meta.id
);
continue;
}
let preview_path = dir.join(format!("{}.png", meta.id));
let preview_url = if preview_path.is_file() {
store.preview_paths.insert(meta.id.clone(), preview_path);
Some(format!("/api/themes/{}/preview", meta.id))
} else {
None
};
store.entries.push(ThemeCatalogEntry {
download_url: format!("/api/themes/{}/download", meta.id),
preview_url,
id: meta.id.clone(),
name: meta.name,
author: meta.author,
version: meta.version,
card_aspect: meta.card_aspect,
size_bytes,
sha256,
});
store.zip_paths.insert(meta.id, zip_path);
}
Err(reason) => {
warn!("theme store: skipping {} — {reason}", zip_path.display());
}
}
}
store.entries.sort_by(|a, b| a.name.cmp(&b.name));
info!("theme store: serving {} theme(s)", store.entries.len());
store
}
/// Catalog entries, sorted by display name.
pub fn entries(&self) -> &[ThemeCatalogEntry] {
&self.entries
}
}
/// Reads one archive: enforces the size cap, hashes the bytes, and
/// extracts + validates the manifest's `meta` block.
fn scan_archive(zip_path: &Path) -> Result<(StoreThemeMeta, u64, String), String> {
let size_bytes = std::fs::metadata(zip_path)
.map_err(|e| format!("cannot stat: {e}"))?
.len();
if size_bytes > MAX_STORE_ARCHIVE_BYTES {
return Err(format!(
"{size_bytes} bytes exceeds the {MAX_STORE_ARCHIVE_BYTES}-byte store limit"
));
}
let bytes = std::fs::read(zip_path).map_err(|e| format!("cannot read: {e}"))?;
let sha256 = hex_digest(&bytes);
let mut archive =
zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip archive: {e}"))?;
let manifest_bytes = {
let entry = archive
.by_name("theme.ron")
.map_err(|e| format!("no theme.ron in archive: {e}"))?;
let mut buf = Vec::new();
entry
.take(MAX_MANIFEST_BYTES)
.read_to_end(&mut buf)
.map_err(|e| format!("cannot read theme.ron: {e}"))?;
buf
};
let parsed: ManifestMetaOnly =
ron::de::from_bytes(&manifest_bytes).map_err(|e| format!("malformed theme.ron: {e}"))?;
let meta = parsed.meta;
if meta.id.is_empty() {
return Err("theme id is empty".to_string());
}
if meta.id.contains('/') || meta.id.contains('\\') || meta.id.contains("..") {
return Err(format!("theme id {:?} is not filesystem-safe", meta.id));
}
if meta.card_aspect.0 == 0 || meta.card_aspect.1 == 0 {
return Err("card_aspect contains a zero".to_string());
}
Ok((meta, size_bytes, sha256))
}
/// Lowercase-hex SHA-256 of `bytes`.
fn hex_digest(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
out.push_str(&format!("{byte:02x}"));
}
out
}
/// `GET /api/themes` — the full catalog.
pub async fn list(State(state): State<AppState>) -> Json<ThemeCatalogResponse> {
Json(ThemeCatalogResponse {
themes: state.theme_store.entries().to_vec(),
})
}
/// `GET /api/themes/{id}/download` — the theme archive bytes.
pub async fn download(
State(state): State<AppState>,
UrlPath(id): UrlPath<String>,
) -> Result<Response, AppError> {
let path = state
.theme_store
.zip_paths
.get(&id)
.ok_or_else(|| AppError::NotFound("theme not found".into()))?;
let bytes = tokio::fs::read(path)
.await
.map_err(|e| AppError::Internal(format!("theme archive unreadable: {e}")))?;
Ok((
[
(header::CONTENT_TYPE, "application/zip".to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{id}.zip\""),
),
],
bytes,
)
.into_response())
}
/// `GET /api/themes/{id}/preview` — the preview PNG, when the store
/// ships one for this theme.
pub async fn preview(
State(state): State<AppState>,
UrlPath(id): UrlPath<String>,
) -> Result<Response, AppError> {
let path = state
.theme_store
.preview_paths
.get(&id)
.ok_or_else(|| AppError::NotFound("theme preview not found".into()))?;
let bytes = tokio::fs::read(path)
.await
.map_err(|e| AppError::Internal(format!("theme preview unreadable: {e}")))?;
Ok(([(header::CONTENT_TYPE, "image/png".to_string())], bytes).into_response())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
/// Minimal valid manifest for id/name pairs — the scan only reads
/// the `meta` block, so no face entries are needed.
fn manifest(id: &str, name: &str) -> String {
format!(
r#"(
meta: (
id: "{id}",
name: "{name}",
author: "Test Author",
version: "1.0.0",
card_aspect: (2, 3),
),
faces: {{}},
back: "back.svg",
)"#
)
}
fn write_zip(dir: &Path, file_name: &str, manifest: &str) -> PathBuf {
let path = dir.join(file_name);
let file = std::fs::File::create(&path).expect("create zip");
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default();
writer.start_file("theme.ron", options).expect("start");
writer.write_all(manifest.as_bytes()).expect("write");
writer.finish().expect("finish");
path
}
#[test]
fn scan_of_missing_dir_yields_empty_catalog() {
let store = ThemeStore::scan(Path::new("/nonexistent/theme/store"));
assert!(store.entries().is_empty());
}
#[test]
fn scan_builds_sorted_catalog_with_hashes() {
let dir = tempfile::tempdir().expect("tempdir");
let zebra = write_zip(dir.path(), "b.zip", &manifest("zebra", "Zebra"));
write_zip(dir.path(), "a.zip", &manifest("aurora", "Aurora"));
let store = ThemeStore::scan(dir.path());
let entries = store.entries();
assert_eq!(entries.len(), 2);
// Sorted by display name, not filename.
assert_eq!(entries[0].id, "aurora");
assert_eq!(entries[1].id, "zebra");
assert_eq!(entries[1].download_url, "/api/themes/zebra/download");
assert_eq!(entries[1].preview_url, None);
let zebra_bytes = std::fs::read(&zebra).expect("read zip");
assert_eq!(entries[1].sha256, hex_digest(&zebra_bytes));
assert_eq!(entries[1].size_bytes, zebra_bytes.len() as u64);
}
#[test]
fn scan_picks_up_preview_png_keyed_by_theme_id() {
let dir = tempfile::tempdir().expect("tempdir");
write_zip(dir.path(), "neon.zip", &manifest("neon", "Neon"));
std::fs::write(dir.path().join("neon.png"), b"png-bytes").expect("write png");
let store = ThemeStore::scan(dir.path());
assert_eq!(
store.entries()[0].preview_url.as_deref(),
Some("/api/themes/neon/preview")
);
assert!(store.preview_paths.contains_key("neon"));
}
#[test]
fn scan_skips_duplicate_ids_and_keeps_first() {
let dir = tempfile::tempdir().expect("tempdir");
write_zip(dir.path(), "1_first.zip", &manifest("dupe", "First"));
write_zip(dir.path(), "2_second.zip", &manifest("dupe", "Second"));
let store = ThemeStore::scan(dir.path());
assert_eq!(store.entries().len(), 1);
assert_eq!(store.entries()[0].name, "First");
}
#[test]
fn scan_skips_malformed_and_unsafe_manifests() {
let dir = tempfile::tempdir().expect("tempdir");
// Not a zip at all.
std::fs::write(dir.path().join("junk.zip"), b"not a zip").expect("write");
// Path-separator id must be rejected — it would become a URL
// path segment and a client install directory name.
write_zip(dir.path(), "evil.zip", &manifest("../evil", "Evil"));
// Valid one to prove the scan carries on past failures.
write_zip(dir.path(), "ok.zip", &manifest("ok", "Ok"));
let store = ThemeStore::scan(dir.path());
assert_eq!(store.entries().len(), 1);
assert_eq!(store.entries()[0].id, "ok");
}
}
+124
View File
@@ -1614,6 +1614,7 @@ async fn auth_rate_limit_returns_429_on_11th_request() {
let state = solitaire_server::AppState {
pool: test_pool().await,
jwt_secret: TEST_SECRET.to_string(),
theme_store: std::sync::Arc::new(solitaire_server::theme_store::ThemeStore::empty()),
};
let app = solitaire_server::build_router(state);
@@ -1675,6 +1676,7 @@ async fn sync_push_rate_limit_returns_429_on_11th_request() {
let state = solitaire_server::AppState {
pool: test_pool().await,
jwt_secret: TEST_SECRET.to_string(),
theme_store: std::sync::Arc::new(solitaire_server::theme_store::ThemeStore::empty()),
};
let app = solitaire_server::build_router(state);
@@ -1879,3 +1881,125 @@ async fn replay_upload_malformed_body_returns_400() {
let resp = post_authed(app, "/api/replays", &token, bad).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
// ---------------------------------------------------------------------------
// Theme store
// ---------------------------------------------------------------------------
/// Writes a minimal theme zip (manifest only — the catalog scan reads
/// just the `meta` block) into `dir` and returns its path.
fn write_store_zip(
dir: &std::path::Path,
file_name: &str,
id: &str,
name: &str,
) -> std::path::PathBuf {
use std::io::Write as _;
let manifest = format!(
r#"(
meta: (
id: "{id}",
name: "{name}",
author: "Test Author",
version: "1.0.0",
card_aspect: (2, 3),
),
faces: {{}},
back: "back.svg",
)"#
);
let path = dir.join(file_name);
let file = std::fs::File::create(&path).expect("create zip");
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default();
writer.start_file("theme.ron", options).expect("start_file");
writer
.write_all(manifest.as_bytes())
.expect("write manifest");
writer.finish().expect("finish zip");
path
}
/// `GET /api/themes` returns the scanned catalog as JSON, no auth needed.
#[tokio::test]
async fn theme_catalog_lists_scanned_themes() {
let dir = tempfile::tempdir().expect("tempdir");
let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
let store = solitaire_server::theme_store::ThemeStore::scan(dir.path());
let app = solitaire_server::build_test_router_with_theme_store(test_pool().await, store);
let req = Request::builder()
.uri("/api/themes")
.body(Body::empty())
.expect("build request");
let resp = app.oneshot(req).await.expect("oneshot");
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
let catalog: solitaire_sync::ThemeCatalogResponse =
serde_json::from_slice(&body).expect("parse catalog");
assert_eq!(catalog.themes.len(), 1);
let entry = &catalog.themes[0];
assert_eq!(entry.id, "neon");
assert_eq!(entry.name, "Neon");
assert_eq!(entry.download_url, "/api/themes/neon/download");
assert_eq!(entry.preview_url, None);
let zip_bytes = std::fs::read(&zip_path).expect("read zip");
assert_eq!(entry.size_bytes, zip_bytes.len() as u64);
}
/// The download endpoint streams back exactly the bytes on disk, so a
/// client hashing the response gets the catalog's sha256.
#[tokio::test]
async fn theme_download_returns_archive_bytes() {
let dir = tempfile::tempdir().expect("tempdir");
let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
let store = solitaire_server::theme_store::ThemeStore::scan(dir.path());
let app = solitaire_server::build_test_router_with_theme_store(test_pool().await, store);
let req = Request::builder()
.uri("/api/themes/neon/download")
.body(Body::empty())
.expect("build request");
let resp = app.oneshot(req).await.expect("oneshot");
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.and_then(|v| v.to_str().ok()),
Some("application/zip")
);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
assert_eq!(&body[..], &std::fs::read(&zip_path).expect("read zip")[..]);
}
/// Unknown ids 404 on both file endpoints; a theme without preview art
/// 404s on `/preview` while still serving its download.
#[tokio::test]
async fn theme_unknown_id_and_missing_preview_return_404() {
let dir = tempfile::tempdir().expect("tempdir");
write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
let store = solitaire_server::theme_store::ThemeStore::scan(dir.path());
let app = solitaire_server::build_test_router_with_theme_store(test_pool().await, store);
for uri in [
"/api/themes/nope/download",
"/api/themes/nope/preview",
"/api/themes/neon/preview",
] {
let req = Request::builder()
.uri(uri)
.body(Body::empty())
.expect("build request");
let resp = app.clone().oneshot(req).await.expect("oneshot");
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"expected 404 for {uri}"
);
}
}
Binary file not shown.
Binary file not shown.
+3
View File
@@ -10,5 +10,8 @@ uuid = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
[lints]
workspace = true
+2
View File
@@ -10,11 +10,13 @@ pub mod achievements;
pub mod merge;
pub mod progress;
pub mod stats;
pub mod theme_store;
pub use achievements::AchievementRecord;
pub use merge::{merge, merge_at};
pub use progress::{PlayerProgress, level_for_xp};
pub use stats::StatsSnapshot;
pub use theme_store::{ThemeCatalogEntry, ThemeCatalogResponse};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
+102
View File
@@ -0,0 +1,102 @@
//! Theme-store catalog wire types.
//!
//! Shared between `solitaire_server` (which builds the catalog by
//! scanning its theme-store directory) and `solitaire_data` (which
//! fetches it and downloads theme archives). These are **additive**
//! API types: they do not participate in [`crate::SyncPayload`] and
//! changing them cannot break the sync wire format.
//!
//! Store entries describe downloadable card-art theme archives — the
//! same `.zip` format consumed by the engine's theme importer (a
//! `theme.ron` manifest plus 53 SVGs).
use serde::{Deserialize, Serialize};
/// One downloadable theme in the server's catalog.
///
/// URLs are server-relative (e.g. `/api/themes/neon/download`) so a
/// client can join them onto whichever base URL it is configured with.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThemeCatalogEntry {
/// Unique theme id — `meta.id` from the archive's `theme.ron`.
/// Also the id the theme installs under on the client.
pub id: String,
/// Display name shown in the store UI.
pub name: String,
/// Author attribution (free-form text).
pub author: String,
/// Version string (conventionally semver).
pub version: String,
/// Card aspect ratio as `(numerator, denominator)`.
pub card_aspect: (u32, u32),
/// Size of the `.zip` archive in bytes. Clients use this for
/// display and as a pre-download sanity bound.
pub size_bytes: u64,
/// Lowercase-hex SHA-256 of the `.zip` archive. Clients MUST
/// verify the downloaded bytes against this digest before handing
/// the archive to the theme importer.
pub sha256: String,
/// Server-relative URL of the theme archive.
pub download_url: String,
/// Server-relative URL of a PNG preview, when the store ships one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview_url: Option<String>,
}
/// Response body of the catalog listing endpoint (`GET /api/themes`).
///
/// Wrapped in a struct (rather than a bare `Vec`) so future additive
/// fields — pagination, store revision, purchase metadata — don't
/// break existing clients.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThemeCatalogResponse {
/// Every theme currently offered by the server, sorted by name.
pub themes: Vec<ThemeCatalogEntry>,
}
#[cfg(test)]
mod tests {
use super::*;
fn entry() -> ThemeCatalogEntry {
ThemeCatalogEntry {
id: "neon".into(),
name: "Neon".into(),
author: "Rusty Solitaire".into(),
version: "1.0.0".into(),
card_aspect: (2, 3),
size_bytes: 123_456,
sha256: "ab".repeat(32),
download_url: "/api/themes/neon/download".into(),
preview_url: Some("/api/themes/neon/preview".into()),
}
}
#[test]
fn catalog_round_trips_through_json() {
let response = ThemeCatalogResponse {
themes: vec![entry()],
};
let json = serde_json::to_string(&response).expect("serialize");
let back: ThemeCatalogResponse = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, response);
}
#[test]
fn missing_preview_url_deserializes_to_none() {
// Older servers (or themes without preview art) omit the key
// entirely; the field must default rather than error.
let json = r#"{
"id": "neon",
"name": "Neon",
"author": "Rusty Solitaire",
"version": "1.0.0",
"card_aspect": [2, 3],
"size_bytes": 1,
"sha256": "00",
"download_url": "/api/themes/neon/download"
}"#;
let entry: ThemeCatalogEntry = serde_json::from_str(json).expect("deserialize");
assert_eq!(entry.preview_url, None);
}
}
+25 -14
View File
@@ -19,11 +19,14 @@
//! is the contract.
use chrono::NaiveDate;
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 solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{
DrawStockConfig,
game_state::{GameMode, GameState},
};
use solitaire_core::{KlondikeInstruction, KlondikePile};
use wasm_bindgen::prelude::*;
/// Mirrors `solitaire_data::Replay` v3.
@@ -145,8 +148,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 {
@@ -342,8 +345,7 @@ 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();
@@ -403,7 +405,8 @@ 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()
@@ -465,8 +468,7 @@ 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))),
}
}
@@ -888,7 +890,9 @@ 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}"
);
}
}
@@ -1073,9 +1077,14 @@ 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(),
@@ -1104,7 +1113,9 @@ 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!(
+1 -2
View File
@@ -41,8 +41,7 @@ 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()