Compare commits

...

17 Commits

Author SHA1 Message Date
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
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 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
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
79 changed files with 2810 additions and 390 deletions
+11 -1
View File
@@ -31,6 +31,13 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Full debuginfo made the solitaire_engine test-binary link peak past the
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
# line-tables-only keeps file:line in panic backtraces while cutting the
# link's memory footprint enough to fit the runner.
env:
CARGO_PROFILE_DEV_DEBUG: line-tables-only
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -39,7 +46,7 @@ jobs:
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: 1.95.0 toolchain: 1.95.0
components: clippy components: clippy, rustfmt
- name: Cache cargo build - name: Cache cargo build
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
@@ -53,6 +60,9 @@ jobs:
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \ libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-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), # SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
# same as the web-e2e workflow's server prebuild. # same as the web-e2e workflow's server prebuild.
- name: Clippy (deny warnings) - 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): // From `klondike` (upstream — never edit):
pub enum KlondikePile { pub enum KlondikePile {
Stock, Stock, // NB: no Waste variant — see below
Waste,
Foundation(Foundation), // 4 slots, any suit may claim any slot Foundation(Foundation), // 4 slots, any suit may claim any slot
Tableau(Tableau), // 7 columns 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 DrawStockConfig { DrawOne, DrawThree }
pub enum KlondikeInstruction { /* RotateStock, DstFoundation, ... — the pub enum KlondikeInstruction { /* RotateStock, DstFoundation, ... — the
serialized move format (schema v4+) */ } serialized move format (schema v4+) */ }
Generated
+8
View File
@@ -7351,13 +7351,16 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"solitaire_core", "solitaire_core",
"solitaire_server", "solitaire_server",
"solitaire_sync", "solitaire_sync",
"sqlx", "sqlx",
"tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"uuid", "uuid",
"zip",
] ]
[[package]] [[package]]
@@ -7402,10 +7405,13 @@ dependencies = [
"chrono", "chrono",
"dotenvy", "dotenvy",
"jsonwebtoken", "jsonwebtoken",
"ron",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"solitaire_sync", "solitaire_sync",
"sqlx", "sqlx",
"tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tower", "tower",
@@ -7414,6 +7420,7 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
"zip",
] ]
[[package]] [[package]]
@@ -7422,6 +7429,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"serde", "serde",
"serde_json",
"thiserror 2.0.18", "thiserror 2.0.18",
"uuid", "uuid",
] ]
+1
View File
@@ -47,6 +47,7 @@ dirs = "6"
keyring = "4" keyring = "4"
keyring-core = "1" keyring-core = "1"
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false } reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
sha2 = "0.10"
arboard = { version = "3", default-features = false } arboard = { version = "3", default-features = false }
jni = { version = "0.21", 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 ## Admin — Password Reset
If a player loses access to their account, the server binary includes a If a player loses access to their account, the server binary includes a
+5
View File
@@ -34,6 +34,11 @@ spec:
key: jwt-secret key: jwt-secret
- name: SERVER_PORT - name: SERVER_PORT
value: "8080" 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: volumeMounts:
- name: db-data - name: db-data
mountPath: /data mountPath: /data
+5
View File
@@ -6,8 +6,13 @@ services:
# Override DATABASE_URL so the DB always lands in the persistent volume, # Override DATABASE_URL so the DB always lands in the persistent volume,
# regardless of what .env contains. # regardless of what .env contains.
DATABASE_URL: sqlite:///data/solitaire.db 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: volumes:
- ./data:/data - ./data:/data
- ./theme_store:/theme_store:ro
restart: unless-stopped restart: unless-stopped
expose: expose:
- "${SERVER_PORT:-8080}" - "${SERVER_PORT:-8080}"
+168 -22
View File
@@ -439,10 +439,7 @@ impl GameState {
self.session.history().len() self.session.history().len()
} }
fn cards_with_face( fn cards_with_face(cards: impl IntoIterator<Item = Card>, face_up: bool) -> Vec<(Card, bool)> {
cards: impl IntoIterator<Item = Card>,
face_up: bool,
) -> Vec<(Card, bool)> {
cards.into_iter().map(|card| (card, face_up)).collect() cards.into_iter().map(|card| (card, face_up)).collect()
} }
@@ -507,8 +504,10 @@ impl GameState {
Self::cards_with_face(cards.iter().cloned(), true) Self::cards_with_face(cards.iter().cloned(), true)
} }
KlondikePile::Tableau(tableau) => { KlondikePile::Tableau(tableau) => {
let mut cards = let mut cards = Self::cards_with_face(
Self::cards_with_face(state.tableau_face_down_cards(tableau).iter().cloned(), false); state.tableau_face_down_cards(tableau).iter().cloned(),
false,
);
cards.extend(Self::cards_with_face( cards.extend(Self::cards_with_face(
state.tableau_face_up_cards(tableau).iter().cloned(), state.tableau_face_up_cards(tableau).iter().cloned(),
true, true,
@@ -823,9 +822,7 @@ impl GameState {
) -> Option<(KlondikePile, KlondikePile, usize)> { ) -> Option<(KlondikePile, KlondikePile, usize)> {
let state = self.session.state().state().state(); let state = self.session.state().state().state();
match instruction { match instruction {
KlondikeInstruction::RotateStock => { KlondikeInstruction::RotateStock => Some((KlondikePile::Stock, KlondikePile::Stock, 1)),
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
}
KlondikeInstruction::DstFoundation(dst_foundation) => { KlondikeInstruction::DstFoundation(dst_foundation) => {
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) { if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
return None; return None;
@@ -928,10 +925,7 @@ impl GameState {
/// ///
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the /// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over. /// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
pub fn apply_instruction( pub fn apply_instruction(&mut self, instruction: KlondikeInstruction) -> Result<(), MoveError> {
&mut self,
instruction: KlondikeInstruction,
) -> Result<(), MoveError> {
if self.is_won() { if self.is_won() {
return Err(MoveError::GameAlreadyWon); 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`. /// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`.
/// ///
/// Fresh-deal solving models standard Klondike rules, so the non-standard /// 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1296,12 +1369,13 @@ mod tests {
game.take_from_foundation = false; game.take_from_foundation = false;
assert!(!game.can_move_cards(&from, &to, 1)); assert!(!game.can_move_cards(&from, &to, 1));
assert!( assert!(legal_pile_moves(&game).iter().all(|(f, t, _)| !matches!(
legal_pile_moves(&game) f,
.iter() KlondikePile::Foundation(_)
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_)) ) || !matches!(
|| !matches!(t, KlondikePile::Tableau(_))) t,
); KlondikePile::Tableau(_)
)));
assert!(game.move_cards(from, to, 1).is_err()); assert!(game.move_cards(from, to, 1).is_err());
} }
@@ -1356,13 +1430,85 @@ mod tests {
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded))); 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] #[test]
fn budget_is_passed_through_not_clamped() { fn budget_is_passed_through_not_clamped() {
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the // This seed is Inconclusive at 1k states but Winnable at 5k — proving the
// budget reaches the solver unchanged. // budget reaches the solver unchanged.
let easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 1_000, 1_000); let easy = GameState::solve_fresh_deal(
let medium = 0xD1FF_0000_0000_0012,
GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 5_000, 5_000); 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!(easy.is_err());
assert!(matches!(medium, Ok(Some(_)))); assert!(matches!(medium, Ok(Some(_))));
} }
+3 -1
View File
@@ -13,7 +13,9 @@ pub mod scoring;
// when decoding instructions to piles in `instruction_to_piles`) and do not // when decoding instructions to piles in `instruction_to_piles`) and do not
// appear in any public method signature. // appear in any public method signature.
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit}; 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 // Solvability check API (delegates to `card_game::Session::solve`); replaces the
// former `solitaire_data::solver` wrapper module. // former `solitaire_data::solver` wrapper module.
+9 -2
View File
@@ -41,13 +41,20 @@ fn all_cards(game: &GameState) -> Vec<Card> {
); );
} }
for t in &tableaux { 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 cards
} }
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> { 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. /// Apply a sequence of random actions to a game, silently ignoring errors.
+7
View File
@@ -24,6 +24,9 @@ uuid = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
tokio = { 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 # `keyring-core` is the typed Entry/Error API used by
# `auth_tokens`. The crate's own dependency tree pulls in # `auth_tokens`. The crate's own dependency tree pulls in
@@ -47,6 +50,10 @@ sqlx = { workspace = true }
jsonwebtoken = { workspace = true } jsonwebtoken = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { 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] [lints]
workspace = true workspace = true
+5
View File
@@ -161,6 +161,11 @@ pub use sync_client::LocalOnlyProvider;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub use sync_client::{SolitaireServerClient, provider_for_backend}; 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 mod replay;
pub use replay::{ pub use replay::{
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, 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 /// Identifier of the active card-art theme. Matches `meta.id` from
/// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are /// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are
/// always present; user-supplied themes register under their own ids. /// always present; user-supplied themes register under their own ids.
/// Older `settings.json` files that stored `"default"` or `"classic"` /// Older `settings.json` files that stored `"default"` (the
/// are migrated to `"dark"` by [`Settings::sanitized`]. /// 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")] #[serde(default = "default_theme_id")]
pub selected_theme_id: String, pub selected_theme_id: String,
/// Set to `true` once the achievement-onboarding info-toast has been /// 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", "saved file must use schema version 5",
); );
let loaded = load_game_state_from(&path) let loaded =
.expect("a valid in-progress game must load without error"); 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 // The forward instruction history round-trips, so the reconstructed board
// re-serialises to byte-identical JSON. // re-serialises to byte-identical JSON.
@@ -569,7 +569,11 @@ mod tests {
// Derived board reads match the live game (move count + recycle count are // Derived board reads match the live game (move count + recycle count are
// both rebuilt from the replayed forward history). // 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!( assert_eq!(
loaded.recycle_count(), loaded.recycle_count(),
gs.recycle_count(), gs.recycle_count(),
+35 -9
View File
@@ -77,6 +77,11 @@ pub struct SolitaireServerClient {
username: String, username: String,
/// Shared `reqwest` client (keeps connection pools alive across calls). /// Shared `reqwest` client (keeps connection pools alive across calls).
client: reqwest::Client, 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"))] #[cfg(not(target_arch = "wasm32"))]
@@ -90,6 +95,7 @@ impl SolitaireServerClient {
base_url: base_url.into().trim_end_matches('/').to_owned(), base_url: base_url.into().trim_end_matches('/').to_owned(),
username: username.into(), username: username.into(),
client: reqwest::Client::new(), 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 /// The server rotates refresh tokens on each call: the response includes a
/// new refresh token that replaces the old one. Both tokens are persisted /// new refresh token that replaces the old one. Both tokens are persisted
/// to the OS keychain on success. /// 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 = let old_refresh =
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?; 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 { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
// Token expired — refresh and retry once. // Token expired — refresh and retry once.
self.refresh_token().await?; self.refresh_token(&token).await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -264,7 +290,7 @@ impl SyncProvider for SolitaireServerClient {
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
// Token expired — refresh and retry once. // Token expired — refresh and retry once.
self.refresh_token().await?; self.refresh_token(&token).await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -331,7 +357,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?; .map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?; self.refresh_token(&token).await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -366,7 +392,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?; .map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?; self.refresh_token(&token).await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -406,7 +432,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?; .map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?; self.refresh_token(&token).await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -446,7 +472,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?; .map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?; self.refresh_token(&token).await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -480,7 +506,7 @@ impl SyncProvider for SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?; .map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?; self.refresh_token(&token).await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .client
@@ -542,7 +568,7 @@ impl SolitaireServerClient {
.map_err(|e| SyncError::Network(e.to_string()))?; .map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED { if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?; self.refresh_token(&token).await?;
let new_token = self.access_token()?; let new_token = self.access_token()?;
let resp = self let resp = self
.client .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 // Put the active game in Zen mode. evaluate_on_win reads
// GameStateResource.mode directly to populate last_win_is_zen. // GameStateResource.mode directly to populate last_win_is_zen.
app.world_mut().resource_mut::<GameStateResource>().0.mode = app.world_mut().resource_mut::<GameStateResource>().0.mode = GameMode::Zen;
GameMode::Zen;
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 0, 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 /// Returns the absolute path of the user-themes directory on the
/// current platform. /// current platform.
/// ///
/// # Panics /// When [`solitaire_data::data_dir`] returns `None` (broken `$HOME` /
/// /// `$XDG_*` on desktop; always on wasm32, which has no filesystem) this
/// Panics if [`solitaire_data::data_dir`] returns `None`, which on /// returns an empty path — callers treat that as "no user themes" and
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration. /// the bundled default theme still works. A warning naming the
/// Android always returns `Some`. The panic message names the /// [`set_user_theme_dir`] workaround is logged once. Android always
/// supported workaround ([`set_user_theme_dir`]). /// resolves.
pub fn user_theme_dir() -> PathBuf { pub fn user_theme_dir() -> PathBuf {
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() { if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
return p.clone(); 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 /// Per-target-os resolution of the platform's data dir. Delegates
/// to [`solitaire_data::data_dir`] which encapsulates the /// to [`solitaire_data::data_dir`] which encapsulates the
/// per-target shape (desktop: `dirs::data_dir()`; android: the /// per-target shape (desktop: `dirs::data_dir()`; android: the
/// hardcoded `/data/data/<package>/files` sandbox path). Panics /// hardcoded `/data/data/<package>/files` sandbox path).
/// only when the underlying resolver returns `None`, which on ///
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration — /// When the resolver returns `None` — always on wasm32 (no
/// the panic message names the supported workaround. /// 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 { fn detected_platform_data_dir() -> PathBuf {
solitaire_data::data_dir().unwrap_or_else(|| { 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"))] #[cfg(not(target_arch = "wasm32"))]
{ {
panic!( use std::sync::Once;
"user_theme_dir(): platform data directory is unavailable. \ static WARN_ONCE: Once = Once::new();
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \ WARN_ONCE.call_once(|| {
the OS reported no Application Support / AppData path. \ bevy::log::warn!(
As a workaround call solitaire_engine::assets::user_dir::\ "user_theme_dir(): platform data directory is unavailable; \
set_user_theme_dir() before App::run()." 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 super::*;
use crate::game_plugin::GamePlugin; use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Deck, Rank, Suit}; use solitaire_core::{Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
fn headless_app() -> App { fn headless_app() -> App {
let mut app = App::new(); let mut app = App::new();
@@ -214,7 +214,11 @@ mod tests {
} }
g.set_test_tableau_cards( g.set_test_tableau_cards(
Tableau::Tableau1, 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); g.set_test_auto_completable(true);
let expected = ( let expected = (
@@ -72,9 +72,7 @@ pub struct HoverState {
/// Describes a user action that arrived while cards were still animating. /// Describes a user action that arrived while cards were still animating.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum BufferedInput { pub enum BufferedInput {
Move { Move { from: MoveRequestEvent },
from: MoveRequestEvent,
},
Draw, Draw,
Undo, Undo,
} }
+1 -4
View File
@@ -10,9 +10,7 @@ use crate::animation_plugin::EffectiveSlideDuration;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent}; use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
use crate::resources::DragState; use crate::resources::DragState;
use crate::ui_theme::{ use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z};
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
};
/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity. /// 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 // Task #28 — Hint highlight tick system
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2,7 +2,6 @@
use super::*; use super::*;
use bevy::color::Color; use bevy::color::Color;
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
@@ -272,5 +271,3 @@ pub(super) fn find_top_card_at(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task #28 — Stock-empty visual indicator // Task #28 — Stock-empty visual indicator
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2,7 +2,6 @@
use super::*; use super::*;
use bevy::color::Color; use bevy::color::Color;
use bevy::sprite::Anchor; use bevy::sprite::Anchor;
use solitaire_core::{Card, Rank, Suit}; use solitaire_core::{Card, Rank, Suit};
@@ -205,4 +204,3 @@ pub(super) fn add_android_corner_label(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task #34 — Card-flip animation systems // 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::layout::{Layout, LayoutResource};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::table_plugin::PileMarker; use crate::table_plugin::PileMarker;
use crate::ui_theme::{ use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG, CARD_SHADOW_PADDING_IDLE};
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG,
CARD_SHADOW_PADDING_IDLE,
};
/// Coalesces every `WindowResized` event arriving this frame into the latest /// Coalesces every `WindowResized` event arriving this frame into the latest
/// pending size on [`ResizeThrottle`]. /// 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); 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::color::Color;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::{KlondikePile, Tableau};
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::{KlondikePile, Tableau};
use crate::card_animation::CardAnimation; use crate::card_animation::CardAnimation;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent}; use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
@@ -577,10 +577,7 @@ impl Plugin for CardPlugin {
// the chain each pair is a scheduler ambiguity (#143). All // the chain each pair is a scheduler ambiguity (#143). All
// members are cheap and mostly change-gated; sequential // members are cheap and mostly change-gated; sequential
// execution is not a cost that matters here. // execution is not a cost that matters here.
.configure_sets( .configure_sets(Update, LayoutSystem::UpdateOnResize.before(BoardVisuals))
Update,
LayoutSystem::UpdateOnResize.before(BoardVisuals),
)
.add_systems( .add_systems(
Update, Update,
( (
@@ -597,8 +594,7 @@ impl Plugin for CardPlugin {
clear_right_click_highlights_on_pause, clear_right_click_highlights_on_pause,
tick_hint_highlight, tick_hint_highlight,
update_stock_empty_indicator, update_stock_empty_indicator,
update_stock_count_badge update_stock_count_badge.run_if(resource_changed::<GameStateResource>),
.run_if(resource_changed::<GameStateResource>),
collect_resize_events, collect_resize_events,
snap_cards_on_window_resize, snap_cards_on_window_resize,
resize_android_corner_labels, resize_android_corner_labels,
+1 -6
View File
@@ -2,7 +2,6 @@
use super::*; use super::*;
use bevy::color::Color; use bevy::color::Color;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
@@ -12,10 +11,7 @@ use crate::font_plugin::FontResource;
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker}; use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
use crate::ui_theme::{ use crate::ui_theme::{STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TYPE_BODY, Z_STOCK_BADGE};
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, /// 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 /// 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 std::collections::{HashMap, HashSet};
use bevy::color::Color; use bevy::color::Color;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Card, Rank, Suit}; use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState}; 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::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
use crate::card_animation::CardAnimation; use crate::card_animation::CardAnimation;
@@ -700,4 +700,3 @@ pub(super) fn update_card_entity(
commands.entity(entity).insert(new_children_key); commands.entity(entity).insert(new_children_key);
} }
} }
+21 -34
View File
@@ -1,17 +1,16 @@
use super::*; 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::events::StateChangedEvent;
use crate::game_plugin::GamePlugin;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
use crate::layout::TABLEAU_FAN_FRAC;
use crate::resources::DragState; use crate::resources::DragState;
use crate::table_plugin::TablePlugin;
use crate::ui_theme::TEXT_PRIMARY_HC; 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 solitaire_core::{DrawStockConfig, game_state::GameState};
use std::collections::HashSet;
/// Convenience constructor — all unit tests use Deck1. /// Convenience constructor — all unit tests use Deck1.
fn make_card(suit: Suit, rank: Rank) -> Card { fn make_card(suit: Suit, rank: Rank) -> Card {
@@ -119,8 +118,7 @@ fn waste_draw_one_only_renders_top_card() {
for _ in 0..3 { for _ in 0..3 {
let _ = g.draw(); let _ = g.draw();
} }
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
g.waste_cards().iter().map(|c| c.0.clone()).collect();
assert_eq!(waste_ids.len(), 3); assert_eq!(waste_ids.len(), 3);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); 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" "need at least 3 waste cards for this test"
); );
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect();
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 layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); 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(); let count = waste_pile.len();
assert!(count >= 2, "need at least 2 waste cards"); assert!(count >= 2, "need at least 2 waste cards");
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect();
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 layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); 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 { for _ in 0..3 {
let _ = g.draw(); let _ = g.draw();
} }
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
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 layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
let waste_rendered: Vec<_> = positions 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) { fn fire_window_resize(app: &mut App, width: f32, height: f32) {
// Any Entity will do — the snap system reads only width/height. // Any Entity will do — the snap system reads only width/height.
let window = Entity::from_raw_u32(0) let window = Entity::from_raw_u32(0).expect("Entity::from_raw_u32(0) is a valid placeholder");
.expect("Entity::from_raw_u32(0) is a valid placeholder");
app.world_mut().write_message(WindowResized { app.world_mut().write_message(WindowResized {
window, window,
width, 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 // Sanity-check: the new font size matches FONT_SIZE_FRAC × the
// post-resize card width, so the in-place path is using the // post-resize card width, so the in-place path is using the
// refreshed Layout. // refreshed Layout.
let expected_layout = let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true);
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; let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
assert!( assert!(
(after - expected).abs() < 1e-3, (after - expected).abs() < 1e-3,
@@ -1046,7 +1039,8 @@ fn shadow_offset_increases_during_drag() {
q.iter(app.world()) q.iter(app.world())
.next() .next()
.expect("fixture should spawn at least one CardEntity") .expect("fixture should spawn at least one CardEntity")
.card.clone() .card
.clone()
}; };
// Pick a *different* card to act as the negative control — // 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 { fn shadow_offset_for_card(app: &mut App, card: &Card) -> Vec2 {
// Map every CardEntity to its (Entity, card). // Map every CardEntity to its (Entity, card).
let card_entity = { let card_entity = {
let mut q = app let mut q = app.world_mut().query::<(Entity, &CardEntity)>();
.world_mut()
.query::<(Entity, &CardEntity)>();
q.iter(app.world()) q.iter(app.world())
.find(|(_, c)| c.card == *card) .find(|(_, c)| c.card == *card)
.map(|(e, _)| e) .map(|(e, _)| e)
@@ -1198,8 +1190,7 @@ fn stock_badge_updates_when_stock_count_changes() {
assert_eq!(stock_badge_text(&mut app), "24"); assert_eq!(stock_badge_text(&mut app), "24");
{ {
let mut game = app.world_mut().resource_mut::<GameStateResource>(); let mut game = app.world_mut().resource_mut::<GameStateResource>();
let mut stock: Vec<Card> = let mut stock: Vec<Card> = game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
let _ = stock.pop(); let _ = stock.pop();
game.0.set_test_stock_cards(stock); 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 // distinct dummy `Image`. We never render these; we only
// compare ids. // compare ids.
let mut images = Assets::<Image>::default(); let mut images = Assets::<Image>::default();
let backs: [Handle<Image>; 5] = let backs: [Handle<Image>; 5] = std::array::from_fn(|_| images.add(Image::default()));
std::array::from_fn(|_| images.add(Image::default()));
CardImageSet { CardImageSet {
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())), faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
backs, 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 layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions let mut waste_zs: Vec<f32> = positions
.iter() .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 stock_x = layout.pile_positions[&KlondikePile::Stock].x;
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_positions: Vec<_> = card_positions(&g, &layout) let mut waste_positions: Vec<_> = card_positions(&g, &layout)
.into_iter() .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 layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
let waste_ids: HashSet<Card> = let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
g.waste_cards().iter().map(|c| c.0.clone()).collect();
let mut waste_zs: Vec<f32> = positions let mut waste_zs: Vec<f32> = positions
.iter() .iter()
+7 -3
View File
@@ -18,13 +18,15 @@ use crate::{
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider, SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin,
TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
WinSummaryPlugin,
}; };
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use crate::{ use crate::{
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin, AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
ThemeStorePlugin,
}; };
/// Groups all Ferrous Solitaire gameplay plugins. /// Groups all Ferrous Solitaire gameplay plugins.
@@ -92,6 +94,7 @@ impl Plugin for CoreGamePlugin {
.add_plugins(FeedbackAnimPlugin) .add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin) .add_plugins(CardAnimationPlugin)
.add_plugins(AutoCompletePlugin) .add_plugins(AutoCompletePlugin)
.add_plugins(SolutionPlaybackPlugin)
.add_plugins(ReplayPlaybackPlugin) .add_plugins(ReplayPlaybackPlugin)
.add_plugins(ReplayOverlayPlugin) .add_plugins(ReplayOverlayPlugin)
.add_plugins(StatsPlugin::default()) .add_plugins(StatsPlugin::default())
@@ -126,6 +129,7 @@ impl Plugin for CoreGamePlugin {
.add_plugins(AudioPlugin) .add_plugins(AudioPlugin)
.add_plugins(SyncPlugin::new(sync_provider)) .add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(SyncSetupPlugin) .add_plugins(SyncSetupPlugin)
.add_plugins(ThemeStorePlugin)
.add_plugins(AnalyticsPlugin) .add_plugins(AnalyticsPlugin)
.add_plugins(LeaderboardPlugin); .add_plugins(LeaderboardPlugin);
} }
+8 -8
View File
@@ -35,8 +35,8 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon}; use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::card_plugin::RightClickHighlight; use crate::card_plugin::RightClickHighlight;
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
@@ -437,7 +437,8 @@ fn tableau_or_stack_pos(
base.x, base.x,
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32), 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 pile_len = game.waste_cards().len();
let visible_start = pile_len.saturating_sub(3); let visible_start = pile_len.saturating_sub(3);
let slot = index.saturating_sub(visible_start) as f32; let slot = index.saturating_sub(visible_start) as f32;
@@ -581,7 +582,10 @@ mod tests {
use crate::layout::compute_layout; use crate::layout::compute_layout;
use solitaire_core::{Card, Deck, Rank, Suit}; 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 /// Builds an `App` with `MinimalPlugins` and the overlay system
/// registered, plus the resources the system needs. Callers /// registered, plus the resources the system needs. Callers
@@ -630,11 +634,7 @@ mod tests {
// — same colour family, illegal. Tableau(2) must NOT be // — same colour family, illegal. Tableau(2) must NOT be
// highlighted. // highlighted.
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic); let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
set_tableau_top( set_tableau_top(&mut game, 2, Card::new(Deck::Deck1, Suit::Clubs, Rank::Six));
&mut game,
2,
Card::new(Deck::Deck1, Suit::Clubs, Rank::Six),
);
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five); let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
let mut app = overlay_test_app(game); let mut app = overlay_test_app(game);
+4 -1
View File
@@ -54,7 +54,10 @@ impl DifficultyIndexResource {
DifficultyLevel::Hard => &mut self.hard, DifficultyLevel::Hard => &mut self.hard,
DifficultyLevel::Expert => &mut self.expert, DifficultyLevel::Expert => &mut self.expert,
DifficultyLevel::Grandmaster => &mut self.grandmaster, 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()]; let seed = catalog[*cursor % catalog.len()];
*cursor = cursor.wrapping_add(1); *cursor = cursor.wrapping_add(1);
+13 -1
View File
@@ -2,8 +2,8 @@
use bevy::prelude::Message; use bevy::prelude::Message;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::{Card, Suit};
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::GameMode;
use solitaire_core::{Card, Suit};
use solitaire_data::AchievementRecord; use solitaire_data::AchievementRecord;
use solitaire_sync::SyncResponse; use solitaire_sync::SyncResponse;
@@ -134,6 +134,12 @@ pub struct ManualSyncRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)] #[derive(Message, Debug, Clone, Copy, Default)]
pub struct SyncConfigureRequestEvent; 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 /// Request to disconnect from the current sync backend, clear stored
/// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect" /// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect"
/// button in the Settings sync section. /// button in the Settings sync section.
@@ -153,6 +159,12 @@ pub struct DeleteAccountRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)] #[derive(Message, Debug, Clone, Copy, Default)]
pub struct PauseRequestEvent; 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" /// Request to toggle the help / controls overlay. Fired by the HUD "Help"
/// button alongside the existing `F1` accelerator so the overlay is /// button alongside the existing `F1` accelerator so the overlay is
/// reachable without a keyboard. Consumed by `help_plugin::toggle_help_screen`. /// 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(); let mut app = App::new();
app.add_plugins(MinimalPlugins) app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin); .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 { app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true, reduce_motion_mode: true,
..Settings::default() ..Settings::default()
@@ -906,7 +909,10 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins) app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin); .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 { app.insert_resource(SettingsResource(Settings {
reduce_motion_mode: true, reduce_motion_mode: true,
..Settings::default() ..Settings::default()
+14 -4
View File
@@ -14,8 +14,13 @@ use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use bevy::window::AppLifecycle; use bevy::window::AppLifecycle;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}}; use solitaire_core::{
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction}; DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction,
};
use solitaire_core::{
DrawStockConfig,
game_state::{GameMode, GameState},
};
#[allow(deprecated)] #[allow(deprecated)]
use solitaire_data::latest_replay_path; use solitaire_data::latest_replay_path;
use solitaire_data::{ use solitaire_data::{
@@ -188,7 +193,9 @@ impl Plugin for GamePlugin {
) )
} else { } 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, None,
) )
}; };
@@ -1324,7 +1331,10 @@ fn auto_save_game_state(
// or there's a pending restore the player hasn't answered — saving // or there's a pending restore the player hasn't answered — saving
// the fresh-deal placeholder we seeded GameStateResource with at // the fresh-deal placeholder we seeded GameStateResource with at
// startup would clobber the real saved game on disk. // 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; return;
} }
+15 -8
View File
@@ -372,9 +372,7 @@ fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
}); });
app.update(); app.update();
let events = app let events = app.world().resource::<Messages<CardFlippedEvent>>();
.world()
.resource::<Messages<CardFlippedEvent>>();
let mut cursor = events.get_cursor(); let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect(); let fired: Vec<_> = cursor.read(events).collect();
assert!( assert!(
@@ -800,7 +798,10 @@ fn replay_recording_skips_undo() {
1, 1,
"only the draw is recorded; the undo does not erase it nor add a new entry", "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 /// 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(); app.update();
let history = load_replay_history_from(&path) let history =
.expect("a winning replay must be persisted to ReplayPath"); load_replay_history_from(&path).expect("a winning replay must be persisted to ReplayPath");
assert_eq!( assert_eq!(
history.replays.len(), history.replays.len(),
1, 1,
@@ -1059,7 +1060,10 @@ fn new_game_with_solver_toggle_off_random_seed_path() {
app.update(); app.update();
// Game state was reseeded — move_count is 0 on the new game. // 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] #[test]
@@ -1133,7 +1137,10 @@ fn new_game_with_solver_toggle_on_retries_until_winnable() {
// The chosen seed is non-deterministic (system time), // The chosen seed is non-deterministic (system time),
// but the new game must have been started cleanly: // but the new game must have been started cleanly:
// move_count back to 0, undo stack empty. // 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!( assert_eq!(
app.world() app.world()
.resource::<GameStateResource>() .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), zen_best: stats.map_or(0, |s| s.0.zen_best_score),
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score), challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
daily_today, 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, font_res,
difficulty_expanded, difficulty_expanded,
last_difficulty: settings.and_then(|s| s.0.last_difficulty), last_difficulty: settings.and_then(|s| s.0.last_difficulty),
+5 -4
View File
@@ -3,8 +3,6 @@
use super::*; use super::*;
/// Auto-fade state for the action button bar. The bar fades out when /// 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 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 /// 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 /// `target` at a fixed rate so the visual transition is smooth across
/// variable framerates. /// variable framerates.
#[cfg(not(target_os = "android"))] #[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 { let Ok(window) = windows.single() else {
return; 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, from.alpha + (to.alpha - from.alpha) * t,
) )
} }
@@ -3,8 +3,6 @@
use super::*; use super::*;
/// `Changed<Interaction>` filter ensures we only react on the frame the /// `Changed<Interaction>` filter ensures we only react on the frame the
/// interaction state transitions, avoiding repeat events while the button /// interaction state transitions, avoiding repeat events while the button
/// is held down. Each click handler fires the corresponding request event, /// 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 spawn::*;
use updates::*; use updates::*;
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the // On wasm32 AvatarPlugin is gated out; define a placeholder type so the
// Option<Res<AvatarResource>> parameters below compile without changes. // Option<Res<AvatarResource>> parameters below compile without changes.
// The resource is never inserted on wasm, so every call resolves to None. // The resource is never inserted on wasm, so every call resolves to None.
+3 -3
View File
@@ -2,7 +2,6 @@
use super::*; use super::*;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use crate::avatar_plugin::AvatarResource; use crate::avatar_plugin::AvatarResource;
@@ -292,7 +291,9 @@ pub(super) fn spawn_avatar_child(
const SIZE: f32 = 32.0; const SIZE: f32 = 32.0;
if let Some(handle) = avatar.and_then(|a| a.0.clone()) { if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
// Logged-in with a downloaded avatar: keep the accent disc behind it. // 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. // Image fills the circle container; border_radius clips it to a disc.
commands.entity(parent).with_children(|b| { commands.entity(parent).with_children(|b| {
b.spawn(( 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] #[test]
fn score_reflects_game_state() { fn score_reflects_game_state() {
let mut app = headless_app(); 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(); 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] #[test]
@@ -192,7 +199,9 @@ fn challenge_time_color_zero_is_danger() {
fn challenge_hud_empty_when_no_daily_resource() { fn challenge_hud_empty_when_no_daily_resource() {
// No DailyChallengeResource inserted → HudChallenge must be empty. // No DailyChallengeResource inserted → HudChallenge must be empty.
let mut app = headless_app(); let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().set_changed(); app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.update(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), ""); assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
} }
@@ -207,7 +216,9 @@ fn challenge_hud_shows_time_limit_when_resource_present() {
target_score: None, target_score: None,
max_time_secs: Some(300), max_time_secs: Some(300),
}); });
app.world_mut().resource_mut::<GameStateResource>().set_changed(); app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.update(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00"); 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), target_score: Some(4000),
max_time_secs: None, max_time_secs: None,
}); });
app.world_mut().resource_mut::<GameStateResource>().set_changed(); app.world_mut()
.resource_mut::<GameStateResource>()
.set_changed();
app.update(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts"); 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), max_time_secs: Some(300),
}); });
// Mark the game as won — HudChallenge should be empty. // 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(); app.update();
assert_eq!(read_hud_text::<HudChallenge>(&mut app), ""); 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); set_manual_time_step(&mut app, 0.0);
// Initial state has score=0; bumping by 50 (the threshold) // Initial state has score=0; bumping by 50 (the threshold)
// is the smallest jump that triggers the floater. // 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(); app.update();
// One floater should now exist. // One floater should now exist.
@@ -405,7 +424,10 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
#[test] #[test]
fn score_floater_despawns_after_full_lifetime() { fn score_floater_despawns_after_full_lifetime() {
let mut app = headless_app(); 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(); app.update();
assert_eq!(count_with::<ScoreFloater>(&mut app), 1); 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(); let mut app = headless_app();
// +5 mirrors a single tableau-to-foundation move; well below // +5 mirrors a single tableau-to-foundation move; well below
// the 50-point threshold so the floater path stays dormant. // 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(); app.update();
assert_eq!( assert_eq!(
count_with::<ScoreFloater>(&mut app), count_with::<ScoreFloater>(&mut app),
@@ -507,7 +532,10 @@ fn score_change_skips_pulse_and_floater_under_reduce_motion() {
..Settings::default() ..Settings::default()
})); }));
// +100 would normally create both a ScorePulse and a ScoreFloater. // +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(); app.update();
assert_eq!( assert_eq!(
count_with::<ScorePulse>(&mut app), count_with::<ScorePulse>(&mut app),
+1 -2
View File
@@ -3,9 +3,9 @@
use super::*; use super::*;
use bevy::window::WindowResized; use bevy::window::WindowResized;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::Suit; use solitaire_core::Suit;
use solitaire_core::{DrawStockConfig, game_state::GameMode}; use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::auto_complete_plugin::AutoCompleteState; use crate::auto_complete_plugin::AutoCompleteState;
@@ -609,4 +609,3 @@ pub(super) fn resize_action_bar_labels(
font.font_size = new_size; font.font_size = new_size;
} }
} }
+16 -15
View File
@@ -27,10 +27,10 @@ use bevy::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
use bevy::window::{MonitorSelection, WindowMode}; 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::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::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::tuning::AnimationTuning; 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. // Find the top face-up card in the source pile and highlight it.
let source_cards = pile_cards(game, from); 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 { if let Some(card) = top_card {
for (entity, card_entity, mut sprite) in card_entities.iter_mut() { for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
if card_entity.card == card { if card_entity.card == card {
@@ -831,9 +834,7 @@ fn end_drag(
let origin_cards = pile_cards(&game.0, &origin); let origin_cards = pile_cards(&game.0, &origin);
if !origin_cards.is_empty() { if !origin_cards.is_empty() {
for card in &drag.cards { for card in &drag.cards {
let Some(stack_index) = let Some(stack_index) = origin_cards.iter().position(|(c, _)| c == card) else {
origin_cards.iter().position(|(c, _)| c == card)
else {
continue; continue;
}; };
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index); 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); let origin_cards = pile_cards(&game.0, &origin);
if !origin_cards.is_empty() { if !origin_cards.is_empty() {
for card in &drag.cards { for card in &drag.cards {
let Some(stack_index) = let Some(stack_index) = origin_cards.iter().position(|(c, _)| c == card)
origin_cards.iter().position(|(c, _)| c == card)
else { else {
continue; continue;
}; };
@@ -1174,7 +1174,8 @@ fn card_position(
y_offset -= layout.card_size.y * step; y_offset -= layout.card_size.y * step;
} }
Vec2::new(base.x, base.y + y_offset) 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 // 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` // card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
// so clicking the visually rightmost (top) card actually registers — a // so clicking the visually rightmost (top) card actually registers — a
@@ -1248,7 +1249,10 @@ fn find_draggable_at(
} }
(i, i + 1) (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)); return Some((pile, start, cards));
} }
} }
@@ -1550,8 +1554,7 @@ fn handle_double_tap(
return; return;
} }
let Some((found_card, found_face_up)) = let Some((found_card, found_face_up)) = pile_cards.iter().find(|(c, _)| c == top_card)
pile_cards.iter().find(|(c, _)| c == top_card)
else { else {
return; return;
}; };
@@ -1783,8 +1786,6 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
} }
} }
const fn tableau_number(tableau: Tableau) -> u8 { const fn tableau_number(tableau: Tableau) -> u8 {
match tableau { match tableau {
Tableau::Tableau1 => 1, 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 mut game = GameState::new(42, DrawStockConfig::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
clear_test_piles(&mut game); clear_test_piles(&mut game);
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two), let waste = vec![
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five), Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)]; Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
];
game.set_test_waste_cards(waste.clone()); game.set_test_waste_cards(waste.clone());
let top_index = waste.len() - 1; // 2 = the visible top 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"); 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.0, KlondikePile::Stock, "origin is the waste pile");
assert_eq!(result.1, top_index, "picks the top index, not the buffer"); 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] #[test]
@@ -176,8 +182,7 @@ fn find_draggable_skips_face_down_cards() {
// face-up card, but the iterator should skip face-down cards and // 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 // the cursor sits above the face-up card's AABB, so the result
// is None. // is None.
let face_down_pos = let face_down_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
let result = find_draggable_at(face_down_pos, &game, &layout); let result = find_draggable_at(face_down_pos, &game, &layout);
assert!(result.is_none(), "face-down cards should not be draggable"); 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 // 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 // sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre. // base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
let face_up_pos = let face_up_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
let result = find_draggable_at(face_up_pos, &game, &layout) let result = find_draggable_at(face_up_pos, &game, &layout)
.expect("clicking the face-up card's visible centre must initiate a drag"); .expect("clicking the face-up card's visible centre must initiate a drag");
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7)); 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 king = Card::new(D::Deck1, Suit::Spades, Rank::King);
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen); let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack); let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
game.set_test_tableau_cards( game.set_test_tableau_cards(Tableau::Tableau1, vec![king, queen.clone(), jack.clone()]);
Tableau::Tableau1,
vec![king, queen.clone(), jack.clone()],
);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); 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 // 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 // (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 // 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. // edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
let queen_center = let queen_center = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375); 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"); let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1)); 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(); let (pile, _start, ids) = result.unwrap();
assert_eq!(pile, KlondikePile::Stock); 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] #[test]
@@ -558,8 +562,7 @@ fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
fn rejected_drag_animation_targets_origin_resting_position() { fn rejected_drag_animation_targets_origin_resting_position() {
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen 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 target_pos = Vec2::new(123.5, -50.0); // origin pile slot
let anim = let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
assert!( assert!(
(anim.end - target_pos).length() < 1e-6, (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() { fn rejected_drag_animation_starts_from_drag_position() {
let drag_pos = Vec2::new(640.0, 200.0); let drag_pos = Vec2::new(640.0, 200.0);
let target_pos = Vec2::new(80.0, -120.0); let target_pos = Vec2::new(80.0, -120.0);
let anim = let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
assert!( assert!(
(anim.start - drag_pos).length() < 1e-6, (anim.start - drag_pos).length() < 1e-6,
@@ -601,12 +603,8 @@ fn rejected_drag_animation_starts_from_drag_position() {
/// the call site honest. /// the call site honest.
#[test] #[test]
fn rejected_drag_animation_uses_correct_duration() { fn rejected_drag_animation_uses_correct_duration() {
let anim = build_drag_reject_animation( let anim =
Vec2::new(640.0, 200.0), build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
DRAG_Z,
Vec2::new(80.0, -120.0),
0,
);
assert!( assert!(
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6, (anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \ "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. /// jittery rather than forgiving.
#[test] #[test]
fn rejected_drag_animation_uses_responsive_curve() { fn rejected_drag_animation_uses_responsive_curve() {
let anim = build_drag_reject_animation( let anim =
Vec2::new(640.0, 200.0), build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
DRAG_Z,
Vec2::new(80.0, -120.0),
0,
);
assert_eq!( assert_eq!(
anim.curve, anim.curve,
MotionCurve::Responsive, MotionCurve::Responsive,
@@ -683,10 +677,16 @@ fn pressing_h_spawns_pending_hint_task() {
app.init_resource::<HintSolverConfig>(); app.init_resource::<HintSolverConfig>();
app.init_resource::<crate::pending_hint::PendingHintTask>(); app.init_resource::<crate::pending_hint::PendingHintTask>();
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(LayoutResource( app.insert_resource(LayoutResource(compute_layout(
compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true), Vec2::new(1280.0, 800.0),
)); 0.0,
app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne))); 0.0,
true,
)));
app.insert_resource(GameStateResource(GameState::new(
42,
DrawStockConfig::DrawOne,
)));
app.add_systems(Update, handle_keyboard_hint); app.add_systems(Update, handle_keyboard_hint);
// Simulate the H key being pressed this frame. // Simulate the H key being pressed this frame.
+2 -4
View File
@@ -862,10 +862,8 @@ fn handle_display_name_confirm(
.leaderboard_display_name .leaderboard_display_name
.clone() .clone()
.unwrap_or_else(|| { .unwrap_or_else(|| {
if let SyncBackend::SolitaireServer { if let SyncBackend::SolitaireServer { ref username, .. } =
ref username, settings.0.sync_backend
..
} = settings.0.sync_backend
{ {
username.chars().take(32).collect() username.chars().take(32).collect()
} else { } else {
+6
View File
@@ -46,6 +46,7 @@ pub mod safe_area;
mod schedule_checks; mod schedule_checks;
pub mod selection_plugin; pub mod selection_plugin;
pub mod settings_plugin; pub mod settings_plugin;
pub mod solution_playback_plugin;
pub mod splash_plugin; pub mod splash_plugin;
pub mod stats_plugin; pub mod stats_plugin;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@@ -54,6 +55,8 @@ pub mod sync_plugin;
pub mod sync_setup_plugin; pub mod sync_setup_plugin;
pub mod table_plugin; pub mod table_plugin;
pub mod theme; pub mod theme;
#[cfg(not(target_arch = "wasm32"))]
pub mod theme_store_plugin;
pub mod time_attack_plugin; pub mod time_attack_plugin;
pub mod touch_selection_plugin; pub mod touch_selection_plugin;
pub mod ui_focus; pub mod ui_focus;
@@ -159,6 +162,7 @@ pub use settings_plugin::{
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS, SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
}; };
pub use solitaire_data::SyncProvider; pub use solitaire_data::SyncProvider;
pub use solution_playback_plugin::{SolutionPlayback, SolutionPlaybackPlugin, SolutionSolveTask};
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot}; pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
pub use stats_plugin::{ pub use stats_plugin::{
LatestReplayPath, ReplayHistoryResource, ReplayNextButton, ReplayPrevButton, LatestReplayPath, ReplayHistoryResource, ReplayNextButton, ReplayPrevButton,
@@ -176,6 +180,8 @@ pub use theme::{
ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry, ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry,
ThemeRegistryPlugin, set_theme, ThemeRegistryPlugin, set_theme,
}; };
#[cfg(not(target_arch = "wasm32"))]
pub use theme_store_plugin::{ThemeStorePlugin, ThemeStoreScreen};
pub use time_attack_plugin::{ pub use time_attack_plugin::{
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, 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::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::splash_plugin::SplashRoot;
use crate::ui_modal::{ use crate::ui_modal::{
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, spawn_modal_header,
@@ -36,7 +37,6 @@ use crate::ui_theme::{
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
}; };
use crate::splash_plugin::SplashRoot;
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING}; use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+36 -1
View File
@@ -71,6 +71,12 @@ struct PauseResumeButton;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct PauseForfeitButton; 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. /// Marker on the forfeit-confirm modal scrim.
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ForfeitConfirmScreen; pub struct ForfeitConfirmScreen;
@@ -107,6 +113,7 @@ impl Plugin for PausePlugin {
.add_message::<PauseRequestEvent>() .add_message::<PauseRequestEvent>()
.add_message::<ForfeitRequestEvent>() .add_message::<ForfeitRequestEvent>()
.add_message::<ForfeitEvent>() .add_message::<ForfeitEvent>()
.add_message::<crate::events::ShowSolutionRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.init_resource::<PausedResource>() .init_resource::<PausedResource>()
.add_systems( .add_systems(
@@ -125,6 +132,7 @@ impl Plugin for PausePlugin {
handle_pause_draw_buttons, handle_pause_draw_buttons,
handle_pause_resume_button, handle_pause_resume_button,
handle_pause_forfeit_button, handle_pause_forfeit_button,
handle_pause_solution_button,
handle_forfeit_request, handle_forfeit_request,
handle_forfeit_confirm_buttons, handle_forfeit_confirm_buttons,
handle_forfeit_keyboard, 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 /// Translates a click on the pause modal's Forfeit button into a
/// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the /// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the
/// confirm modal — same code path as the `G` accelerator. /// confirm modal — same code path as the `G` accelerator.
@@ -498,6 +522,14 @@ fn spawn_pause_screen(
ButtonVariant::Tertiary, ButtonVariant::Tertiary,
font_res, font_res,
); );
spawn_modal_button(
actions,
PauseSolutionButton,
"Show solution",
None,
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button( spawn_modal_button(
actions, actions,
PauseResumeButton, PauseResumeButton,
@@ -969,7 +1001,10 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin); app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
app.init_resource::<ButtonInput<KeyCode>>(); 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.update();
app app
} }
+22 -17
View File
@@ -178,9 +178,9 @@ mod tests {
use super::*; use super::*;
use crate::events::HintVisualEvent; use crate::events::HintVisualEvent;
use crate::input_plugin::HintSolverConfig; use crate::input_plugin::HintSolverConfig;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Card, Deck, Rank, Suit}; use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameState}; use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_core::{Foundation, KlondikePile, Tableau};
/// Build a minimal Bevy app exercising only the polling system /// Build a minimal Bevy app exercising only the polling system
/// and the resources/messages it touches. /// and the resources/messages it touches.
@@ -249,10 +249,7 @@ mod tests {
.into_iter() .into_iter()
.zip(suits.iter()) .zip(suits.iter())
{ {
game.set_test_tableau_cards( game.set_test_tableau_cards(tableau, vec![Card::new(Deck::Deck1, *suit, Rank::King)]);
tableau,
vec![Card::new(Deck::Deck1, *suit, Rank::King)],
);
} }
game game
} }
@@ -267,9 +264,11 @@ mod tests {
let mut app = pending_hint_app(); let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state())); app.insert_resource(GameStateResource(near_finished_state()));
let cfg = *app.world().resource::<HintSolverConfig>(); let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut() app.world_mut().resource_mut::<PendingHintTask>().spawn(
.resource_mut::<PendingHintTask>() near_finished_state(),
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); cfg.moves_budget,
cfg.states_budget,
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while app.world().resource::<PendingHintTask>().is_pending() { while app.world().resource::<PendingHintTask>().is_pending() {
@@ -306,9 +305,11 @@ mod tests {
let mut app = pending_hint_app(); let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state())); app.insert_resource(GameStateResource(near_finished_state()));
let cfg = *app.world().resource::<HintSolverConfig>(); let cfg = *app.world().resource::<HintSolverConfig>();
app.world_mut() app.world_mut().resource_mut::<PendingHintTask>().spawn(
.resource_mut::<PendingHintTask>() near_finished_state(),
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); cfg.moves_budget,
cfg.states_budget,
);
assert!( assert!(
app.world().resource::<PendingHintTask>().is_pending(), app.world().resource::<PendingHintTask>().is_pending(),
"task is in flight after spawn", "task is in flight after spawn",
@@ -344,18 +345,22 @@ mod tests {
let cfg = *app.world().resource::<HintSolverConfig>(); let cfg = *app.world().resource::<HintSolverConfig>();
// First spawn. // First spawn.
app.world_mut() app.world_mut().resource_mut::<PendingHintTask>().spawn(
.resource_mut::<PendingHintTask>() near_finished_state(),
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); cfg.moves_budget,
cfg.states_budget,
);
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending(); let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
assert!(first_handle_present); assert!(first_handle_present);
// Second spawn. The `spawn` helper drops the prior task // Second spawn. The `spawn` helper drops the prior task
// before assigning the new one — at no point are two tasks // before assigning the new one — at no point are two tasks
// in flight. // in flight.
app.world_mut() app.world_mut().resource_mut::<PendingHintTask>().spawn(
.resource_mut::<PendingHintTask>() near_finished_state(),
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget); cfg.moves_budget,
cfg.states_budget,
);
// Resource still pending (the second task), but the first // Resource still pending (the second task), but the first
// is gone. We can't directly observe the first handle once // is gone. We can't directly observe the first handle once
// it's been overwritten — what we *can* assert is that the // 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::math::Vec2;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::game_state::GameState; 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::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
use crate::events::{MoveRejectedEvent, MoveRequestEvent}; 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. /// Builds the `(destination, anchor)` list for a fresh radial open.
/// ///
/// `half_extents` is the window half-size in world space — icons are clamped /// `half_extents` is the window half-size in world space — icons are clamped
@@ -381,8 +378,10 @@ fn build_radial_destinations(
.map(|(i, d)| { .map(|(i, d)| {
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX); let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
let clamped = Vec2::new( let clamped = Vec2::new(
raw.x.clamp(-half_extents.x + margin, half_extents.x - margin), raw.x
raw.y.clamp(-half_extents.y + margin, half_extents.y - margin), .clamp(-half_extents.x + margin, half_extents.x - margin),
raw.y
.clamp(-half_extents.y + margin, half_extents.y - margin),
); );
(d, clamped) (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). /// known card, or `"--"` for an absent top card (empty pile).
pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String { pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String {
match card { 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(), None => "--".to_string(),
} }
} }
+1 -1
View File
@@ -6,8 +6,8 @@ use std::sync::Arc;
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::Resource; use bevy::prelude::Resource;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use solitaire_core::KlondikePile;
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::KlondikePile;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game. /// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
+1 -3
View File
@@ -90,9 +90,7 @@ mod tests {
.and_then(|prefix| prefix.split_whitespace().last()) .and_then(|prefix| prefix.split_whitespace().last())
.and_then(|n| n.parse::<usize>().ok()); .and_then(|n| n.parse::<usize>().ok());
parsed.unwrap_or_else(|| { parsed.unwrap_or_else(|| {
panic!( panic!("ambiguity panic message no longer parseable (Bevy upgrade?): {msg}")
"ambiguity panic message no longer parseable (Bevy upgrade?): {msg}"
)
}) })
} }
}; };
+11 -33
View File
@@ -37,9 +37,9 @@
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use crate::card_plugin::CardEntityIndex; use crate::card_plugin::CardEntityIndex;
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent}; use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
@@ -487,8 +487,10 @@ fn handle_selection_keys(
1 1
}; };
let start = source_cards.len().saturating_sub(count); let start = source_cards.len().saturating_sub(count);
let lifted_cards: Vec<Card> = let lifted_cards: Vec<Card> = source_cards[start..]
source_cards[start..].iter().map(|(c, _)| c.clone()).collect(); .iter()
.map(|(c, _)| c.clone())
.collect();
let Some((bottom, _)) = source_cards.get(start) else { let Some((bottom, _)) = source_cards.get(start) else {
return; return;
}; };
@@ -597,10 +599,7 @@ fn face_up_run_len(cards: &[(Card, bool)]) -> usize {
/// This is intentionally separated from [`best_destination`] so the Enter /// This is intentionally separated from [`best_destination`] so the Enter
/// handler can attempt a foundation move first and fall through to a /// handler can attempt a foundation move first and fall through to a
/// multi-card stack move rather than accepting a single-card tableau move. /// multi-card stack move rather than accepting a single-card tableau move.
fn try_foundation_dest( fn try_foundation_dest(card: &Card, game: &GameState) -> Option<KlondikePile> {
card: &Card,
game: &GameState,
) -> Option<KlondikePile> {
let source = game.pile_containing_card(card.clone())?; let source = game.pile_containing_card(card.clone())?;
for foundation in [ for foundation in [
Foundation::Foundation1, Foundation::Foundation1,
@@ -697,13 +696,7 @@ fn update_selection_highlight(
if let Some(ref pile) = source_pile if let Some(ref pile) = source_pile
&& let Some(card) = top_face_up_card(pile, &game.0) && let Some(card) = top_face_up_card(pile, &game.0)
{ {
spawn_highlight_on_card( spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, source_color);
&mut commands,
&card_index,
&card,
card_size,
source_color,
);
} }
// Destination highlight while lifted. // Destination highlight while lifted.
@@ -714,13 +707,7 @@ fn update_selection_highlight(
// in destination-pick mode and the focused index is observable // in destination-pick mode and the focused index is observable
// via the resource. // via the resource.
if let Some(card) = top_face_up_card(dest, &game.0) { if let Some(card) = top_face_up_card(dest, &game.0) {
spawn_highlight_on_card( spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, dest_color);
&mut commands,
&card_index,
&card,
card_size,
dest_color,
);
} }
} }
} }
@@ -1047,10 +1034,7 @@ mod tests {
press_key(&mut app, KeyCode::Tab); press_key(&mut app, KeyCode::Tab);
app.update(); app.update();
let selected = app let selected = app.world().resource::<SelectionState>().selected_pile;
.world()
.resource::<SelectionState>()
.selected_pile;
// The cycle order starts at Waste, but Waste is empty so the next // The cycle order starts at Waste, but Waste is empty so the next
// available pile (Tableau(0)) is selected. // available pile (Tableau(0)) is selected.
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1))); assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
@@ -1216,16 +1200,10 @@ mod tests {
drag.active_touch_id = None; drag.active_touch_id = None;
} }
let before = app let before = app.world().resource::<SelectionState>().selected_pile;
.world()
.resource::<SelectionState>()
.selected_pile;
press_key(&mut app, KeyCode::Tab); press_key(&mut app, KeyCode::Tab);
app.update(); app.update();
let after = app let after = app.world().resource::<SelectionState>().selected_pile;
.world()
.resource::<SelectionState>()
.selected_pile;
assert_eq!( assert_eq!(
before, after, before, after,
@@ -407,6 +407,10 @@ pub(super) fn handle_settings_buttons(
| SettingsButton::DeleteAccount => { | SettingsButton::DeleteAccount => {
// Handled by `handle_sync_buttons`. // Handled by `handle_sync_buttons`.
} }
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::OpenThemeStore => {
// Handled by `handle_sync_buttons`.
}
SettingsButton::Done => { SettingsButton::Done => {
screen.0 = false; screen.0 = false;
} }
@@ -423,6 +427,9 @@ pub(super) fn handle_sync_buttons(
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>, mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>, mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
mut delete_account: MessageWriter<DeleteAccountRequestEvent>, mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
#[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter<
crate::events::ThemeStoreOpenRequestEvent,
>,
mut screen: ResMut<SettingsScreen>, mut screen: ResMut<SettingsScreen>,
) { ) {
for (interaction, button) in &interaction_query { for (interaction, button) in &interaction_query {
@@ -439,6 +446,13 @@ pub(super) fn handle_sync_buttons(
screen.0 = false; screen.0 = false;
configure_sync.write(SyncConfigureRequestEvent); 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 => { SettingsButton::DisconnectSync => {
logout_sync.write(SyncLogoutRequestEvent); logout_sync.write(SyncLogoutRequestEvent);
} }
@@ -250,6 +250,9 @@ enum SettingsButton {
/// Scan `user_theme_dir()` for new `.zip` files and import each one. /// Scan `user_theme_dir()` for new `.zip` files and import each one.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
ScanThemes, ScanThemes,
/// Open the in-game theme-store modal (closes Settings first).
#[cfg(not(target_arch = "wasm32"))]
OpenThemeStore,
SyncNow, SyncNow,
/// Open the sync-server Connect modal (shown when backend = Local). /// Open the sync-server Connect modal (shown when backend = Local).
ConnectSync, ConnectSync,
@@ -313,6 +316,8 @@ impl SettingsButton {
SettingsButton::SelectTheme(_) => 85, SettingsButton::SelectTheme(_) => 85,
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
SettingsButton::ScanThemes => 86, SettingsButton::ScanThemes => 86,
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::OpenThemeStore => 87,
// Sync section // Sync section
SettingsButton::SyncNow => 90, SettingsButton::SyncNow => 90,
SettingsButton::ConnectSync => 91, SettingsButton::ConnectSync => 91,
@@ -371,6 +376,7 @@ impl Plugin for SettingsPlugin {
.add_message::<SyncLogoutRequestEvent>() .add_message::<SyncLogoutRequestEvent>()
.add_message::<DeleteAccountRequestEvent>() .add_message::<DeleteAccountRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>() .add_message::<ToggleSettingsRequestEvent>()
.add_message::<crate::events::ThemeStoreOpenRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<MouseWheel>() .add_message::<MouseWheel>()
.add_message::<TouchInput>() .add_message::<TouchInput>()
@@ -239,6 +239,8 @@ pub(super) fn spawn_settings_panel(
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
import_themes_row(body, font_res); import_themes_row(body, font_res);
#[cfg(not(target_arch = "wasm32"))]
theme_store_row(body, font_res);
// --- Privacy (only shown when a Matomo URL is configured) --- // --- Privacy (only shown when a Matomo URL is configured) ---
if settings.matomo_url.is_some() { if settings.matomo_url.is_some() {
@@ -1193,6 +1195,31 @@ pub(super) fn import_themes_row(
}); });
} }
/// "Theme store" row: one pill button that closes Settings and opens
/// the in-game theme-store modal (`theme_store_plugin`). Sits directly
/// under the Import row so both install paths live together.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn theme_store_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) {
parent
.spawn((
FocusRow,
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
..default()
},
))
.with_children(|row| {
pill_button(
row,
SettingsButton::OpenThemeStore,
"Browse theme store",
"Download themes from your sync server without leaving the game.",
font_res,
);
});
}
pub(super) fn icon_button( pub(super) fn icon_button(
parent: &mut ChildSpawnerCommands, parent: &mut ChildSpawnerCommands,
label: &str, label: &str,
@@ -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] #[test]
fn zen_win_event_updates_zen_best_score_only() { fn zen_win_event_updates_zen_best_score_only() {
let mut app = headless_app(); let mut app = headless_app();
app.world_mut() app.world_mut().resource_mut::<GameStateResource>().0.mode =
.resource_mut::<GameStateResource>() solitaire_core::game_state::GameMode::Zen;
.0
.mode = solitaire_core::game_state::GameMode::Zen;
app.world_mut().write_message(GameWonEvent { app.world_mut().write_message(GameWonEvent {
score: 1800, score: 1800,
+4 -6
View File
@@ -301,9 +301,9 @@ fn push_on_exit(
exit_events.clear(); exit_events.clear();
let payload = build_payload(&stats.0, &achievements.0, &progress.0); let payload = build_payload(&stats.0, &achievements.0, &progress.0);
let result = rt let result = rt.0.block_on(async {
.0 tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await
.block_on(async { tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await }); });
match result { match result {
Ok(Ok(_)) | Ok(Err(SyncError::UnsupportedPlatform)) => {} Ok(Ok(_)) | Ok(Err(SyncError::UnsupportedPlatform)) => {}
Ok(Err(e)) => warn!("sync push on exit failed: {e}"), 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). // In-memory contract: replays[0].share_url is now Some(url).
let live = app let live = app.world().resource::<ReplayHistoryResource>();
.world()
.resource::<ReplayHistoryResource>();
assert_eq!( assert_eq!(
live.0.replays.first().and_then(|r| r.share_url.clone()), live.0.replays.first().and_then(|r| r.share_url.clone()),
Some(url.clone()), Some(url.clone()),
+12 -11
View File
@@ -7,8 +7,8 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::WindowResized; use bevy::window::WindowResized;
use solitaire_core::KlondikePile; use solitaire_core::KlondikePile;
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use solitaire_core::Suit; use solitaire_core::Suit;
use solitaire_core::{FOUNDATIONS, TABLEAUS};
use crate::events::{HintVisualEvent, StateChangedEvent}; use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
@@ -385,7 +385,11 @@ fn on_window_resized(
>, >,
mut marker_outlines: Query< mut marker_outlines: Query<
&mut Sprite, &mut Sprite,
(Without<PileMarker>, Without<TableBackground>, Without<Text2d>), (
Without<PileMarker>,
Without<TableBackground>,
Without<Text2d>,
),
>, >,
mut marker_labels: Query<&mut TextFont, With<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) // must be re-derived here too or a resize (fold/unfold, rotation)
// leaves them at the stale size — visible as oversized grey // leaves them at the stale size — visible as oversized grey
// frames on empty piles. // frames on empty piles.
let outline_size = let outline_size = new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
let font_size = new_layout.card_size.x * 0.28; let font_size = new_layout.card_size.x * 0.28;
for child in children.into_iter().flatten() { for child in children.into_iter().flatten() {
if let Ok(mut outline) = marker_outlines.get_mut(*child) { if let Ok(mut outline) = marker_outlines.get_mut(*child) {
@@ -593,8 +596,6 @@ fn pile_cards(
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -703,7 +704,10 @@ mod tests {
} }
} }
assert_eq!(outlines, 12, "all 12 markers carry an outline child"); 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] #[test]
@@ -939,10 +943,7 @@ mod tests {
#[test] #[test]
fn suit_symbol_all_four_are_distinct() { fn suit_symbol_all_four_are_distinct() {
let symbols: Vec<&str> = Suit::SUITS let symbols: Vec<&str> = Suit::SUITS.iter().map(suit_symbol).collect();
.iter()
.map(suit_symbol)
.collect();
let unique: std::collections::HashSet<&&str> = symbols.iter().collect(); let unique: std::collections::HashSet<&&str> = symbols.iter().collect();
assert_eq!(unique.len(), 4, "all four suit symbols must be distinct"); 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 /// Kicks off the initial theme load — the one named by
/// `Settings::selected_theme_id` if available, falling back to the /// `Settings::selected_theme_id` if available, falling back to the
/// embedded default. The actual rasterisation runs asynchronously on /// fresh-install default from `solitaire_data::Settings`. The actual
/// the asset task pool; the sync system below picks up the /// rasterisation runs asynchronously on the asset task pool; the sync
/// `LoadedWithDependencies` event when every face + back is ready. /// system below picks up the `LoadedWithDependencies` event when every
/// face + back is ready.
fn load_initial_theme( fn load_initial_theme(
asset_server: Res<AssetServer>, asset_server: Res<AssetServer>,
settings: Option<Res<crate::settings_plugin::SettingsResource>>, settings: Option<Res<crate::settings_plugin::SettingsResource>>,
mut commands: Commands, mut commands: Commands,
) { ) {
let default_id = solitaire_data::Settings::default().selected_theme_id;
let id = settings let id = settings
.as_deref() .as_deref()
.map(|s| s.0.selected_theme_id.as_str()) .map(|s| s.0.selected_theme_id.as_str())
.unwrap_or("classic"); .unwrap_or(&default_id);
let url = bundled_theme_url(id) let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(id));
.map(str::to_string)
.unwrap_or_else(|| format!("themes://{id}/theme.ron"));
let handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle)); commands.insert_resource(ActiveTheme(handle));
} }
@@ -162,10 +170,7 @@ fn react_to_settings_theme_change(
return; return;
} }
let url = bundled_theme_url(new_id) let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(new_id));
.map(str::to_string)
.unwrap_or_else(|| format!("themes://{new_id}/theme.ron"));
let handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle)); 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()); image_set.theme_back = Some(theme.back.clone());
} }
/// Switches the active theme to `theme_id` — the embedded source for
/// the bundled `"dark"` / `"classic"` themes, `themes://` for user
/// Switches the active theme to the one served at /// themes. Returns the new `Handle<CardTheme>` so callers can poll
/// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>` /// `Assets<CardTheme>` if they want to wait for the load before
/// so callers can poll `Assets<CardTheme>` if they want to wait for /// changing UI state.
/// the load before changing UI state.
/// ///
/// The handle is also written to the [`ActiveTheme`] resource — the /// The handle is also written to the [`ActiveTheme`] resource — the
/// per-frame sync system picks up the `LoadedWithDependencies` event /// per-frame sync system picks up the `LoadedWithDependencies` event
@@ -268,8 +272,7 @@ pub fn set_theme(
asset_server: &AssetServer, asset_server: &AssetServer,
theme_id: &str, theme_id: &str,
) -> Handle<CardTheme> { ) -> Handle<CardTheme> {
let url = format!("themes://{theme_id}/theme.ron"); let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(theme_id));
let handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle.clone())); commands.insert_resource(ActiveTheme(handle.clone()));
handle handle
} }
@@ -477,13 +480,43 @@ mod tests {
} }
#[test] #[test]
fn set_theme_url_format_matches_themes_source() { fn theme_manifest_url_routes_bundled_ids_to_embedded_source() {
// The format string is the only behavioural surface of // The bundled themes live in the binary, not in the user
// set_theme that doesn't require an App. We assert the URL // themes directory — resolving them to `themes://` would
// shape so a future refactor doesn't accidentally change the // NotFound and silently leave the previous theme active.
// path layout. assert_eq!(
let url = format!("themes://{}/theme.ron", "user_uploaded"); theme_manifest_url("dark"),
assert_eq!(url, "themes://user_uploaded/theme.ron"); 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 /// 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::ecs::message::MessageReader;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::KlondikePile;
use solitaire_core::Card; use solitaire_core::Card;
use solitaire_core::KlondikePile;
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntity;
use crate::events::StateChangedEvent; 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 /// [`BORDER_SUBTLE_HC`]. Currently used by the WIN MOVE scrub-bar
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather /// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
/// than to a neutral gray. /// than to a neutral gray.
pub const fn with_hc( pub const fn with_hc(default_color: Color, hc_color: Color) -> Self {
default_color: Color,
hc_color: Color,
) -> Self {
Self { Self {
default_color, default_color,
hc_color, hc_color,
+7 -1
View File
@@ -29,9 +29,15 @@ tower-http = { version = "0.6", features = ["fs"] }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
dotenvy = { 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] [dev-dependencies]
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
tempfile = { workspace = true }
[lints] [lints]
workspace = true workspace = true
+19
View File
@@ -11,6 +11,7 @@ pub mod leaderboard;
pub mod middleware; pub mod middleware;
pub mod replays; pub mod replays;
pub mod sync; pub mod sync;
pub mod theme_store;
pub use auth::reset_password; pub use auth::reset_password;
@@ -86,6 +87,10 @@ pub struct AppState {
pub pool: SqlitePool, pub pool: SqlitePool,
/// HS256 signing secret for JWT access and refresh tokens. /// HS256 signing secret for JWT access and refresh tokens.
pub jwt_secret: String, 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`]. /// 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. /// integration tests do not need to set `JWT_SECRET` in the environment.
#[doc(hidden)] #[doc(hidden)]
pub fn build_test_router(pool: SqlitePool) -> Router { 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 { let state = AppState {
pool, pool,
jwt_secret: "test_secret_32_chars_minimum_ok!".to_string(), jwt_secret: "test_secret_32_chars_minimum_ok!".to_string(),
theme_store: Arc::new(theme_store),
}; };
build_router_inner(state, false) 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/daily-challenge", get(challenge::daily_challenge))
.route("/api/replays/recent", get(replays::recent)) .route("/api/replays/recent", get(replays::recent))
.route("/api/replays/{id}", get(replays::get_by_id)) .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)) .route("/health", get(health))
.nest_service("/avatars", ServeDir::new("avatars")); .nest_service("/avatars", ServeDir::new("avatars"));
+15 -2
View File
@@ -31,12 +31,13 @@
//! echo "new_password" | ./solitaire_server --reset-password alice //! 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 sqlx::{SqlitePool, sqlite::SqliteConnectOptions};
use std::{ use std::{
io::{self, BufRead}, io::{self, BufRead},
net::SocketAddr, net::SocketAddr,
str::FromStr, str::FromStr,
sync::Arc,
}; };
const JWT_SECRET_MIN_BYTES: usize = 32; const JWT_SECRET_MIN_BYTES: usize = 32;
@@ -142,7 +143,19 @@ async fn run_server() {
tracing::info!("database ready at {db_url}"); 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 app = build_router(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port)); 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 { let state = solitaire_server::AppState {
pool: test_pool().await, pool: test_pool().await,
jwt_secret: TEST_SECRET.to_string(), 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); 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 { let state = solitaire_server::AppState {
pool: test_pool().await, pool: test_pool().await,
jwt_secret: TEST_SECRET.to_string(), 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); 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; let resp = post_authed(app, "/api/replays", &token, bad).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 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; return ret;
}, },
__wbindgen_cast_0000000000000001: function(arg0, arg1) { __wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 61868, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 61918, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000002: function(arg0, arg1) { __wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000003: function(arg0, arg1) { __wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7314, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7364, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000004: function(arg0, arg1) { __wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000005: function(arg0, arg1) { __wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000006: function(arg0, arg1) { __wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000007: function(arg0, arg1) { __wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000008: function(arg0, arg1) { __wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000009: function(arg0, arg1) { __wbindgen_cast_0000000000000009: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8);
return ret; return ret;
}, },
__wbindgen_cast_000000000000000a: function(arg0, arg1) { __wbindgen_cast_000000000000000a: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9);
return ret; return ret;
}, },
__wbindgen_cast_000000000000000b: function(arg0, arg1) { __wbindgen_cast_000000000000000b: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7312, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7362, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1);
return ret; return ret;
}, },
__wbindgen_cast_000000000000000c: function(arg0, arg1) { __wbindgen_cast_000000000000000c: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7313, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7363, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c);
return ret; return ret;
}, },
Binary file not shown.
Binary file not shown.
+3
View File
@@ -10,5 +10,8 @@ uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
[lints] [lints]
workspace = true workspace = true
+2
View File
@@ -10,11 +10,13 @@ pub mod achievements;
pub mod merge; pub mod merge;
pub mod progress; pub mod progress;
pub mod stats; pub mod stats;
pub mod theme_store;
pub use achievements::AchievementRecord; pub use achievements::AchievementRecord;
pub use merge::{merge, merge_at}; pub use merge::{merge, merge_at};
pub use progress::{PlayerProgress, level_for_xp}; pub use progress::{PlayerProgress, level_for_xp};
pub use stats::StatsSnapshot; pub use stats::StatsSnapshot;
pub use theme_store::{ThemeCatalogEntry, ThemeCatalogResponse};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; 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. //! is the contract.
use chrono::NaiveDate; use chrono::NaiveDate;
use solitaire_core::{KlondikeInstruction, KlondikePile};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::error::MoveError; 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::*; use wasm_bindgen::prelude::*;
/// Mirrors `solitaire_data::Replay` v3. /// Mirrors `solitaire_data::Replay` v3.
@@ -145,8 +148,8 @@ impl ReplayPlayer {
let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> { let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
self.game.pile(t).iter().map(CardSnapshot::from).collect() self.game.pile(t).iter().map(CardSnapshot::from).collect()
}; };
let foundations: [Vec<CardSnapshot>; 4] = solitaire_core::FOUNDATIONS let foundations: [Vec<CardSnapshot>; 4] =
.map(|f| pile_cards(KlondikePile::Foundation(f))); solitaire_core::FOUNDATIONS.map(|f| pile_cards(KlondikePile::Foundation(f)));
let tableaus: [Vec<CardSnapshot>; 7] = let tableaus: [Vec<CardSnapshot>; 7] =
solitaire_core::TABLEAUS.map(|t| pile_cards(KlondikePile::Tableau(t))); solitaire_core::TABLEAUS.map(|t| pile_cards(KlondikePile::Tableau(t)));
StateSnapshot { 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 { fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport {
let stock = game.stock_cards(); let stock = game.stock_cards();
let waste = game.waste_cards(); let waste = game.waste_cards();
let foundations = let foundations = solitaire_core::FOUNDATIONS.map(|f| game.pile(KlondikePile::Foundation(f)));
solitaire_core::FOUNDATIONS.map(|f| game.pile(KlondikePile::Foundation(f)));
let tableaus = solitaire_core::TABLEAUS.map(|t| game.pile(KlondikePile::Tableau(t))); let tableaus = solitaire_core::TABLEAUS.map(|t| game.pile(KlondikePile::Tableau(t)));
let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new(); 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 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() let state_ok = duplicate_cards.is_empty()
&& missing_cards.is_empty() && missing_cards.is_empty()
@@ -465,8 +468,7 @@ impl SolitaireGame {
.iter() .iter()
.map(CardSnapshot::from) .map(CardSnapshot::from)
.collect(), .collect(),
foundations: solitaire_core::FOUNDATIONS foundations: solitaire_core::FOUNDATIONS.map(|f| cards(KlondikePile::Foundation(f))),
.map(|f| cards(KlondikePile::Foundation(f))),
tableaus: solitaire_core::TABLEAUS.map(|t| cards(KlondikePile::Tableau(t))), 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(); let idx = pick_move_index(&legal_moves).unwrap_or_default();
if let Err(e) = game.apply_legal_move_native(idx) { 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 stock_before = game.game.stock_cards().len();
let waste_before = game.game.waste_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!( assert_eq!(
game.game.stock_cards().len(), game.game.stock_cards().len(),
@@ -1104,7 +1113,9 @@ mod tests {
"seed {seed}: stock must have at least 3 cards for this test" "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); let expected_drawn = stock_before.min(3);
assert_eq!( assert_eq!(
+1 -2
View File
@@ -41,8 +41,7 @@ pub fn start() {
// texture-dimension limit is now taken from the adapter (see // texture-dimension limit is now taken from the adapter (see
// the RenderPlugin below), so this is purely a quality/perf // the RenderPlugin below), so this is purely a quality/perf
// choice, no longer a crash-avoidance hack. // choice, no longer a crash-avoidance hack.
resolution: WindowResolution::default() resolution: WindowResolution::default().with_scale_factor_override(1.0),
.with_scale_factor_override(1.0),
..default() ..default()
}), }),
..default() ..default()