Compare commits

..

35 Commits

Author SHA1 Message Date
funman300 374858ab6d Merge pull request 'feat(engine): tabbed Settings panel — Phase A of the menu redesign' (#160) from feat/settings-tabs into master
Build and Deploy / build-and-push (push) Successful in 2m23s
Web WASM Rebuild / rebuild (push) Waiting to run
Test / test (push) Successful in 37m11s
2026-07-09 04:59:11 +00:00
Gitea CI e1d91bee73 ci(web-wasm-rebuild): keep on Docker runner (host executor incompatible with taiki-e) [skip ci] 2026-07-08 15:18:40 -07:00
Gitea CI 9dcd25b3e7 ci(web-wasm-rebuild): set RUNNER_OS/ARCH on the taiki-e step (step-level env)
Web WASM Rebuild / rebuild (push) Failing after 1m54s
2026-07-08 15:01:12 -07:00
Gitea CI 2ef0bce1ea ci(web-wasm-rebuild): set RUNNER_OS/RUNNER_ARCH for host-executor runner
Web WASM Rebuild / rebuild (push) Failing after 1m54s
2026-07-08 14:53:42 -07:00
Gitea CI 326ef6894a ci: route web-wasm-rebuild workflow to the rust-host runner (CT 107)
Web WASM Rebuild / rebuild (push) Failing after 2m0s
2026-07-08 13:27:34 -07:00
Gitea CI caaafe34e0 ci(test): route test workflow to the rust-host runner (CT 107)
Test / test (push) Failing after 1m3s
2026-07-08 12:27:34 -07:00
Gitea CI 2254693a7a build(wasm): honor CARGO_TARGET_DIR in build_wasm.sh [skip ci] 2026-07-08 12:25:09 -07:00
Gitea CI 4ba646738c chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m24s
Web E2E / web-e2e (push) Successful in 5m33s
2026-07-08 03:08:06 +00:00
funman300 251d35bc28 Merge pull request 'feat: Show solution — winning_line core API + auto-playback (card_game bucket 4)' (#159) from feat/solution-line into master
Build and Deploy / build-and-push (push) Successful in 6m20s
Test / test (push) Successful in 11m24s
Web E2E / web-e2e (push) Successful in 4m46s
Web WASM Rebuild / rebuild (push) Successful in 8m10s
2026-07-08 01:08:58 +00:00
funman300 fe3c3aed31 fix(ci): cap cargo to two parallel jobs so concurrent test-binary links fit runner memory
Test / test (push) Successful in 36m31s
Runs 514/516/519 (incl. master itself) died on 'ld terminated with
signal 9' — the workspace now links enough large test binaries that
per-core parallel linking OOMs the runner even at line-tables-only
debuginfo. CARGO_BUILD_JOBS=2 keeps at most two links in flight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:33:36 -07:00
Gitea CI d0e4ce796b chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m24s
Web E2E / web-e2e (push) Successful in 4m12s
2026-07-08 00:24:36 +00:00
funman300 50d6d41d85 feat(engine): tabbed Settings panel — Phase A of the menu redesign
Test / test (pull_request) Failing after 7m15s
Replaces the 37-row single-scroll settings modal with five tabs
(Audio, Gameplay, Appearance, Accessibility, Account); only the active
tab's rows spawn, so every tab fits without scroll-hunting.

- SettingsTab + ActiveSettingsTab (session-only) + SettingsTabButton
  chips under the modal header; rebuild-on-switch mirrors the
  leaderboard despawn/respawn pattern via a shared build_panel helper
- Accessibility tab gathers color-blind, high-contrast, reduce-motion,
  touch-input, and tooltip-delay from the old Gameplay/Cosmetic mix;
  Account = Sync + Privacy
- SettingsButton enum, input handlers, and persistence untouched
- Tests: per-tab Focusable/Tooltip sweeps, tab-switch rebuild test,
  picker/thumbnail tests pinned to the Appearance tab

Plan: docs/ui-redesign-2026-07.md (also added, phases A–M)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:47:06 -07:00
funman300 9e4d4a6716 Merge pull request 'feat(core): Spider rules as a second card_game::Game implementation' (#157) from feat/spider-core into master
Build and Deploy / build-and-push (push) Successful in 6m3s
Web E2E / web-e2e (push) Successful in 5m7s
Web WASM Rebuild / rebuild (push) Successful in 7m10s
Test / test (push) Successful in 9m31s
2026-07-07 23:40:43 +00:00
funman300 ea1014285d feat(engine): Show solution — auto-play the winning line from the pause menu
Test / test (pull_request) Successful in 15m21s
New SolutionPlaybackPlugin: the pause modal's 'Show solution' button
resumes the game and requests a solve of the live position via
GameState::winning_line on AsyncComputeTaskPool (never blocks the main
thread; stale results discarded via a move_count snapshot). The line
then plays back one instruction per 0.45 s through the normal
MoveRequestEvent / DrawRequestEvent pipeline — animations, scoring,
undo history and win detection behave as if the player made the moves.
Tableau run counts are decoded against the live state at step time.

Playback cancels on Esc, pause, undo / new-game requests, and any
rejected move (the signature of the player diverging the board).
Toasts cover search start, line found, unwinnable, and budget-
exhausted outcomes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:33:49 -07:00
Gitea CI 4f849a23b8 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m20s
Web E2E / web-e2e (push) Successful in 4m55s
2026-07-07 23:31:55 +00:00
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 299f6bfea7 feat(core): winning_line — full solver line via Solution::clean_solution
New GameState::winning_line(moves_budget, states_budget) returns the
complete instruction sequence to a win (Ok(None) when unwinnable or
already won, Err on budget exhaustion), compacting the raw DFS trace
with the previously unused card_game Solution::clean_solution and
stripping foundation→foundation no-ops when the stripped line still
replays to a win — an internal replay check guarantees the returned
sequence always applies cleanly via apply_instruction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:19:12 -07:00
funman300 8c0eb3dfab Merge pull request 'fix(engine): force full board repaint when the decor-view poller fires (#130)' (#155) from fix/130-forced-repaint into master
Build and Deploy / build-and-push (push) Successful in 2m13s
Test / test (push) Successful in 8m7s
Web WASM Rebuild / rebuild (push) Successful in 7m29s
2026-07-07 23:14:11 +00:00
funman300 a39e04329e fix(engine): force full board repaint when the decor-view poller fires (#130)
Test / test (pull_request) Successful in 12m14s
The #152 poller fixes resolution + layout on a missed fold/unfold, but
a transient clip could survive if card sprites held stale visuals after
geometry converged. Emit StateChangedEvent alongside the synthetic
WindowResized so card_plugin re-renders every sprite from scratch —
silent self-heal, no player-facing prompt.

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

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

Closes #130

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:49:17 -07:00
Gitea CI ae7af9adf4 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 7m12s
Web E2E / web-e2e (push) Successful in 5m16s
2026-07-07 09:12:14 +00:00
85 changed files with 4702 additions and 708 deletions
+19 -2
View File
@@ -29,7 +29,21 @@ on:
jobs:
test:
runs-on: ubuntu-latest
runs-on: rust-host
# 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.
#
# CARGO_BUILD_JOBS=2: with one job per core, cargo links several large
# test binaries concurrently; as the workspace grew (runs 514/516/519)
# two+ simultaneous ld processes OOM-killed the runner again even at
# line-tables-only. Two jobs keeps at most two links in flight — the
# compile-throughput cost is small next to the cache-warm build.
env:
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_BUILD_JOBS: '2'
steps:
- name: Checkout
@@ -39,7 +53,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 +67,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
+1 -1
View File
@@ -77,7 +77,7 @@ wasm-bindgen \
--out-name canvas \
--target web \
--no-typescript \
"$REPO_ROOT/target/wasm32-unknown-unknown/wasm-release/solitaire_web.wasm"
"${CARGO_TARGET_DIR:-$REPO_ROOT/target}/wasm32-unknown-unknown/wasm-release/solitaire_web.wasm"
# Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed).
# wasm-opt passes are skipped silently when the tool is not installed.
+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}"
+204
View File
@@ -0,0 +1,204 @@
# Menu UX Redesign — July 2026
Status: PLANNING. Visual identity (Terminal / base16-eighties) is settled and
out of scope — this is about **structure and interaction**, not colors or type.
## Diagnosis (from code survey, 2026-07-07)
| Surface | Today | Pain |
| --- | --- | --- |
| Settings | One scrolling modal, 5 section labels, ~37 control rows, single Done | Scroll-hunting for any toggle; endless on the folded (cover) screen; every new feature (theme store, import) stretches it further |
| Home | Modal launcher: stats strip, draw-mode row, difficulty rows, 6 equal mode cards, Cancel | No "Continue" primacy; deal options (draw/difficulty/winnable) crowd the mode choice; six cards weighted identically though Classic dominates play |
| HUD menu | Popover with 7 destinations (Help, Modes, Stats, Achievements, Profile, Settings, Leaderboard) | "Modes" duplicates Home's job; flat list, no grouping; each item opens another modal — modal-on-modal navigation |
| Global | Everything is a modal over the felt | No hierarchy; near-square unfolded Fold renders one narrow wasted column |
## Phase A — Settings tabs (highest pain, lowest risk)
- Replace the single scroll with **tabs**: `Audio · Gameplay · Appearance ·
Accessibility · Account`.
- *Appearance* = today's Cosmetic (card back, background, card theme,
theme store, import).
- *Accessibility* = extracted from Gameplay/Cosmetic: color-blind,
high-contrast, reduce-motion, touch input mode, tooltip delay.
- *Account* = Sync + Privacy merged.
- Narrow screens: tab chips in a row under the header. Wide (aspect > ~1.2 —
unfolded Fold, desktop): left rail, rows in two columns.
- Only the active tab's rows are spawned → each tab is ≤ 8 rows and fits the
cover screen without scrolling.
- **Implementation constraint:** pure `ui.rs` re-layout + one `SettingsTab`
resource. The `SettingsButton` enum, input handlers, and persistence are
untouched, so the 25 settings tests and the ambiguity gate stay green.
## Phase B — Home becomes a real home
- **Hierarchy, top to bottom:**
1. `Continue` card (only when a game is in progress) — mode, elapsed, score.
2. Hero `New Game` — one tap, reuses last mode + deal options.
3. Compact 2×3 mode grid (Classic, Daily, Zen, Challenge, Time Attack,
Seed) — smaller cards, glyph + name + one-line description on wide only.
4. Stats strip moves to the bottom (or right pane when unfolded).
- **Deal options** (draw 1/3, difficulty, winnable-only) move off the top
level into a disclosure on the Classic card / New Game hero — they are
Classic-mode concerns, not global ones.
- Cancel becomes `Back to table` and only renders when a game exists.
- Unfolded Fold / desktop: two panes — modes left, Continue + stats +
daily/weekly right.
## Phase C — HUD menu consolidation
- Drop `Modes` from the popover (Home owns mode selection; HUD's New-Game
path opens Home).
- Group the remaining six: **Play** (Home) · **You** (Profile, Stats,
Achievements) · **Community** (Leaderboard) · **System** (Settings, Help) —
section dividers in the existing popover, not a new widget.
- Dismissal audit: Esc / scrim-tap / Done must behave identically on every
modal (most already do via `ScrimDismissible`; sweep the stragglers).
## Phase D — Validation gates
- Optional Stitch mockups (Terminal design-system asset
`assets/0f1652274fea460585d0b331a9ddbb06`, project 593811793268308763) for
Home + Settings before implementing Phase B. Phase A is safe to build
straight in-engine.
- Per-phase gates: full test suite, clippy `-D warnings`, ambiguity gate at 0,
Android checklist (§15.3), on-device pass on the Fold 7 in BOTH postures.
## Sequencing & risk
| Phase | Size | Risk | Depends on |
| --- | --- | --- | --- |
| A — Settings tabs | ~1 session | Low (layout-only) | — |
| B — Home hierarchy | 12 sessions | Medium (touches launch flow) | D mockups if wanted |
| C — Menu grouping | small | Low | B (Modes removal) |
## Phase E — "You" hub (reuses the Phase A tab component)
Profile, Stats, Achievements, and the replay browser are four separate
modals today, reached through the HUD popover one at a time. Fold them
into ONE tabbed hub — `Profile · Stats · Achievements · Replays` — using
the same tab widget Phase A builds for Settings. Cuts the HUD popover to
four destinations and makes the tab component pay for itself twice.
Existing per-screen systems keep their markers/handlers; only the outer
shell changes (same trick as Phase A).
## Phase F — Mobile ergonomics (Fold-first)
- **Bottom action bar** on touch: Undo, Hint, Draw within thumb reach at
the bottom safe-area edge (via the existing `SafeAreaAnchoredBottom`),
score-only band stays on top. Desktop keeps the current top band.
Biggest one-handed-play win available; folded posture is tall and
top-heavy today.
- **Hold-to-repeat Undo** — press-and-hold steps back repeatedly
(respecting the existing undo scoring penalty), instead of tap-tap-tap.
- Both are additive; game stays fully playable with the top band alone
(UI-first rule §3.3 satisfied).
## Phase G — Post-win flow
Win summary today is a stats dump with a close. Give it an action
hierarchy: **Play again** (same mode/options, primary) · **Share replay**
(the upload already returns a share URL; surface it here with copy
feedback) · **Watch replay** · quiet stats below. Rematch loop drops from
4 taps to 1.
## Phase H — Feedback polish (small, bundling candidates)
- Unify queued vs. immediate toast styling (two subtly different styles
exist today) and anchor both to one position; consider a 5-item toast
history on the stats screen.
- Theme-store modal: render the preview PNGs the server already serves
(v1 is text-only rows).
- Hint: optional ghost-motion preview of the suggested move instead of a
static highlight (auto-disabled under reduce-motion).
## Phase I — Onboarding & discoverability
The first-run onboarding exists, but everything learned after it is
invisible: the radial menu (long-press/right-click), hint cycling,
tap-to-toggle HUD chrome, hotkeys. Add **contextual one-time tips** —
fired by the situation, not a tour (first stall → hint tip; first
long-press-able stack → radial tip; each shows once, stored in
Settings like `shown_achievement_onboarding` already is). Plus a
one-shot **"What's new"** card on first launch after a version bump —
ObtainX updates are silent today, so shipped features go unnoticed
(nobody will find the theme store on their own).
## Phase J — Keyboard & focus completeness (desktop + web)
`ui_focus` already gives deterministic Esc order and tab-walk
(`FocusRow`/`Focusable`), and `KeyboardDragState` exists — but full
keyboard-only play has never been audited end to end. Deliverables: every
modal reachable/dismissable without a mouse, a visible focus ring styled
to the Terminal system (current focus state is subtle), and a hotkey
cheat-sheet overlay (hold `?`) generated from the actual bindings instead
of the static Help text. Web build (Rhys) benefits most.
## Phase K — UI scale & touch-target accessibility
All type sizes flow through `TYPE_*` tokens and layout through
`compute_layout` — which makes a **UI scale setting** (90/100/115/130 %)
cheap to wire and genuinely useful on the Fold's dense cover screen.
Pair with: respect Android system font scale on first run (seed the
setting from it), and a one-time audit that every interactive element
meets the 44 px logical minimum in BOTH Fold postures (the pill buttons
and picker swatches are the suspects). Lands in the Accessibility tab
that Phase A creates.
## Phase L — Empty & loading states, standardized
Async/empty surfaces each improvise today: leaderboard with no entries,
stats with no games, replay browser with no replays, theme store
loading/error (just added), sync status text. Define ONE pattern —
glyph + one-line explanation + a single next-step action — as a
`spawn_empty_state` helper in `ui_modal`, and sweep all five surfaces
onto it. Small, mechanical, big perceived-quality win.
## Phase M — Sync transparency & data stewardship
The sync layer returns `ConflictReport`s ("data is never silently
discarded") — but no UI ever shows them; players can't tell what a merge
did. Add: a post-sync summary line ("merged, 2 conflicts kept newer
values" → tap for detail in the Account tab), a visible last-synced
timestamp (exists as `SyncStatus::LastSynced`, barely surfaced), and
local **data export/import** (zip of the JSON saves) for device
migrations without a server. Closes the trust loop the server work
opened.
## Roadmap tie-ins (already in flight elsewhere)
- **Spider mode** (feat/spider-core, PR #157): Home's mode grid gains a
7th card once engine work lands — the 2×3 grid in Phase B should be
designed as N-card flow from day one. Spider UI also needs positional
card→entity keys (documented in spider.rs module docs).
- **Show solution** (feat/solution-line, in flight): lands in the pause
menu; Phase C's grouping should leave room for it under a "Game"
cluster if the pause menu grows.
## Sequencing (updated)
| Phase | Size | Risk | Depends on |
| --- | --- | --- | --- |
| A — Settings tabs | ~1 session | Low | — (DECIDED: tabs) |
| E — "You" hub | ~1 session | Low | A's tab component |
| C — Menu grouping | small | Low | E (popover shrinks) |
| B — Home hierarchy | 12 sessions | Medium | D mockups if wanted |
| F — Bottom action bar | ~1 session | Medium (input paths) | — |
| G — Post-win flow | small | Low | — |
| H — Feedback polish | small each | Low | — |
| I — Onboarding & discoverability | ~1 session | Low | ships best after B/F land |
| J — Keyboard & focus completeness | ~1 session | Low | A/E (fewer modals to audit) |
| K — UI scale & touch targets | ~1 session | Medium (layout-wide) | A (Accessibility tab) |
| L — Empty-state standardization | small | Low | — |
| M — Sync transparency & export | ~1 session | LowMedium | A (Account tab) |
Suggested order: **A → E → C** (one arc: the tab component and the menu
slimming), then **B**, then **F/G/H** as independent follow-ups.
## Open decisions
1. ~~Settings: tabs vs. sub-pages~~**DECIDED 2026-07-07: tabs.**
2. Deal options: disclosure on Classic card (proposed) vs. keep global row?
3. Time Attack + Seed: top-level cards (proposed, grid stays symmetric) vs.
tucked under a "More" card?
4. Stitch mockups for Phase B, or iterate directly in-engine?
5. Phase F bottom bar: touch-only (proposed) or also desktop?
+168 -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);
}
@@ -1115,6 +1109,66 @@ impl GameState {
})
}
/// Full winning line from the current position:
///
/// * `Ok(Some(line))` — winnable; applying every instruction in order via
/// [`GameState::apply_instruction`] reaches a won game.
/// * `Ok(None)` — provably unwinnable, or already won (no moves to show).
/// * `Err(SolveError)` — inconclusive; budget exhausted before a verdict.
///
/// Delegates to upstream [`card_game::Session::solve`] on a solve-budgeted
/// copy of the board like [`GameState::solve_first_move`], but returns the
/// whole path instead of the first move, compacted with
/// [`card_game::Solution::clean_solution`] (drops move ranges that loop
/// back to an already-seen state — the raw DFS trace is full of them).
///
/// Foundation→foundation shuffles ([`KlondikeInstruction::is_useless`])
/// are additionally stripped when the remaining sequence still replays to
/// a win; if stripping one would break a later move's preconditions the
/// unstripped cleaned line is returned instead, so the replay contract
/// above always holds. `clean_solution` is quadratic-ish in line length —
/// callers should run this off the UI thread with modest budgets.
pub fn winning_line(
&self,
moves_budget: u64,
states_budget: u64,
) -> Result<Option<Vec<KlondikeInstruction>>, SolveError> {
if self.is_won() {
return Ok(None);
}
let inner = KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation);
let config = SessionConfig {
inner: inner.clone(),
undo_penalty: 0,
solve_moves_budget: moves_budget,
solve_states_budget: states_budget,
};
let start = self.session.state().state().clone();
let session = Session::new(start.clone(), config);
let Some(solution) = session.solve()? else {
return Ok(None);
};
let cleaned: Vec<KlondikeInstruction> = solution
.clean_solution()
.iter()
.map(|snapshot| *snapshot.instruction())
.collect();
let filtered: Vec<KlondikeInstruction> = cleaned
.iter()
.copied()
.filter(|instruction| !instruction.is_useless())
.collect();
if line_replays_to_win(start, &inner, &filtered) {
Ok(Some(filtered))
} else {
Ok(Some(cleaned))
}
}
/// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`.
///
/// Fresh-deal solving models standard Klondike rules, so the non-standard
@@ -1132,6 +1186,25 @@ impl GameState {
}
}
/// `true` when applying `line` in order from `start` is legal at every step
/// and ends in a won game. Pure replay check backing
/// [`GameState::winning_line`]'s "the returned sequence always replays to a
/// win" contract.
fn line_replays_to_win(
mut state: Klondike,
config: &KlondikeConfig,
line: &[KlondikeInstruction],
) -> bool {
let mut stats = <Klondike as card_game::Game>::Stats::default();
for &instruction in line {
if !state.is_instruction_valid(config, instruction) {
return false;
}
state.process_instruction(&mut stats, config, instruction);
}
state.is_win()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1296,12 +1369,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());
}
@@ -1356,13 +1430,85 @@ mod tests {
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
}
// ── Full winning line (winning_line) ──────────────────────────────────
#[test]
fn winning_line_replays_to_a_won_game() {
// Seed 0xD1FF_0000_0000_0012 / DrawOne is proven Winnable at 5k
// budgets by `budget_is_passed_through_not_clamped`. Standard rules
// (take-from-foundation off) keep the search space identical to
// that baseline. The returned line must apply cleanly through the
// normal instruction pipeline and end in a win — the whole point
// of the API contract.
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
game.take_from_foundation = false;
let line = game
.winning_line(5_000, 5_000)
.expect("this seed must not exhaust a 5k budget")
.expect("this seed must be winnable");
assert!(!line.is_empty(), "a winnable unfinished game needs moves");
for (i, instruction) in line.iter().enumerate() {
game.apply_instruction(*instruction)
.unwrap_or_else(|e| panic!("move {i} of the line must be legal: {e}"));
}
assert!(game.is_won(), "line must end in a won game");
}
#[test]
fn winning_line_contains_no_useless_moves() {
// The foundation→foundation strip must survive the replay check on
// this seed; a line shown to the player should never shuffle
// between foundations. Same proven-winnable seed and standard
// rules as `winning_line_replays_to_a_won_game`.
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
game.take_from_foundation = false;
let line = game
.winning_line(5_000, 5_000)
.expect("budget")
.expect("winnable");
assert!(
!line.iter().any(KlondikeInstruction::is_useless),
"filtered line must not contain foundation→foundation moves"
);
}
#[test]
fn winning_line_is_inconclusive_when_budget_exhausted() {
let game = GameState::new(7, DrawStockConfig::DrawOne);
let outcome = game.winning_line(5_000, 0);
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
}
#[test]
fn winning_line_matches_first_move_verdict() {
// The two solver entry points must agree on winnability for the
// same position and budgets (both are deterministic DFS).
let game = GameState::new(42, DrawStockConfig::DrawOne);
let first = game.solve_first_move(5_000, 5_000);
let line = game.winning_line(5_000, 5_000);
assert_eq!(
matches!(first, Ok(Some(_))),
matches!(line, Ok(Some(_))),
"solve_first_move and winning_line disagree on winnability"
);
}
#[test]
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()
+7 -3
View File
@@ -18,13 +18,15 @@ use crate::{
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider,
TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin,
StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
WinSummaryPlugin,
};
#[cfg(not(target_arch = "wasm32"))]
use crate::{
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
ThemeStorePlugin,
};
/// Groups all Ferrous Solitaire gameplay plugins.
@@ -92,6 +94,7 @@ impl Plugin for CoreGamePlugin {
.add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin)
.add_plugins(AutoCompletePlugin)
.add_plugins(SolutionPlaybackPlugin)
.add_plugins(ReplayPlaybackPlugin)
.add_plugins(ReplayOverlayPlugin)
.add_plugins(StatsPlugin::default())
@@ -126,6 +129,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);
+13 -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.
@@ -153,6 +159,12 @@ pub struct DeleteAccountRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct PauseRequestEvent;
/// Request to solve the current deal and auto-play the winning line.
/// Fired by the pause menu's "Show solution" button; consumed by
/// `solution_playback_plugin`.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ShowSolutionRequestEvent;
/// Request to toggle the help / controls overlay. Fired by the HUD "Help"
/// button alongside the existing `F1` accelerator so the overlay is
/// reachable without a keyboard. Consumed by `help_plugin::toggle_help_screen`.
+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 {
+6
View File
@@ -46,6 +46,7 @@ pub mod safe_area;
mod schedule_checks;
pub mod selection_plugin;
pub mod settings_plugin;
pub mod solution_playback_plugin;
pub mod splash_plugin;
pub mod stats_plugin;
#[cfg(not(target_arch = "wasm32"))]
@@ -54,6 +55,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;
@@ -159,6 +162,7 @@ pub use settings_plugin::{
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
};
pub use solitaire_data::SyncProvider;
pub use solution_playback_plugin::{SolutionPlayback, SolutionPlaybackPlugin, SolutionSolveTask};
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
pub use stats_plugin::{
LatestReplayPath, ReplayHistoryResource, ReplayNextButton, ReplayPrevButton,
@@ -176,6 +180,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};
// ---------------------------------------------------------------------------
+36 -1
View File
@@ -71,6 +71,12 @@ struct PauseResumeButton;
#[derive(Component, Debug)]
struct PauseForfeitButton;
/// Marker on the "Show solution" secondary button on the pause modal.
/// A click resumes the game and fires `ShowSolutionRequestEvent`;
/// `solution_playback_plugin` takes it from there.
#[derive(Component, Debug)]
struct PauseSolutionButton;
/// Marker on the forfeit-confirm modal scrim.
#[derive(Component, Debug)]
pub struct ForfeitConfirmScreen;
@@ -107,6 +113,7 @@ impl Plugin for PausePlugin {
.add_message::<PauseRequestEvent>()
.add_message::<ForfeitRequestEvent>()
.add_message::<ForfeitEvent>()
.add_message::<crate::events::ShowSolutionRequestEvent>()
.add_message::<InfoToastEvent>()
.init_resource::<PausedResource>()
.add_systems(
@@ -125,6 +132,7 @@ impl Plugin for PausePlugin {
handle_pause_draw_buttons,
handle_pause_resume_button,
handle_pause_forfeit_button,
handle_pause_solution_button,
handle_forfeit_request,
handle_forfeit_confirm_buttons,
handle_forfeit_keyboard,
@@ -304,6 +312,22 @@ fn handle_pause_resume_button(
}
}
/// Translates a click on the pause modal's "Show solution" button into
/// a resume (`PauseRequestEvent` — playback can't run while paused)
/// plus a `ShowSolutionRequestEvent` for `solution_playback_plugin`.
fn handle_pause_solution_button(
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseSolutionButton>)>,
mut pause: MessageWriter<PauseRequestEvent>,
mut solution: MessageWriter<crate::events::ShowSolutionRequestEvent>,
) {
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
pause.write(PauseRequestEvent);
solution.write(crate::events::ShowSolutionRequestEvent);
}
}
}
/// Translates a click on the pause modal's Forfeit button into a
/// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the
/// confirm modal — same code path as the `G` accelerator.
@@ -498,6 +522,14 @@ fn spawn_pause_screen(
ButtonVariant::Tertiary,
font_res,
);
spawn_modal_button(
actions,
PauseSolutionButton,
"Show solution",
None,
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(
actions,
PauseResumeButton,
@@ -969,7 +1001,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.
+108 -2
View File
@@ -82,9 +82,11 @@ impl Plugin for SafeAreaInsetsPlugin {
);
#[cfg(target_os = "android")]
app.init_resource::<android::SafeAreaPollTries>()
app.add_message::<crate::events::StateChangedEvent>()
.init_resource::<android::SafeAreaPollTries>()
.add_systems(Update, android::refresh_insets)
.add_systems(Update, android::rearm_on_resumed);
.add_systems(Update, android::rearm_on_resumed)
.add_systems(Update, android::refresh_surface_size);
}
}
@@ -225,6 +227,7 @@ fn on_app_resumed(
mod android {
use super::{AppLifecycle, SafeAreaInsets};
use bevy::prelude::*;
use bevy::window::WindowResized;
/// Tracks how many frames `refresh_insets` has polled. Stored as a
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
@@ -299,11 +302,114 @@ mod android {
) {
for event in lifecycle.read() {
if matches!(event, AppLifecycle::WillResume) {
// Evidence line for #130: winit's Android backend has open
// TODOs around forwarding resume notifications, so whether
// this ever fires on a given device is an open question.
info!("safe_area: AppLifecycle::WillResume received; re-arming inset poll");
poll.0 = 0;
}
}
}
/// Polls the decor view's size via JNI and forces a relayout when it
/// disagrees with Bevy's cached `Window` resolution (#130).
///
/// winit's Android backend does not forward content-rect changes that
/// happen while the app is backgrounded (fold/unfold on foldables), so
/// after a fold cycle Bevy can keep rendering and laying out for the
/// previous screen's dimensions. Unlike `refresh_insets` this poller
/// never settles: it cannot rely on `AppLifecycle::WillResume` to re-arm
/// it, because that event is itself delivered through the same unreliable
/// lifecycle plumbing. A JNI round-trip every `POLL_INTERVAL_FRAMES`
/// frames is cheap.
///
/// On a mismatch it:
/// 1. writes the real size into `window.resolution` so the renderer
/// reconfigures the surface and systems reading `window.width()` see
/// the truth,
/// 2. emits a synthetic `WindowResized` (logical pixels) so
/// `on_window_resized` in `table_plugin` recomputes the board layout,
/// 3. re-arms the inset poller, because a screen change almost always
/// moves the system bars too — covering the "re-poll never fires"
/// hole left open in #116,
/// 4. emits `StateChangedEvent` so `card_plugin`'s sync pipeline
/// re-renders every card sprite from scratch — belt-and-braces for
/// the transient tableau clip of #130, where geometry converged but
/// stale card visuals survived the relayout.
pub(super) fn refresh_surface_size(
mut frame: Local<u32>,
mut windows: Query<(Entity, &mut Window)>,
mut resize_events: MessageWriter<WindowResized>,
mut state_events: MessageWriter<crate::events::StateChangedEvent>,
mut poll: ResMut<SafeAreaPollTries>,
) {
const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps
*frame += 1;
if !frame.is_multiple_of(POLL_INTERVAL_FRAMES) {
return;
}
let Some((entity, mut window)) = windows.iter_mut().next() else {
return;
};
let (decor_w, decor_h) = match query_decor_size() {
Ok(size) => size,
Err(e) => {
// One-time note; the bridge simply isn't up yet during the
// first frames of a launch.
if *frame == POLL_INTERVAL_FRAMES {
warn!("safe_area: decor size query failed (will retry): {e}");
}
return;
}
};
if decor_w == 0 || decor_h == 0 {
return; // decor view not laid out yet
}
// Reads go through `Deref` and do not trip change detection; only
// mutate `window` once a mismatch is confirmed.
let cached_w = window.resolution.physical_width();
let cached_h = window.resolution.physical_height();
if decor_w == cached_w && decor_h == cached_h {
return;
}
info!(
"safe_area: decor view is {decor_w}x{decor_h} but cached resolution is \
{cached_w}x{cached_h}; forcing relayout (fold/unfold missed by winit?)"
);
window.resolution.set_physical_resolution(decor_w, decor_h);
let scale = window.scale_factor();
resize_events.write(WindowResized {
window: entity,
width: decor_w as f32 / scale,
height: decor_h as f32 / scale,
});
state_events.write(crate::events::StateChangedEvent);
poll.0 = 0;
}
/// Physical pixel size of the activity's decor view — the ground truth
/// for the surface we are actually being displayed on, independent of
/// whatever winit last told Bevy.
fn query_decor_size() -> Result<(u32, u32), String> {
use solitaire_data::android_jni;
android_jni::with_activity_env(|env, activity| {
let window = env
.call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
.l()?;
let decor = env
.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
.l()?;
let w = env.call_method(&decor, "getWidth", "()I", &[])?.i()?;
let h = env.call_method(&decor, "getHeight", "()I", &[])?.i()?;
Ok((w.max(0) as u32, h.max(0) as u32))
})
}
fn query_insets() -> Result<SafeAreaInsets, String> {
use solitaire_data::android_jni;
+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;
}
@@ -414,6 +418,25 @@ pub(super) fn handle_settings_buttons(
}
}
/// Switches the active Settings tab when a chip is pressed. The
/// rebuild itself happens in `rebuild_panel_on_tab_change`, driven by
/// change detection on [`ActiveSettingsTab`].
pub(super) fn handle_tab_buttons(
interactions: Query<(&Interaction, &SettingsTabButton), Changed<Interaction>>,
mut active: ResMut<ActiveSettingsTab>,
) {
for (interaction, chip) in &interactions {
if *interaction != Interaction::Pressed {
continue;
}
// Compare before writing so re-clicking the active chip doesn't
// trip change detection and rebuild the panel for nothing.
if active.0 != chip.0 {
active.0 = chip.0;
}
}
}
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
/// and Delete Account. Split from `handle_settings_buttons` to stay within
/// Bevy's 16-parameter system limit.
@@ -423,6 +446,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 +465,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);
}
+61 -1
View File
@@ -170,6 +170,51 @@ struct SmartDefaultSizeText;
#[derive(Component, Debug)]
struct AnalyticsEnabledText;
/// Which tab of the Settings panel is showing. Tabs replace the old
/// single-scroll five-section layout (Phase A of
/// `docs/ui-redesign-2026-07.md`) so each group fits on screen without
/// scroll-hunting.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(super) enum SettingsTab {
#[default]
Audio,
Gameplay,
Appearance,
Accessibility,
Account,
}
impl SettingsTab {
/// Every tab, in display order.
pub(super) const ALL: [Self; 5] = [
Self::Audio,
Self::Gameplay,
Self::Appearance,
Self::Accessibility,
Self::Account,
];
/// Chip label.
pub(super) fn label(self) -> &'static str {
match self {
Self::Audio => "Audio",
Self::Gameplay => "Gameplay",
Self::Appearance => "Appearance",
Self::Accessibility => "Access",
Self::Account => "Account",
}
}
}
/// The active Settings tab. Session-only — deliberately not persisted;
/// reopening Settings within a session returns to the last tab.
#[derive(Resource, Debug, Default)]
pub(super) struct ActiveSettingsTab(pub(super) SettingsTab);
/// Marker on each tab chip button in the Settings header row.
#[derive(Component, Debug)]
pub(super) struct SettingsTabButton(pub(super) SettingsTab);
/// Marks the scrollable inner card so the mouse-wheel system can target it.
#[derive(Component, Debug)]
struct SettingsPanelScrollable;
@@ -250,6 +295,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 +361,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,
@@ -364,6 +414,7 @@ impl Plugin for SettingsPlugin {
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
.init_resource::<SettingsScreen>()
.init_resource::<SettingsScrollPos>()
.init_resource::<ActiveSettingsTab>()
.init_resource::<PendingWindowGeometry>()
.add_message::<SettingsChangedEvent>()
.add_message::<ManualSyncRequestEvent>()
@@ -371,6 +422,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>()
@@ -417,7 +469,15 @@ impl Plugin for SettingsPlugin {
app.add_systems(
Update,
(
sync_settings_panel_visibility,
// Chained: a tab click must observe this frame's
// visibility state, and the rebuild must observe the
// click — total order avoids panel double-spawns.
(
sync_settings_panel_visibility,
handle_tab_buttons,
rebuild_panel_on_tab_change,
)
.chain(),
handle_settings_buttons,
handle_sync_buttons,
#[cfg(not(target_arch = "wasm32"))]
+128 -70
View File
@@ -98,6 +98,25 @@ fn volume_clamped_at_zero_does_not_emit_event() {
);
}
/// Opens the Settings panel showing `tab`, then waits out the
/// spawn / child-flush / marker-attach frames.
fn open_settings_on(app: &mut App, tab: SettingsTab) {
app.world_mut().resource_mut::<ActiveSettingsTab>().0 = tab;
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
}
/// Switches the already-open panel to `tab` (drives the
/// `rebuild_panel_on_tab_change` path) and waits out the rebuild frames.
fn switch_settings_tab(app: &mut App, tab: SettingsTab) {
app.world_mut().resource_mut::<ActiveSettingsTab>().0 = tab;
app.update();
app.update();
app.update();
}
#[test]
fn pressing_o_toggles_settings_screen_flag() {
let mut app = headless_app();
@@ -243,40 +262,40 @@ fn headless_app_with_focus() -> App {
fn settings_buttons_get_focusable_marker() {
let mut app = headless_app_with_focus();
// Open the panel.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
// Two more ticks: the first runs `sync_settings_panel_visibility`
// and queues the spawn commands; the second flushes them and
// runs `attach_focusable_to_settings_buttons`.
app.update();
app.update();
// Walk every tab: each rebuild must leave no bespoke
// `SettingsButton` (not `Done`, which is also a `ModalButton`)
// without a `Focusable`.
let mut total_tagged = 0usize;
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
if i == 0 {
open_settings_on(&mut app, tab);
} else {
switch_settings_tab(&mut app, tab);
}
// Every bespoke `SettingsButton` (not `Done`, which is also a
// `ModalButton`) must carry a `Focusable`.
let untagged: Vec<&SettingsButton> = app
.world_mut()
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
.iter(app.world())
.collect();
let untagged: Vec<&SettingsButton> = app
.world_mut()
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
.iter(app.world())
.collect();
assert!(
untagged.is_empty(),
"every bespoke Settings button on tab {tab:?} must carry Focusable; missing: {untagged:?}"
);
total_tagged += app
.world_mut()
.query_filtered::<&SettingsButton, With<Focusable>>()
.iter(app.world())
.count();
}
// Floor across all five tabs so the assertion above isn't
// vacuously true (audio 4 + gameplay 8 + appearance ≥5 +
// accessibility 6 + account ≥1).
assert!(
untagged.is_empty(),
"every bespoke Settings button must carry Focusable; missing: {:?}",
untagged
);
// And there must be at least one tagged `SettingsButton` so the
// assertion above isn't vacuously true (the panel really did
// spawn).
let tagged_count = app
.world_mut()
.query_filtered::<&SettingsButton, With<Focusable>>()
.iter(app.world())
.count();
assert!(
tagged_count >= 6,
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
total_tagged >= 20,
"expected the five tabs to spawn many bespoke buttons; got {total_tagged}"
);
}
@@ -290,41 +309,45 @@ fn settings_buttons_get_focusable_marker() {
fn settings_buttons_carry_tooltip() {
let mut app = headless_app_with_focus();
// Open the panel and let spawn + child-flush run.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
// Walk every tab: no bespoke `SettingsButton` (i.e. excluding
// `Done`, which is also a `ModalButton`) may be missing a
// `Tooltip` on any of them.
let mut total_tipped = 0usize;
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
if i == 0 {
open_settings_on(&mut app, tab);
} else {
switch_settings_tab(&mut app, tab);
}
// No bespoke `SettingsButton` (i.e. excluding `Done`, which is
// also a `ModalButton`) may be missing a `Tooltip`.
let untipped: Vec<&SettingsButton> = app
.world_mut()
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
.iter(app.world())
.collect();
let untipped: Vec<&SettingsButton> = app
.world_mut()
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
.iter(app.world())
.collect();
assert!(
untipped.is_empty(),
"every bespoke Settings button on tab {tab:?} must carry Tooltip; missing: {untipped:?}"
);
total_tipped += app
.world_mut()
.query_filtered::<&SettingsButton, With<Tooltip>>()
.iter(app.world())
.count();
}
// Floor across all five tabs so the assertion above isn't
// vacuously true.
assert!(
untipped.is_empty(),
"every bespoke Settings button must carry Tooltip; missing: {:?}",
untipped
total_tipped >= 20,
"expected the five tabs to spawn many tooltipped buttons; got {total_tipped}"
);
// And there must be at least 6 tipped buttons so the assertion
// above isn't vacuously true: SFX +/, Music +/, Draw Mode,
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
// card-back and one background swatch — well over the floor.
let tipped_count = app
.world_mut()
.query_filtered::<&SettingsButton, With<Tooltip>>()
.iter(app.world())
.count();
assert!(
tipped_count >= 6,
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
);
// The loop ends on the Account tab, where with default (Local)
// settings the Connect button spawns.
// Spot-check: with default (Local) settings the Connect button
// spawns. We verify its tooltip carries the canonical microcopy.
// We verify its tooltip carries the canonical microcopy.
let connect_tip = app
.world_mut()
.query::<(&SettingsButton, &Tooltip)>()
@@ -342,10 +365,8 @@ fn settings_buttons_carry_tooltip() {
fn settings_picker_rows_get_focus_row_marker() {
let mut app = headless_app_with_focus();
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
// Picker rows live on the Appearance tab.
open_settings_on(&mut app, SettingsTab::Appearance);
// Two picker rows are spawned (card-back + background); each
// must carry the FocusRow marker.
@@ -360,6 +381,45 @@ fn settings_picker_rows_get_focus_row_marker() {
);
}
/// Switching tabs despawns the old rows and spawns the new tab's —
/// exactly one panel exists afterwards (no double-spawn from the
/// visibility system racing the rebuild system).
#[test]
fn switching_tab_rebuilds_panel_with_new_rows() {
let mut app = headless_app_with_focus();
fn has_button(app: &mut App, wanted: fn(&SettingsButton) -> bool) -> bool {
app.world_mut()
.query::<&SettingsButton>()
.iter(app.world())
.any(wanted)
}
open_settings_on(&mut app, SettingsTab::Audio);
assert!(has_button(&mut app, |b| matches!(b, SettingsButton::SfxUp)));
assert!(
!has_button(&mut app, |b| matches!(b, SettingsButton::ToggleDrawMode)),
"Gameplay rows must not spawn while the Audio tab is active"
);
switch_settings_tab(&mut app, SettingsTab::Gameplay);
assert!(
!has_button(&mut app, |b| matches!(b, SettingsButton::SfxUp)),
"Audio rows must despawn when leaving the Audio tab"
);
assert!(has_button(&mut app, |b| matches!(
b,
SettingsButton::ToggleDrawMode
)));
let panel_count = app
.world_mut()
.query_filtered::<Entity, With<SettingsPanel>>()
.iter(app.world())
.count();
assert_eq!(panel_count, 1, "tab switch must not stack panels");
}
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
/// at least one theme and the [`ThemeThumbnailCache`] holds a
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
@@ -404,11 +464,9 @@ fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
);
app.insert_resource(cache);
// Open the panel and let the spawn + child-flush systems run.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
// Open the panel on the Appearance tab (the theme picker's home)
// and let the spawn + child-flush systems run.
open_settings_on(&mut app, SettingsTab::Appearance);
// Find every ImageNode tagged with ThemeThumbnailMarker — the
// theme picker chip for "test_theme" must contribute exactly
+317 -192
View File
@@ -45,13 +45,34 @@ pub(super) fn spawn_settings_panel(
scroll_offset: f32,
font_res: Option<&FontResource>,
theme_overrides_back: bool,
active_tab: SettingsTab,
) {
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Settings", font_res);
// Scrollable bodycontains every section so tall content stays
// reachable on short windows. The Done button below stays fixed
// outside the scroll so it's always one click away.
// Tab chips — one group of rows on screen at a time, no
// scroll-hunting (Phase A, docs/ui-redesign-2026-07.md).
card.spawn((
FocusRow,
Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
column_gap: VAL_SPACE_2,
row_gap: VAL_SPACE_2,
margin: UiRect::bottom(VAL_SPACE_3),
..default()
},
))
.with_children(|row| {
for tab in SettingsTab::ALL {
tab_chip(row, tab, tab == active_tab, font_res);
}
});
// Scrollable body — most tabs fit without scrolling, but the
// Appearance pickers can still overflow short windows. The Done
// button below stays fixed outside the scroll so it's always one
// click away.
card.spawn((
SettingsPanelScrollable,
SettingsScrollNode,
@@ -65,198 +86,20 @@ pub(super) fn spawn_settings_panel(
..default()
},
))
.with_children(|body| {
// --- Audio ---
section_label(body, "Audio", font_res);
volume_row(
.with_children(|body| match active_tab {
SettingsTab::Audio => spawn_audio_tab(body, settings, font_res),
SettingsTab::Gameplay => spawn_gameplay_tab(body, settings, font_res),
SettingsTab::Appearance => spawn_appearance_tab(
body,
"SFX Volume",
settings.sfx_volume,
SfxVolumeText,
SettingsButton::SfxDown,
SettingsButton::SfxUp,
"Lower sound effects volume.",
"Raise sound effects volume.",
font_res,
);
volume_row(
body,
"Music Volume",
settings.music_volume,
MusicVolumeText,
SettingsButton::MusicDown,
SettingsButton::MusicUp,
"Lower music and ambience volume.",
"Raise music and ambience volume.",
font_res,
);
// --- Gameplay ---
section_label(body, "Gameplay", font_res);
toggle_row(
body,
"Draw Mode",
DrawModeText,
draw_mode_label(&settings.draw_mode),
SettingsButton::ToggleDrawMode,
"Switch between Draw 1 and Draw 3. Takes effect next deal.",
font_res,
);
toggle_row(
body,
"Winnable deals only",
WinnableDealsOnlyText,
winnable_deals_only_label(settings.winnable_deals_only),
SettingsButton::ToggleWinnableDealsOnly,
"When on, fresh Classic deals are filtered through a solver \
(may take a moment when on).",
font_res,
);
toggle_row(
body,
"Anim Speed",
AnimSpeedText,
anim_speed_label(&settings.animation_speed),
SettingsButton::CycleAnimSpeed,
"Cycle animation speed: Normal, Fast, Instant.",
font_res,
);
tooltip_delay_row(
body,
settings.tooltip_delay_secs,
font_res,
);
time_bonus_multiplier_row(
body,
settings.time_bonus_multiplier,
font_res,
);
replay_move_interval_row(
body,
settings.replay_move_interval_secs,
font_res,
);
toggle_row(
body,
"Smart window size",
SmartDefaultSizeText,
smart_default_size_label(!settings.disable_smart_default_size),
SettingsButton::ToggleSmartDefaultSize,
"When ON, fresh launches resize the window to ~70 % of the \
monitor. OFF pins the 1280\u{00D7}800 baseline. Saved \
window size always wins.",
font_res,
);
// --- Cosmetic ---
section_label(body, "Cosmetic", font_res);
toggle_row(
body,
"Theme",
ThemeText,
theme_label(&settings.theme),
SettingsButton::ToggleTheme,
"Cycle felt color: Green, Blue, Dark.",
font_res,
);
toggle_row(
body,
"Color-blind Mode",
ColorBlindText,
color_blind_label(settings.color_blind_mode),
SettingsButton::ToggleColorBlind,
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
font_res,
);
toggle_row(
body,
"High Contrast",
HighContrastText,
on_off_label(settings.high_contrast_mode),
SettingsButton::ToggleHighContrast,
"Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.",
font_res,
);
toggle_row(
body,
"Reduce Motion",
ReduceMotionText,
on_off_label(settings.reduce_motion_mode),
SettingsButton::ToggleReduceMotion,
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
font_res,
);
toggle_row(
body,
"Touch Input Mode",
TouchInputModeText,
touch_input_mode_label(&settings.touch_input_mode),
SettingsButton::ToggleTouchInputMode,
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
font_res,
);
if theme_overrides_back {
// The active theme provides its own back; the legacy
// picker has no visible effect, so we replace its
// swatch row with an informational caption. The
// player's `selected_card_back` value still
// round-trips through `settings.json` — the moment
// they switch to a theme without a back, the picker
// re-appears with their previous choice intact.
picker_row_overridden_by_theme(body, "Card Back", font_res);
} else {
picker_row(
body,
"Card Back",
unlocked_card_backs,
settings.selected_card_back,
SettingsButton::SelectCardBack,
"Choose your deck art. New backs unlock at higher levels.",
font_res,
);
}
picker_row(
body,
"Background",
settings,
unlocked_card_backs,
unlocked_backgrounds,
settings.selected_background,
SettingsButton::SelectBackground,
"Choose your felt art. New felts unlock at higher levels.",
themes,
theme_overrides_back,
font_res,
);
// Card-art theme picker — only renders when the registry has
// entries (production: always; tests: only when
// ThemeRegistryPlugin is installed).
if !themes.is_empty() {
theme_picker_row(
body,
"Card Theme",
themes,
&settings.selected_theme_id,
"Choose card-face artwork. Imported themes appear here.",
font_res,
);
}
#[cfg(not(target_arch = "wasm32"))]
import_themes_row(body, font_res);
// --- Privacy (only shown when a Matomo URL is configured) ---
if settings.matomo_url.is_some() {
section_label(body, "Privacy", font_res);
toggle_row(
body,
"Share usage data",
AnalyticsEnabledText,
on_off_label(settings.analytics_enabled),
SettingsButton::ToggleAnalytics,
"Sends anonymous game events to Matomo for aggregate analytics.",
font_res,
);
}
// --- Sync ---
section_label(body, "Sync", font_res);
sync_row(body, sync_status, &settings.sync_backend, font_res);
),
SettingsTab::Accessibility => spawn_accessibility_tab(body, settings, font_res),
SettingsTab::Account => spawn_account_tab(body, settings, sync_status, font_res),
});
// Done is the only action — primary so the player always knows
@@ -274,6 +117,263 @@ pub(super) fn spawn_settings_panel(
});
}
/// One Settings tab chip. The active chip is filled + bright; inactive
/// chips are quiet outlines.
fn tab_chip(
parent: &mut ChildSpawnerCommands,
tab: SettingsTab,
active: bool,
font_res: Option<&FontResource>,
) {
let font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
parent
.spawn((
SettingsTabButton(tab),
Button,
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(if active { BG_ELEVATED_HI } else { BG_BASE }),
BorderColor::all(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
HighContrastBorder::with_default(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
))
.with_children(|b| {
b.spawn((
Text::new(tab.label()),
font,
TextColor(if active { TEXT_PRIMARY } else { TEXT_SECONDARY }),
));
});
}
/// Audio tab: the two volume rows.
fn spawn_audio_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
font_res: Option<&FontResource>,
) {
volume_row(
body,
"SFX Volume",
settings.sfx_volume,
SfxVolumeText,
SettingsButton::SfxDown,
SettingsButton::SfxUp,
"Lower sound effects volume.",
"Raise sound effects volume.",
font_res,
);
volume_row(
body,
"Music Volume",
settings.music_volume,
MusicVolumeText,
SettingsButton::MusicDown,
SettingsButton::MusicUp,
"Lower music and ambience volume.",
"Raise music and ambience volume.",
font_res,
);
}
/// Gameplay tab: rules and pacing.
fn spawn_gameplay_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
font_res: Option<&FontResource>,
) {
toggle_row(
body,
"Draw Mode",
DrawModeText,
draw_mode_label(&settings.draw_mode),
SettingsButton::ToggleDrawMode,
"Switch between Draw 1 and Draw 3. Takes effect next deal.",
font_res,
);
toggle_row(
body,
"Winnable deals only",
WinnableDealsOnlyText,
winnable_deals_only_label(settings.winnable_deals_only),
SettingsButton::ToggleWinnableDealsOnly,
"When on, fresh Classic deals are filtered through a solver \
(may take a moment when on).",
font_res,
);
toggle_row(
body,
"Anim Speed",
AnimSpeedText,
anim_speed_label(&settings.animation_speed),
SettingsButton::CycleAnimSpeed,
"Cycle animation speed: Normal, Fast, Instant.",
font_res,
);
time_bonus_multiplier_row(body, settings.time_bonus_multiplier, font_res);
replay_move_interval_row(body, settings.replay_move_interval_secs, font_res);
toggle_row(
body,
"Smart window size",
SmartDefaultSizeText,
smart_default_size_label(!settings.disable_smart_default_size),
SettingsButton::ToggleSmartDefaultSize,
"When ON, fresh launches resize the window to ~70 % of the \
monitor. OFF pins the 1280\u{00D7}800 baseline. Saved \
window size always wins.",
font_res,
);
}
/// Appearance tab: felt, deck art, backgrounds, and the theme system.
#[allow(clippy::too_many_arguments)]
fn spawn_appearance_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
unlocked_card_backs: &[usize],
unlocked_backgrounds: &[usize],
themes: &[ThemePickerEntry],
theme_overrides_back: bool,
font_res: Option<&FontResource>,
) {
toggle_row(
body,
"Theme",
ThemeText,
theme_label(&settings.theme),
SettingsButton::ToggleTheme,
"Cycle felt color: Green, Blue, Dark.",
font_res,
);
if theme_overrides_back {
// The active theme provides its own back; the legacy
// picker has no visible effect, so we replace its
// swatch row with an informational caption. The
// player's `selected_card_back` value still
// round-trips through `settings.json` — the moment
// they switch to a theme without a back, the picker
// re-appears with their previous choice intact.
picker_row_overridden_by_theme(body, "Card Back", font_res);
} else {
picker_row(
body,
"Card Back",
unlocked_card_backs,
settings.selected_card_back,
SettingsButton::SelectCardBack,
"Choose your deck art. New backs unlock at higher levels.",
font_res,
);
}
picker_row(
body,
"Background",
unlocked_backgrounds,
settings.selected_background,
SettingsButton::SelectBackground,
"Choose your felt art. New felts unlock at higher levels.",
font_res,
);
// Card-art theme picker — only renders when the registry has
// entries (production: always; tests: only when
// ThemeRegistryPlugin is installed).
if !themes.is_empty() {
theme_picker_row(
body,
"Card Theme",
themes,
&settings.selected_theme_id,
"Choose card-face artwork. Imported themes appear here.",
font_res,
);
}
#[cfg(not(target_arch = "wasm32"))]
import_themes_row(body, font_res);
#[cfg(not(target_arch = "wasm32"))]
theme_store_row(body, font_res);
}
/// Accessibility tab: perception and input assists, gathered from the
/// old Gameplay/Cosmetic sections so they're findable in one place.
fn spawn_accessibility_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
font_res: Option<&FontResource>,
) {
toggle_row(
body,
"Color-blind Mode",
ColorBlindText,
color_blind_label(settings.color_blind_mode),
SettingsButton::ToggleColorBlind,
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
font_res,
);
toggle_row(
body,
"High Contrast",
HighContrastText,
on_off_label(settings.high_contrast_mode),
SettingsButton::ToggleHighContrast,
"Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.",
font_res,
);
toggle_row(
body,
"Reduce Motion",
ReduceMotionText,
on_off_label(settings.reduce_motion_mode),
SettingsButton::ToggleReduceMotion,
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
font_res,
);
toggle_row(
body,
"Touch Input Mode",
TouchInputModeText,
touch_input_mode_label(&settings.touch_input_mode),
SettingsButton::ToggleTouchInputMode,
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
font_res,
);
tooltip_delay_row(body, settings.tooltip_delay_secs, font_res);
}
/// Account tab: privacy opt-in (when analytics is configured) and the
/// sync backend controls.
fn spawn_account_tab(
body: &mut ChildSpawnerCommands,
settings: &Settings,
sync_status: &str,
font_res: Option<&FontResource>,
) {
// --- Privacy (only shown when a Matomo URL is configured) ---
if settings.matomo_url.is_some() {
section_label(body, "Privacy", font_res);
toggle_row(
body,
"Share usage data",
AnalyticsEnabledText,
on_off_label(settings.analytics_enabled),
SettingsButton::ToggleAnalytics,
"Sends anonymous game events to Matomo for aggregate analytics.",
font_res,
);
}
// --- Sync ---
section_label(body, "Sync", font_res);
sync_row(body, sync_status, &settings.sync_backend, font_res);
}
/// Section divider — small lavender label inside the scrollable body.
pub(super) fn section_label(
parent: &mut ChildSpawnerCommands,
@@ -1193,6 +1293,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,
+119 -51
View File
@@ -17,6 +17,7 @@ use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBord
#[allow(clippy::too_many_arguments)]
pub(super) fn sync_settings_panel_visibility(
screen: Res<SettingsScreen>,
active_tab: Res<ActiveSettingsTab>,
panels: Query<Entity, With<SettingsPanel>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
@@ -35,60 +36,17 @@ pub(super) fn sync_settings_panel_visibility(
}
if screen.0 {
if panels.is_empty() && other_modal_scrims.is_empty() {
let status_label = sync_status.map_or_else(
|| "Status: local only".to_string(),
|s| sync_status_label(&s.0),
);
let unlocked_backs = progress
.as_ref()
.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
let unlocked_bgs = progress
.as_ref()
.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
// Snapshot themes by id, display_name and (optional)
// thumbnail pair so spawn_settings_panel doesn't have to
// know about the registry / cache shapes. Empty when
// ThemeRegistryPlugin isn't installed (tests under
// MinimalPlugins) — the picker row simply won't render.
// Missing thumbnails (cache not ready, or partial user
// theme) leave `thumbnails: None` so the chip renders its
// plain-text fallback instead of a broken sprite.
let themes: Vec<ThemePickerEntry> = theme_registry
.as_deref()
.map(|r| {
r.iter()
.map(|e| ThemePickerEntry {
id: e.id.clone(),
display_name: e.display_name.clone(),
thumbnails: theme_thumbs
.as_deref()
.and_then(|c| c.get(&e.id))
.filter(|p| p.is_fully_populated())
.cloned(),
})
.collect()
})
.unwrap_or_default();
// The active card-art theme can supply its own back image —
// see `card_plugin::CardImageSet::theme_back`. When that is
// populated the legacy "Card Back" picker has no visible
// effect, so we render it muted with an explanatory caption
// rather than letting the player click swatches that do
// nothing. Absent under `MinimalPlugins`; treated as
// "no override" in that case.
let theme_overrides_back = card_images
.as_ref()
.is_some_and(|cs| cs.theme_back.is_some());
spawn_settings_panel(
build_panel(
&mut commands,
&settings.0,
&status_label,
unlocked_backs,
unlocked_bgs,
&themes,
scroll_pos.0,
sync_status.as_deref(),
progress.as_deref(),
font_res.as_deref(),
theme_overrides_back,
theme_registry.as_deref(),
theme_thumbs.as_deref(),
card_images.as_deref(),
scroll_pos.0,
active_tab.0,
);
}
} else {
@@ -102,6 +60,116 @@ pub(super) fn sync_settings_panel_visibility(
}
}
/// Gathers the spawn context (sync label, unlocks, theme snapshot,
/// back-override flag) and spawns the panel showing `active_tab`.
/// Shared by [`sync_settings_panel_visibility`] (open) and
/// [`rebuild_panel_on_tab_change`] (tab switch).
#[allow(clippy::too_many_arguments)]
fn build_panel(
commands: &mut Commands,
settings: &Settings,
sync_status: Option<&SyncStatusResource>,
progress: Option<&ProgressResource>,
font_res: Option<&FontResource>,
theme_registry: Option<&crate::theme::ThemeRegistry>,
theme_thumbs: Option<&ThemeThumbnailCache>,
card_images: Option<&crate::card_plugin::CardImageSet>,
scroll_offset: f32,
active_tab: SettingsTab,
) {
let status_label = sync_status.map_or_else(
|| "Status: local only".to_string(),
|s| sync_status_label(&s.0),
);
let unlocked_backs = progress.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
let unlocked_bgs = progress.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
// Snapshot themes by id, display_name and (optional)
// thumbnail pair so spawn_settings_panel doesn't have to
// know about the registry / cache shapes. Empty when
// ThemeRegistryPlugin isn't installed (tests under
// MinimalPlugins) — the picker row simply won't render.
// Missing thumbnails (cache not ready, or partial user
// theme) leave `thumbnails: None` so the chip renders its
// plain-text fallback instead of a broken sprite.
let themes: Vec<ThemePickerEntry> = theme_registry
.map(|r| {
r.iter()
.map(|e| ThemePickerEntry {
id: e.id.clone(),
display_name: e.display_name.clone(),
thumbnails: theme_thumbs
.and_then(|c| c.get(&e.id))
.filter(|p| p.is_fully_populated())
.cloned(),
})
.collect()
})
.unwrap_or_default();
// The active card-art theme can supply its own back image —
// see `card_plugin::CardImageSet::theme_back`. When that is
// populated the legacy "Card Back" picker has no visible
// effect, so we render it muted with an explanatory caption
// rather than letting the player click swatches that do
// nothing. Absent under `MinimalPlugins`; treated as
// "no override" in that case.
let theme_overrides_back = card_images.is_some_and(|cs| cs.theme_back.is_some());
spawn_settings_panel(
commands,
settings,
&status_label,
unlocked_backs,
unlocked_bgs,
&themes,
scroll_offset,
font_res,
theme_overrides_back,
active_tab,
);
}
/// Rebuilds the open panel when [`ActiveSettingsTab`] changes (tab chip
/// clicked). No-op while the panel is closed — the next open reads the
/// resource directly.
#[allow(clippy::too_many_arguments)]
pub(super) fn rebuild_panel_on_tab_change(
active_tab: Res<ActiveSettingsTab>,
panels: Query<Entity, With<SettingsPanel>>,
mut scroll_pos: ResMut<SettingsScrollPos>,
mut commands: Commands,
settings: Res<SettingsResource>,
sync_status: Option<Res<SyncStatusResource>>,
progress: Option<Res<ProgressResource>>,
font_res: Option<Res<FontResource>>,
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
) {
if !active_tab.is_changed() || active_tab.is_added() {
return;
}
if panels.is_empty() {
return;
}
for entity in &panels {
commands.entity(entity).despawn();
}
// A fresh tab starts at the top; per-tab scroll memory isn't worth
// the bookkeeping now that no tab needs much scrolling.
scroll_pos.0 = 0.0;
build_panel(
&mut commands,
&settings.0,
sync_status.as_deref(),
progress.as_deref(),
font_res.as_deref(),
theme_registry.as_deref(),
theme_thumbs.as_deref(),
card_images.as_deref(),
0.0,
active_tab.0,
);
}
/// Keeps the sync-status text node current while the panel is open.
pub(super) fn update_sync_status_text(
sync_status: Option<Res<SyncStatusResource>>,
@@ -0,0 +1,414 @@
//! "Show solution" — solve the current deal off-thread and auto-play
//! the winning line through the normal move pipeline.
//!
//! The pause menu's "Show solution" button fires
//! [`crate::events::ShowSolutionRequestEvent`]. This plugin snapshots
//! the live [`GameState`], runs [`GameState::winning_line`] on
//! [`AsyncComputeTaskPool`] (the solver plus `clean_solution` can take
//! seconds — §2.4 never block the main thread), then steps the returned
//! instructions on a cadence, one `MoveRequestEvent` /
//! `DrawRequestEvent` per tick — the same events player input produces,
//! so animations, scoring, undo history, and win detection all behave
//! exactly as if the player made the moves.
//!
//! Playback cancels on Esc, on pause, on undo / new-game requests, and
//! on any rejected move (which is what a player interfering mid-line
//! produces — their move diverges the state, the next scripted step
//! becomes illegal, and the rejection stops the run cleanly).
use std::collections::VecDeque;
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::game_state::GameState;
use solitaire_core::{
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction,
};
use crate::events::{
DrawRequestEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
ShowSolutionRequestEvent, UndoRequestEvent,
};
use crate::game_plugin::GameMutation;
use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource;
/// Seconds between scripted moves — slow enough to follow, fast enough
/// not to drag on a 100-move line.
const STEP_INTERVAL_SECS: f32 = 0.45;
/// Initial delay before the first scripted move, giving the pause modal
/// time to close and the player a beat to see what's happening.
const FIRST_STEP_DELAY_SECS: f32 = 0.9;
/// In-flight solver task plus the `move_count` snapshot used to detect
/// a stale result (player moved while the solver ran). Mirrors
/// `PendingHintTask`.
#[derive(Resource, Default)]
pub struct SolutionSolveTask {
inner: Option<(u32, Task<SolveTaskOutput>)>,
}
/// What the solver task carries back to the main thread.
enum SolveTaskOutput {
Line(Vec<KlondikeInstruction>),
Unwinnable,
Inconclusive,
}
/// Queue of instructions currently being auto-played, or empty when no
/// playback is active. HUD/UI may read `is_active` to badge the state.
#[derive(Resource, Default)]
pub struct SolutionPlayback {
queue: VecDeque<KlondikeInstruction>,
cooldown: f32,
}
impl SolutionPlayback {
/// `true` while a solution line is being auto-played.
pub fn is_active(&self) -> bool {
!self.queue.is_empty()
}
fn stop(&mut self) {
self.queue.clear();
}
}
/// Bevy plugin for the Show-solution flow. See the module docs.
pub struct SolutionPlaybackPlugin;
impl Plugin for SolutionPlaybackPlugin {
fn build(&self, app: &mut App) {
// add_message is idempotent — GamePlugin registers most of these
// too, but this plugin must also boot standalone under
// MinimalPlugins in tests.
app.init_resource::<SolutionSolveTask>()
.init_resource::<SolutionPlayback>()
.add_message::<ShowSolutionRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<MoveRequestEvent>()
.add_message::<DrawRequestEvent>()
.add_message::<MoveRejectedEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<NewGameRequestEvent>()
.add_systems(
Update,
(
handle_show_solution_request,
poll_solution_task,
cancel_playback_on_interrupt,
drive_solution_playback,
)
.chain()
.before(GameMutation),
);
}
}
/// Starts a solver task from the live game state. A repeat request
/// while one is already in flight (or playback is running) is ignored
/// — the button is idempotent, not a queue.
fn handle_show_solution_request(
mut requests: MessageReader<ShowSolutionRequestEvent>,
game: Option<Res<GameStateResource>>,
mut task: ResMut<SolutionSolveTask>,
playback: Res<SolutionPlayback>,
mut toast: MessageWriter<InfoToastEvent>,
) {
if requests.is_empty() {
return;
}
requests.clear();
if task.inner.is_some() || playback.is_active() {
return;
}
let Some(game) = game else { return };
if game.0.is_won() {
return;
}
toast.write(InfoToastEvent("Searching for a solution…".to_string()));
let snapshot: GameState = game.0.clone();
let move_count = snapshot.move_count();
let handle = AsyncComputeTaskPool::get().spawn(async move {
match snapshot.winning_line(DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET) {
Ok(Some(line)) => SolveTaskOutput::Line(line),
Ok(None) => SolveTaskOutput::Unwinnable,
Err(_) => SolveTaskOutput::Inconclusive,
}
});
task.inner = Some((move_count, handle));
}
/// Polls the solver; on completion either starts playback or explains
/// why there is nothing to play. A result computed for a position the
/// player has since moved past is discarded silently.
fn poll_solution_task(
mut task: ResMut<SolutionSolveTask>,
game: Option<Res<GameStateResource>>,
mut playback: ResMut<SolutionPlayback>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some((move_count_at_spawn, handle)) = task.inner.as_mut() else {
return;
};
let Some(output) = future::block_on(future::poll_once(handle)) else {
return;
};
let move_count_at_spawn = *move_count_at_spawn;
task.inner = None;
let Some(game) = game else { return };
if game.0.move_count() != move_count_at_spawn {
return; // Stale — the board moved while we were solving.
}
match output {
SolveTaskOutput::Line(line) if line.is_empty() => {}
SolveTaskOutput::Line(line) => {
toast.write(InfoToastEvent(format!(
"Solution found — playing {} moves. Press Esc to stop.",
line.len()
)));
playback.queue = line.into();
playback.cooldown = FIRST_STEP_DELAY_SECS;
}
SolveTaskOutput::Unwinnable => {
toast.write(InfoToastEvent(
"No winning line exists from this position.".to_string(),
));
}
SolveTaskOutput::Inconclusive => {
toast.write(InfoToastEvent(
"Couldn't find a solution within the search budget.".to_string(),
));
}
}
}
/// Stops playback on Esc, pause, undo / new-game requests, or a
/// rejected move (the signature of the player diverging the board
/// mid-line). Runs before `drive_solution_playback` so a cancel takes
/// effect without one extra scripted move slipping out.
fn cancel_playback_on_interrupt(
mut playback: ResMut<SolutionPlayback>,
keys: Option<Res<ButtonInput<KeyCode>>>,
paused: Option<Res<PausedResource>>,
mut rejected: MessageReader<MoveRejectedEvent>,
mut undos: MessageReader<UndoRequestEvent>,
mut new_games: MessageReader<NewGameRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
if !playback.is_active() {
rejected.clear();
undos.clear();
new_games.clear();
return;
}
let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape));
let interrupted = esc
|| paused.is_some_and(|p| p.0)
|| rejected.read().next().is_some()
|| undos.read().next().is_some()
|| new_games.read().next().is_some();
if interrupted {
playback.stop();
toast.write(InfoToastEvent("Solution playback stopped.".to_string()));
}
}
/// Emits the next scripted instruction every [`STEP_INTERVAL_SECS`]
/// while playback is active, translated to the same request events
/// player input produces. Instructions that no longer decode against
/// the live state stop the run instead of guessing.
fn drive_solution_playback(
mut playback: ResMut<SolutionPlayback>,
game: Option<Res<GameStateResource>>,
time: Res<Time>,
mut moves: MessageWriter<MoveRequestEvent>,
mut draws: MessageWriter<DrawRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
) {
if !playback.is_active() {
return;
}
let Some(game) = game else {
playback.stop();
return;
};
if game.0.is_won() {
playback.stop();
return;
}
playback.cooldown -= time.delta_secs();
if playback.cooldown > 0.0 {
return;
}
playback.cooldown = STEP_INTERVAL_SECS;
let Some(instruction) = playback.queue.pop_front() else {
return;
};
match instruction {
KlondikeInstruction::RotateStock => {
draws.write(DrawRequestEvent);
}
other => {
// Decode against the LIVE state — tableau run lengths depend on
// the current face-up counts, so this must happen at step time,
// not at solve time.
let Some((from, to, count)) = game.0.instruction_to_piles(other) else {
playback.stop();
toast.write(InfoToastEvent("Solution playback stopped.".to_string()));
return;
};
moves.write(MoveRequestEvent { from, to, count });
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use bevy::ecs::message::Messages;
use solitaire_core::DrawStockConfig;
/// Seed proven Winnable at 5k budgets by the core
/// `budget_is_passed_through_not_clamped` test. Solved here under
/// standard rules (no take-from-foundation) to match that baseline.
const WINNABLE_SEED: u64 = 0xD1FF_0000_0000_0012;
fn winnable_state() -> GameState {
let mut game = GameState::new(WINNABLE_SEED, DrawStockConfig::DrawOne);
game.take_from_foundation = false;
game
}
/// Full pipeline: GamePlugin consumes the Move/Draw requests the
/// playback driver emits, exactly as in production.
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(SolutionPlaybackPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn request_solution(app: &mut App) {
app.world_mut()
.resource_mut::<Messages<ShowSolutionRequestEvent>>()
.write(ShowSolutionRequestEvent);
}
/// Pump updates until the solver task resolves (wall-clock bounded,
/// mirroring `winnable_solver_emits_hint_after_async_completes`).
fn pump_until_solved(app: &mut App) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while app.world().resource::<SolutionSolveTask>().inner.is_some() {
app.update();
std::thread::yield_now();
if std::time::Instant::now() >= deadline {
break;
}
}
assert!(
app.world().resource::<SolutionSolveTask>().inner.is_none(),
"solver task should have completed within 30 s wall-clock",
);
}
#[test]
fn request_solves_and_arms_playback() {
let mut app = headless_app();
app.insert_resource(GameStateResource(winnable_state()));
request_solution(&mut app);
app.update();
assert!(
app.world().resource::<SolutionSolveTask>().inner.is_some(),
"request must spawn a solver task",
);
pump_until_solved(&mut app);
assert!(
app.world().resource::<SolutionPlayback>().is_active(),
"a winnable position must arm playback",
);
}
#[test]
fn playback_reaches_win_through_normal_pipeline() {
let mut app = headless_app();
app.insert_resource(GameStateResource(winnable_state()));
request_solution(&mut app);
app.update();
pump_until_solved(&mut app);
assert!(app.world().resource::<SolutionPlayback>().is_active());
// Force each step instead of waiting out the real cadence; a line
// is at most a few hundred instructions.
for _ in 0..600 {
app.world_mut().resource_mut::<SolutionPlayback>().cooldown = 0.0;
app.update();
if app.world().resource::<GameStateResource>().0.is_won() {
break;
}
}
assert!(
app.world().resource::<GameStateResource>().0.is_won(),
"auto-played line must drive the real game to a win",
);
assert!(
!app.world().resource::<SolutionPlayback>().is_active(),
"playback must deactivate once the game is won",
);
}
#[test]
fn escape_cancels_playback() {
let mut app = headless_app();
app.insert_resource(GameStateResource(winnable_state()));
app.world_mut()
.resource_mut::<SolutionPlayback>()
.queue
.push_back(KlondikeInstruction::RotateStock);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Escape);
app.update();
assert!(
!app.world().resource::<SolutionPlayback>().is_active(),
"Esc must stop playback",
);
}
#[test]
fn repeat_request_is_ignored_while_active() {
let mut app = headless_app();
app.insert_resource(GameStateResource(winnable_state()));
app.world_mut()
.resource_mut::<SolutionPlayback>()
.queue
.push_back(KlondikeInstruction::RotateStock);
request_solution(&mut app);
app.update();
assert!(
app.world().resource::<SolutionSolveTask>().inner.is_none(),
"a request during active playback must not spawn a solver task",
);
}
}
+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}"
);
}
}
+12 -12
View File
@@ -1649,62 +1649,62 @@ function __wbg_get_imports() {
return ret;
},
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 61868, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 62000, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7314, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7446, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6);
return ret;
},
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7);
return ret;
},
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8);
return ret;
},
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9);
return ret;
},
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7312, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7444, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1);
return ret;
},
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7313, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7445, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c);
return ret;
},
Binary file not shown.
Binary file not shown.
+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()