Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db1cc58f3a | |||
| 255b781420 | |||
| 5b5d587818 | |||
| 2b2e7a7f2c | |||
| 5c4d440b31 | |||
| 374858ab6d | |||
| 38b82d4858 | |||
| 4700bd7912 | |||
| d4448bf0cd | |||
| e1d91bee73 | |||
| 9dcd25b3e7 | |||
| 2ef0bce1ea | |||
| 326ef6894a | |||
| caaafe34e0 | |||
| 2254693a7a | |||
| 4ba646738c | |||
| 251d35bc28 | |||
| 1d2b6dc5de | |||
| fe3c3aed31 | |||
| d0e4ce796b | |||
| 9f038250d9 | |||
| 0c69d6859d | |||
| 50d6d41d85 | |||
| 9e4d4a6716 | |||
| ea1014285d | |||
| 4f849a23b8 | |||
| 2c2f0b592a | |||
| 299f6bfea7 | |||
| 8c0eb3dfab | |||
| a39e04329e | |||
| 6f27e775f2 | |||
| 358bbc7eb5 | |||
| 444f8d7e33 | |||
| a80547c514 | |||
| d87397b382 | |||
| 018b69285d | |||
| a4ad848c93 | |||
| ff8c00d2f4 | |||
| abf1312cf5 | |||
| 8b09c51271 | |||
| f61573513c | |||
| 757c35e4a0 | |||
| 113a933170 | |||
| 18bb1fa0be | |||
| 38b81a4004 | |||
| ae7af9adf4 |
@@ -29,7 +29,21 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: rust-host
|
||||
|
||||
# Full debuginfo made the solitaire_engine test-binary link peak past the
|
||||
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
|
||||
# line-tables-only keeps file:line in panic backtraces while cutting the
|
||||
# link's memory footprint enough to fit the runner.
|
||||
#
|
||||
# CARGO_BUILD_JOBS=2: with one job per core, cargo links several large
|
||||
# test binaries concurrently; as the workspace grew (runs 514/516/519)
|
||||
# two+ simultaneous ld processes OOM-killed the runner again even at
|
||||
# line-tables-only. Two jobs keeps at most two links in flight — the
|
||||
# compile-throughput cost is small next to the cache-warm build.
|
||||
env:
|
||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||
CARGO_BUILD_JOBS: '2'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -39,7 +53,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: clippy
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Cache cargo build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -53,6 +67,9 @@ jobs:
|
||||
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
|
||||
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --check
|
||||
|
||||
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
|
||||
# same as the web-e2e workflow's server prebuild.
|
||||
- name: Clippy (deny warnings)
|
||||
|
||||
+6
-2
@@ -592,11 +592,15 @@ pub struct Session<G> { /* replayable instruction log + derived stats */ }
|
||||
|
||||
// From `klondike` (upstream — never edit):
|
||||
pub enum KlondikePile {
|
||||
Stock,
|
||||
Waste,
|
||||
Stock, // NB: no Waste variant — see below
|
||||
Foundation(Foundation), // 4 slots, any suit may claim any slot
|
||||
Tableau(Tableau), // 7 columns
|
||||
}
|
||||
// Pile-coordinate convention: upstream has no `Waste` variant. In
|
||||
// pile-coordinate space `KlondikePile::Stock` denotes the face-up
|
||||
// *waste* pile (the only stock-side pile cards move out of); use
|
||||
// `GameState::stock_cards()` / `waste_cards()` when the face-down
|
||||
// draw stack must be distinguished. Documented on `GameState::pile`.
|
||||
pub enum DrawStockConfig { DrawOne, DrawThree }
|
||||
pub enum KlondikeInstruction { /* RotateStock, DstFoundation, ... — the
|
||||
serialized move format (schema v4+) */ }
|
||||
|
||||
Generated
+9
@@ -7331,6 +7331,7 @@ dependencies = [
|
||||
"card_game",
|
||||
"klondike",
|
||||
"proptest",
|
||||
"rand 0.10.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
@@ -7351,13 +7352,16 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"solitaire_core",
|
||||
"solitaire_server",
|
||||
"solitaire_sync",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7402,10 +7406,13 @@ dependencies = [
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"jsonwebtoken",
|
||||
"ron",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"solitaire_sync",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tower",
|
||||
@@ -7414,6 +7421,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7422,6 +7430,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -47,6 +47,7 @@ dirs = "6"
|
||||
keyring = "4"
|
||||
keyring-core = "1"
|
||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||
sha2 = "0.10"
|
||||
arboard = { version = "3", default-features = false }
|
||||
jni = { version = "0.21", default-features = false }
|
||||
|
||||
|
||||
@@ -44,6 +44,26 @@ docker compose up -d
|
||||
```
|
||||
|
||||
|
||||
## Theme store
|
||||
|
||||
The server can offer card-art themes for in-game download. Drop theme
|
||||
`.zip` archives (the same format the game's Settings → Import accepts:
|
||||
a `theme.ron` manifest plus 53 SVGs) into the directory named by
|
||||
`THEME_STORE_DIR` (default: `theme_store/` next to the binary), then
|
||||
restart the server — the catalog is scanned once at startup. An
|
||||
optional `<theme-id>.png` in the same directory becomes the theme's
|
||||
store preview.
|
||||
|
||||
Endpoints (public, no auth):
|
||||
|
||||
- `GET /api/themes` — catalog JSON (id, name, author, size, sha256)
|
||||
- `GET /api/themes/<id>/download` — the archive
|
||||
- `GET /api/themes/<id>/preview` — the preview PNG, if present
|
||||
|
||||
Archives that are oversized (> 20 MiB), unreadable, or have a
|
||||
malformed `theme.ron` are skipped with a warning in the server log;
|
||||
they never fail startup.
|
||||
|
||||
## Admin — Password Reset
|
||||
|
||||
If a player loses access to their account, the server binary includes a
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ wasm-bindgen \
|
||||
--out-name canvas \
|
||||
--target web \
|
||||
--no-typescript \
|
||||
"$REPO_ROOT/target/wasm32-unknown-unknown/wasm-release/solitaire_web.wasm"
|
||||
"${CARGO_TARGET_DIR:-$REPO_ROOT/target}/wasm32-unknown-unknown/wasm-release/solitaire_web.wasm"
|
||||
|
||||
# Optional size optimisation — Bevy bundles are large (~5-15 MB uncompressed).
|
||||
# wasm-opt passes are skipped silently when the tool is not installed.
|
||||
|
||||
@@ -34,6 +34,11 @@ spec:
|
||||
key: jwt-secret
|
||||
- name: SERVER_PORT
|
||||
value: "8080"
|
||||
# Theme-store catalog directory on the persistent volume.
|
||||
# Scanned once at startup — after dropping new theme zips
|
||||
# into /data/theme_store, restart the deployment.
|
||||
- name: THEME_STORE_DIR
|
||||
value: /data/theme_store
|
||||
volumeMounts:
|
||||
- name: db-data
|
||||
mountPath: /data
|
||||
|
||||
@@ -6,8 +6,13 @@ services:
|
||||
# Override DATABASE_URL so the DB always lands in the persistent volume,
|
||||
# regardless of what .env contains.
|
||||
DATABASE_URL: sqlite:///data/solitaire.db
|
||||
# Theme-store catalog directory (scanned once at startup; the
|
||||
# host ./theme_store folder is where the operator drops theme
|
||||
# zips + preview PNGs).
|
||||
THEME_STORE_DIR: /theme_store
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./theme_store:/theme_store:ro
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "${SERVER_PORT:-8080}"
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# Menu UX Redesign — July 2026
|
||||
|
||||
Status: PLANNING. Visual identity (Terminal / base16-eighties) is settled and
|
||||
out of scope — this is about **structure and interaction**, not colors or type.
|
||||
|
||||
## Diagnosis (from code survey, 2026-07-07)
|
||||
|
||||
| Surface | Today | Pain |
|
||||
| --- | --- | --- |
|
||||
| Settings | One scrolling modal, 5 section labels, ~37 control rows, single Done | Scroll-hunting for any toggle; endless on the folded (cover) screen; every new feature (theme store, import) stretches it further |
|
||||
| Home | Modal launcher: stats strip, draw-mode row, difficulty rows, 6 equal mode cards, Cancel | No "Continue" primacy; deal options (draw/difficulty/winnable) crowd the mode choice; six cards weighted identically though Classic dominates play |
|
||||
| HUD menu | Popover with 7 destinations (Help, Modes, Stats, Achievements, Profile, Settings, Leaderboard) | "Modes" duplicates Home's job; flat list, no grouping; each item opens another modal — modal-on-modal navigation |
|
||||
| Global | Everything is a modal over the felt | No hierarchy; near-square unfolded Fold renders one narrow wasted column |
|
||||
|
||||
## Phase A — Settings tabs (highest pain, lowest risk)
|
||||
|
||||
- Replace the single scroll with **tabs**: `Audio · Gameplay · Appearance ·
|
||||
Accessibility · Account`.
|
||||
- *Appearance* = today's Cosmetic (card back, background, card theme,
|
||||
theme store, import).
|
||||
- *Accessibility* = extracted from Gameplay/Cosmetic: color-blind,
|
||||
high-contrast, reduce-motion, touch input mode, tooltip delay.
|
||||
- *Account* = Sync + Privacy merged.
|
||||
- Narrow screens: tab chips in a row under the header. Wide (aspect > ~1.2 —
|
||||
unfolded Fold, desktop): left rail, rows in two columns.
|
||||
- Only the active tab's rows are spawned → each tab is ≤ 8 rows and fits the
|
||||
cover screen without scrolling.
|
||||
- **Implementation constraint:** pure `ui.rs` re-layout + one `SettingsTab`
|
||||
resource. The `SettingsButton` enum, input handlers, and persistence are
|
||||
untouched, so the 25 settings tests and the ambiguity gate stay green.
|
||||
|
||||
## Phase B — Home becomes a real home
|
||||
|
||||
- **Hierarchy, top to bottom:**
|
||||
1. `Continue` card (only when a game is in progress) — mode, elapsed, score.
|
||||
2. Hero `New Game` — one tap, reuses last mode + deal options.
|
||||
3. Compact 2×3 mode grid (Classic, Daily, Zen, Challenge, Time Attack,
|
||||
Seed) — smaller cards, glyph + name + one-line description on wide only.
|
||||
4. Stats strip moves to the bottom (or right pane when unfolded).
|
||||
- **Deal options** (draw 1/3, difficulty, winnable-only) move off the top
|
||||
level into a disclosure on the Classic card / New Game hero — they are
|
||||
Classic-mode concerns, not global ones.
|
||||
- Cancel becomes `Back to table` and only renders when a game exists.
|
||||
- Unfolded Fold / desktop: two panes — modes left, Continue + stats +
|
||||
daily/weekly right.
|
||||
|
||||
## Phase C — HUD menu consolidation
|
||||
|
||||
- Drop `Modes` from the popover (Home owns mode selection; HUD's New-Game
|
||||
path opens Home).
|
||||
- Group the remaining six: **Play** (Home) · **You** (Profile, Stats,
|
||||
Achievements) · **Community** (Leaderboard) · **System** (Settings, Help) —
|
||||
section dividers in the existing popover, not a new widget.
|
||||
- Dismissal audit: Esc / scrim-tap / Done must behave identically on every
|
||||
modal (most already do via `ScrimDismissible`; sweep the stragglers).
|
||||
|
||||
## Phase D — Validation gates
|
||||
|
||||
- Optional Stitch mockups (Terminal design-system asset
|
||||
`assets/0f1652274fea460585d0b331a9ddbb06`, project 593811793268308763) for
|
||||
Home + Settings before implementing Phase B. Phase A is safe to build
|
||||
straight in-engine.
|
||||
- Per-phase gates: full test suite, clippy `-D warnings`, ambiguity gate at 0,
|
||||
Android checklist (§15.3), on-device pass on the Fold 7 in BOTH postures.
|
||||
|
||||
## Sequencing & risk
|
||||
|
||||
| Phase | Size | Risk | Depends on |
|
||||
| --- | --- | --- | --- |
|
||||
| A — Settings tabs | ~1 session | Low (layout-only) | — |
|
||||
| B — Home hierarchy | 1–2 sessions | Medium (touches launch flow) | D mockups if wanted |
|
||||
| C — Menu grouping | small | Low | B (Modes removal) |
|
||||
|
||||
## Phase E — "You" hub (reuses the Phase A tab component)
|
||||
|
||||
Profile, Stats, Achievements, and the replay browser are four separate
|
||||
modals today, reached through the HUD popover one at a time. Fold them
|
||||
into ONE tabbed hub — `Profile · Stats · Achievements · Replays` — using
|
||||
the same tab widget Phase A builds for Settings. Cuts the HUD popover to
|
||||
four destinations and makes the tab component pay for itself twice.
|
||||
Existing per-screen systems keep their markers/handlers; only the outer
|
||||
shell changes (same trick as Phase A).
|
||||
|
||||
## Phase F — Mobile ergonomics (Fold-first)
|
||||
|
||||
- **Bottom action bar** on touch: Undo, Hint, Draw within thumb reach at
|
||||
the bottom safe-area edge (via the existing `SafeAreaAnchoredBottom`),
|
||||
score-only band stays on top. Desktop keeps the current top band.
|
||||
Biggest one-handed-play win available; folded posture is tall and
|
||||
top-heavy today.
|
||||
- **Hold-to-repeat Undo** — press-and-hold steps back repeatedly
|
||||
(respecting the existing undo scoring penalty), instead of tap-tap-tap.
|
||||
- Both are additive; game stays fully playable with the top band alone
|
||||
(UI-first rule §3.3 satisfied).
|
||||
|
||||
## Phase G — Post-win flow
|
||||
|
||||
Win summary today is a stats dump with a close. Give it an action
|
||||
hierarchy: **Play again** (same mode/options, primary) · **Share replay**
|
||||
(the upload already returns a share URL; surface it here with copy
|
||||
feedback) · **Watch replay** · quiet stats below. Rematch loop drops from
|
||||
4 taps to 1.
|
||||
|
||||
## Phase H — Feedback polish (small, bundling candidates)
|
||||
|
||||
- Unify queued vs. immediate toast styling (two subtly different styles
|
||||
exist today) and anchor both to one position; consider a 5-item toast
|
||||
history on the stats screen.
|
||||
- Theme-store modal: render the preview PNGs the server already serves
|
||||
(v1 is text-only rows).
|
||||
- Hint: optional ghost-motion preview of the suggested move instead of a
|
||||
static highlight (auto-disabled under reduce-motion).
|
||||
|
||||
## Phase I — Onboarding & discoverability
|
||||
|
||||
The first-run onboarding exists, but everything learned after it is
|
||||
invisible: the radial menu (long-press/right-click), hint cycling,
|
||||
tap-to-toggle HUD chrome, hotkeys. Add **contextual one-time tips** —
|
||||
fired by the situation, not a tour (first stall → hint tip; first
|
||||
long-press-able stack → radial tip; each shows once, stored in
|
||||
Settings like `shown_achievement_onboarding` already is). Plus a
|
||||
one-shot **"What's new"** card on first launch after a version bump —
|
||||
ObtainX updates are silent today, so shipped features go unnoticed
|
||||
(nobody will find the theme store on their own).
|
||||
|
||||
## Phase J — Keyboard & focus completeness (desktop + web)
|
||||
|
||||
`ui_focus` already gives deterministic Esc order and tab-walk
|
||||
(`FocusRow`/`Focusable`), and `KeyboardDragState` exists — but full
|
||||
keyboard-only play has never been audited end to end. Deliverables: every
|
||||
modal reachable/dismissable without a mouse, a visible focus ring styled
|
||||
to the Terminal system (current focus state is subtle), and a hotkey
|
||||
cheat-sheet overlay (hold `?`) generated from the actual bindings instead
|
||||
of the static Help text. Web build (Rhys) benefits most.
|
||||
|
||||
## Phase K — UI scale & touch-target accessibility
|
||||
|
||||
All type sizes flow through `TYPE_*` tokens and layout through
|
||||
`compute_layout` — which makes a **UI scale setting** (90/100/115/130 %)
|
||||
cheap to wire and genuinely useful on the Fold's dense cover screen.
|
||||
Pair with: respect Android system font scale on first run (seed the
|
||||
setting from it), and a one-time audit that every interactive element
|
||||
meets the 44 px logical minimum in BOTH Fold postures (the pill buttons
|
||||
and picker swatches are the suspects). Lands in the Accessibility tab
|
||||
that Phase A creates.
|
||||
|
||||
## Phase L — Empty & loading states, standardized
|
||||
|
||||
Async/empty surfaces each improvise today: leaderboard with no entries,
|
||||
stats with no games, replay browser with no replays, theme store
|
||||
loading/error (just added), sync status text. Define ONE pattern —
|
||||
glyph + one-line explanation + a single next-step action — as a
|
||||
`spawn_empty_state` helper in `ui_modal`, and sweep all five surfaces
|
||||
onto it. Small, mechanical, big perceived-quality win.
|
||||
|
||||
## Phase M — Sync transparency & data stewardship
|
||||
|
||||
The sync layer returns `ConflictReport`s ("data is never silently
|
||||
discarded") — but no UI ever shows them; players can't tell what a merge
|
||||
did. Add: a post-sync summary line ("merged, 2 conflicts kept newer
|
||||
values" → tap for detail in the Account tab), a visible last-synced
|
||||
timestamp (exists as `SyncStatus::LastSynced`, barely surfaced), and
|
||||
local **data export/import** (zip of the JSON saves) for device
|
||||
migrations without a server. Closes the trust loop the server work
|
||||
opened.
|
||||
|
||||
## Roadmap tie-ins (already in flight elsewhere)
|
||||
|
||||
- **Spider mode** (feat/spider-core, PR #157): Home's mode grid gains a
|
||||
7th card once engine work lands — the 2×3 grid in Phase B should be
|
||||
designed as N-card flow from day one. Spider UI also needs positional
|
||||
card→entity keys (documented in spider.rs module docs).
|
||||
- **Show solution** (feat/solution-line, in flight): lands in the pause
|
||||
menu; Phase C's grouping should leave room for it under a "Game"
|
||||
cluster if the pause menu grows.
|
||||
|
||||
## Sequencing (updated)
|
||||
|
||||
| Phase | Size | Risk | Depends on |
|
||||
| --- | --- | --- | --- |
|
||||
| A — Settings tabs | ~1 session | Low | — (DECIDED: tabs) |
|
||||
| E — "You" hub | ~1 session | Low | A's tab component |
|
||||
| C — Menu grouping | small | Low | E (popover shrinks) |
|
||||
| B — Home hierarchy | 1–2 sessions | Medium | D mockups if wanted |
|
||||
| F — Bottom action bar | ~1 session | Medium (input paths) | — |
|
||||
| G — Post-win flow | small | Low | — |
|
||||
| H — Feedback polish | small each | Low | — |
|
||||
| I — Onboarding & discoverability | ~1 session | Low | ships best after B/F land |
|
||||
| J — Keyboard & focus completeness | ~1 session | Low | A/E (fewer modals to audit) |
|
||||
| K — UI scale & touch targets | ~1 session | Medium (layout-wide) | A (Accessibility tab) |
|
||||
| L — Empty-state standardization | small | Low | — |
|
||||
| M — Sync transparency & export | ~1 session | Low–Medium | A (Account tab) |
|
||||
|
||||
Suggested order: **A → E → C** (one arc: the tab component and the menu
|
||||
slimming), then **B**, then **F/G/H** as independent follow-ups.
|
||||
|
||||
## Open decisions
|
||||
|
||||
1. ~~Settings: tabs vs. sub-pages~~ — **DECIDED 2026-07-07: tabs.**
|
||||
2. Deal options: disclosure on Classic card (proposed) vs. keep global row?
|
||||
3. Time Attack + Seed: top-level cards (proposed, grid stays symmetric) vs.
|
||||
tucked under a "More" card?
|
||||
4. Stitch mockups for Phase B, or iterate directly in-engine?
|
||||
5. Phase F bottom bar: touch-only (proposed) or also desktop?
|
||||
@@ -16,6 +16,11 @@ serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
klondike = { workspace = true }
|
||||
card_game = { workspace = true }
|
||||
# Deliberately NOT the workspace rand (0.9): this pins the exact dep the
|
||||
# upstream `klondike` crate uses, so `SeedableRng`/`SliceRandom` resolve
|
||||
# against the same crate version as `klondike::Rng` and Spider deals go
|
||||
# through the identical shuffle stack as Klondike deals.
|
||||
rand = { version = "0.10.1", default-features = false, features = ["std_rng"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -439,10 +439,7 @@ impl GameState {
|
||||
self.session.history().len()
|
||||
}
|
||||
|
||||
fn cards_with_face(
|
||||
cards: impl IntoIterator<Item = Card>,
|
||||
face_up: bool,
|
||||
) -> Vec<(Card, bool)> {
|
||||
fn cards_with_face(cards: impl IntoIterator<Item = Card>, face_up: bool) -> Vec<(Card, bool)> {
|
||||
cards.into_iter().map(|card| (card, face_up)).collect()
|
||||
}
|
||||
|
||||
@@ -507,8 +504,10 @@ impl GameState {
|
||||
Self::cards_with_face(cards.iter().cloned(), true)
|
||||
}
|
||||
KlondikePile::Tableau(tableau) => {
|
||||
let mut cards =
|
||||
Self::cards_with_face(state.tableau_face_down_cards(tableau).iter().cloned(), false);
|
||||
let mut cards = Self::cards_with_face(
|
||||
state.tableau_face_down_cards(tableau).iter().cloned(),
|
||||
false,
|
||||
);
|
||||
cards.extend(Self::cards_with_face(
|
||||
state.tableau_face_up_cards(tableau).iter().cloned(),
|
||||
true,
|
||||
@@ -823,9 +822,7 @@ impl GameState {
|
||||
) -> Option<(KlondikePile, KlondikePile, usize)> {
|
||||
let state = self.session.state().state().state();
|
||||
match instruction {
|
||||
KlondikeInstruction::RotateStock => {
|
||||
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
|
||||
}
|
||||
KlondikeInstruction::RotateStock => Some((KlondikePile::Stock, KlondikePile::Stock, 1)),
|
||||
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
||||
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
|
||||
return None;
|
||||
@@ -928,10 +925,7 @@ impl GameState {
|
||||
///
|
||||
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
|
||||
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
|
||||
pub fn apply_instruction(
|
||||
&mut self,
|
||||
instruction: KlondikeInstruction,
|
||||
) -> Result<(), MoveError> {
|
||||
pub fn apply_instruction(&mut self, instruction: KlondikeInstruction) -> Result<(), MoveError> {
|
||||
if self.is_won() {
|
||||
return Err(MoveError::GameAlreadyWon);
|
||||
}
|
||||
@@ -1115,6 +1109,66 @@ impl GameState {
|
||||
})
|
||||
}
|
||||
|
||||
/// Full winning line from the current position:
|
||||
///
|
||||
/// * `Ok(Some(line))` — winnable; applying every instruction in order via
|
||||
/// [`GameState::apply_instruction`] reaches a won game.
|
||||
/// * `Ok(None)` — provably unwinnable, or already won (no moves to show).
|
||||
/// * `Err(SolveError)` — inconclusive; budget exhausted before a verdict.
|
||||
///
|
||||
/// Delegates to upstream [`card_game::Session::solve`] on a solve-budgeted
|
||||
/// copy of the board like [`GameState::solve_first_move`], but returns the
|
||||
/// whole path instead of the first move, compacted with
|
||||
/// [`card_game::Solution::clean_solution`] (drops move ranges that loop
|
||||
/// back to an already-seen state — the raw DFS trace is full of them).
|
||||
///
|
||||
/// Foundation→foundation shuffles ([`KlondikeInstruction::is_useless`])
|
||||
/// are additionally stripped when the remaining sequence still replays to
|
||||
/// a win; if stripping one would break a later move's preconditions the
|
||||
/// unstripped cleaned line is returned instead, so the replay contract
|
||||
/// above always holds. `clean_solution` is quadratic-ish in line length —
|
||||
/// callers should run this off the UI thread with modest budgets.
|
||||
pub fn winning_line(
|
||||
&self,
|
||||
moves_budget: u64,
|
||||
states_budget: u64,
|
||||
) -> Result<Option<Vec<KlondikeInstruction>>, SolveError> {
|
||||
if self.is_won() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let inner = KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation);
|
||||
let config = SessionConfig {
|
||||
inner: inner.clone(),
|
||||
undo_penalty: 0,
|
||||
solve_moves_budget: moves_budget,
|
||||
solve_states_budget: states_budget,
|
||||
};
|
||||
let start = self.session.state().state().clone();
|
||||
let session = Session::new(start.clone(), config);
|
||||
|
||||
let Some(solution) = session.solve()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let cleaned: Vec<KlondikeInstruction> = solution
|
||||
.clean_solution()
|
||||
.iter()
|
||||
.map(|snapshot| *snapshot.instruction())
|
||||
.collect();
|
||||
let filtered: Vec<KlondikeInstruction> = cleaned
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|instruction| !instruction.is_useless())
|
||||
.collect();
|
||||
|
||||
if line_replays_to_win(start, &inner, &filtered) {
|
||||
Ok(Some(filtered))
|
||||
} else {
|
||||
Ok(Some(cleaned))
|
||||
}
|
||||
}
|
||||
|
||||
/// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`.
|
||||
///
|
||||
/// Fresh-deal solving models standard Klondike rules, so the non-standard
|
||||
@@ -1132,6 +1186,25 @@ impl GameState {
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` when applying `line` in order from `start` is legal at every step
|
||||
/// and ends in a won game. Pure replay check backing
|
||||
/// [`GameState::winning_line`]'s "the returned sequence always replays to a
|
||||
/// win" contract.
|
||||
fn line_replays_to_win(
|
||||
mut state: Klondike,
|
||||
config: &KlondikeConfig,
|
||||
line: &[KlondikeInstruction],
|
||||
) -> bool {
|
||||
let mut stats = <Klondike as card_game::Game>::Stats::default();
|
||||
for &instruction in line {
|
||||
if !state.is_instruction_valid(config, instruction) {
|
||||
return false;
|
||||
}
|
||||
state.process_instruction(&mut stats, config, instruction);
|
||||
}
|
||||
state.is_win()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1296,12 +1369,13 @@ mod tests {
|
||||
|
||||
game.take_from_foundation = false;
|
||||
assert!(!game.can_move_cards(&from, &to, 1));
|
||||
assert!(
|
||||
legal_pile_moves(&game)
|
||||
.iter()
|
||||
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_))
|
||||
|| !matches!(t, KlondikePile::Tableau(_)))
|
||||
);
|
||||
assert!(legal_pile_moves(&game).iter().all(|(f, t, _)| !matches!(
|
||||
f,
|
||||
KlondikePile::Foundation(_)
|
||||
) || !matches!(
|
||||
t,
|
||||
KlondikePile::Tableau(_)
|
||||
)));
|
||||
assert!(game.move_cards(from, to, 1).is_err());
|
||||
}
|
||||
|
||||
@@ -1356,13 +1430,85 @@ mod tests {
|
||||
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
|
||||
}
|
||||
|
||||
// ── Full winning line (winning_line) ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn winning_line_replays_to_a_won_game() {
|
||||
// Seed 0xD1FF_0000_0000_0012 / DrawOne is proven Winnable at 5k
|
||||
// budgets by `budget_is_passed_through_not_clamped`. Standard rules
|
||||
// (take-from-foundation off) keep the search space identical to
|
||||
// that baseline. The returned line must apply cleanly through the
|
||||
// normal instruction pipeline and end in a win — the whole point
|
||||
// of the API contract.
|
||||
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
|
||||
game.take_from_foundation = false;
|
||||
let line = game
|
||||
.winning_line(5_000, 5_000)
|
||||
.expect("this seed must not exhaust a 5k budget")
|
||||
.expect("this seed must be winnable");
|
||||
assert!(!line.is_empty(), "a winnable unfinished game needs moves");
|
||||
for (i, instruction) in line.iter().enumerate() {
|
||||
game.apply_instruction(*instruction)
|
||||
.unwrap_or_else(|e| panic!("move {i} of the line must be legal: {e}"));
|
||||
}
|
||||
assert!(game.is_won(), "line must end in a won game");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winning_line_contains_no_useless_moves() {
|
||||
// The foundation→foundation strip must survive the replay check on
|
||||
// this seed; a line shown to the player should never shuffle
|
||||
// between foundations. Same proven-winnable seed and standard
|
||||
// rules as `winning_line_replays_to_a_won_game`.
|
||||
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
|
||||
game.take_from_foundation = false;
|
||||
let line = game
|
||||
.winning_line(5_000, 5_000)
|
||||
.expect("budget")
|
||||
.expect("winnable");
|
||||
assert!(
|
||||
!line.iter().any(KlondikeInstruction::is_useless),
|
||||
"filtered line must not contain foundation→foundation moves"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winning_line_is_inconclusive_when_budget_exhausted() {
|
||||
let game = GameState::new(7, DrawStockConfig::DrawOne);
|
||||
let outcome = game.winning_line(5_000, 0);
|
||||
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winning_line_matches_first_move_verdict() {
|
||||
// The two solver entry points must agree on winnability for the
|
||||
// same position and budgets (both are deterministic DFS).
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let first = game.solve_first_move(5_000, 5_000);
|
||||
let line = game.winning_line(5_000, 5_000);
|
||||
assert_eq!(
|
||||
matches!(first, Ok(Some(_))),
|
||||
matches!(line, Ok(Some(_))),
|
||||
"solve_first_move and winning_line disagree on winnability"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_is_passed_through_not_clamped() {
|
||||
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the
|
||||
// budget reaches the solver unchanged.
|
||||
let easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 1_000, 1_000);
|
||||
let medium =
|
||||
GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 5_000, 5_000);
|
||||
let easy = GameState::solve_fresh_deal(
|
||||
0xD1FF_0000_0000_0012,
|
||||
DrawStockConfig::DrawOne,
|
||||
1_000,
|
||||
1_000,
|
||||
);
|
||||
let medium = GameState::solve_fresh_deal(
|
||||
0xD1FF_0000_0000_0012,
|
||||
DrawStockConfig::DrawOne,
|
||||
5_000,
|
||||
5_000,
|
||||
);
|
||||
assert!(easy.is_err());
|
||||
assert!(matches!(medium, Ok(Some(_))));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod error;
|
||||
pub mod game_state;
|
||||
pub mod klondike_adapter;
|
||||
pub mod scoring;
|
||||
pub mod spider;
|
||||
|
||||
// Re-export the upstream types that cross the solitaire_core API boundary so
|
||||
// downstream crates (engine, wasm) can import from one place without a direct
|
||||
@@ -13,12 +14,21 @@ pub mod scoring;
|
||||
// when decoding instructions to piles in `instruction_to_piles`) and do not
|
||||
// appear in any public method signature.
|
||||
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
|
||||
pub use klondike::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
|
||||
pub use klondike::{
|
||||
DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau,
|
||||
};
|
||||
|
||||
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
||||
// former `solitaire_data::solver` wrapper module.
|
||||
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
||||
|
||||
// Spider rules (second `card_game::Game` implementation; engine UI is a
|
||||
// later phase — nothing outside solitaire_core consumes these yet).
|
||||
pub use spider::{
|
||||
RunLength, Spider, SpiderConfig, SpiderGameState, SpiderInstruction, SpiderIter, SpiderMove,
|
||||
SpiderScoring, SpiderStats, SpiderSuits, SpiderTableau,
|
||||
};
|
||||
|
||||
/// All four foundation slots, in slot order.
|
||||
///
|
||||
/// Canonical iteration source for `Foundation` — upstream `klondike` has no
|
||||
|
||||
@@ -41,13 +41,20 @@ fn all_cards(game: &GameState) -> Vec<Card> {
|
||||
);
|
||||
}
|
||||
for t in &tableaux {
|
||||
cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone()));
|
||||
cards.extend(
|
||||
game.pile(KlondikePile::Tableau(*t))
|
||||
.iter()
|
||||
.map(|(c, _)| c.clone()),
|
||||
);
|
||||
}
|
||||
cards
|
||||
}
|
||||
|
||||
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
|
||||
prop_oneof![Just(DrawStockConfig::DrawOne), Just(DrawStockConfig::DrawThree)]
|
||||
prop_oneof![
|
||||
Just(DrawStockConfig::DrawOne),
|
||||
Just(DrawStockConfig::DrawThree)
|
||||
]
|
||||
}
|
||||
|
||||
/// Apply a sequence of random actions to a game, silently ignoring errors.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,9 @@ uuid = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
# Theme-store downloads are verified against the catalog's SHA-256
|
||||
# before the archive is handed to the engine's theme importer.
|
||||
sha2 = { workspace = true }
|
||||
|
||||
# `keyring-core` is the typed Entry/Error API used by
|
||||
# `auth_tokens`. The crate's own dependency tree pulls in
|
||||
@@ -47,6 +50,10 @@ sqlx = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
# theme_store_round_trip builds a theme zip in a tempdir for the
|
||||
# in-process store server.
|
||||
zip = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -161,6 +161,11 @@ pub use sync_client::LocalOnlyProvider;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use sync_client::{SolitaireServerClient, provider_for_backend};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod theme_store_client;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use theme_store_client::{ThemeStoreClient, ThemeStoreError};
|
||||
|
||||
pub mod replay;
|
||||
pub use replay::{
|
||||
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
|
||||
|
||||
@@ -161,8 +161,10 @@ pub struct Settings {
|
||||
/// Identifier of the active card-art theme. Matches `meta.id` from
|
||||
/// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are
|
||||
/// always present; user-supplied themes register under their own ids.
|
||||
/// Older `settings.json` files that stored `"default"` or `"classic"`
|
||||
/// are migrated to `"dark"` by [`Settings::sanitized`].
|
||||
/// Older `settings.json` files that stored `"default"` (the
|
||||
/// pre-rename id of the dark theme) are migrated to `"dark"` by
|
||||
/// [`Settings::sanitized`]; `"classic"` is a valid player choice
|
||||
/// and is never rewritten.
|
||||
#[serde(default = "default_theme_id")]
|
||||
pub selected_theme_id: String,
|
||||
/// Set to `true` once the achievement-onboarding info-toast has been
|
||||
|
||||
@@ -553,8 +553,8 @@ mod tests {
|
||||
"saved file must use schema version 5",
|
||||
);
|
||||
|
||||
let loaded = load_game_state_from(&path)
|
||||
.expect("a valid in-progress game must load without error");
|
||||
let loaded =
|
||||
load_game_state_from(&path).expect("a valid in-progress game must load without error");
|
||||
|
||||
// The forward instruction history round-trips, so the reconstructed board
|
||||
// re-serialises to byte-identical JSON.
|
||||
@@ -569,7 +569,11 @@ mod tests {
|
||||
|
||||
// Derived board reads match the live game (move count + recycle count are
|
||||
// both rebuilt from the replayed forward history).
|
||||
assert_eq!(loaded.move_count(), gs.move_count(), "move_count round-trips");
|
||||
assert_eq!(
|
||||
loaded.move_count(),
|
||||
gs.move_count(),
|
||||
"move_count round-trips"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.recycle_count(),
|
||||
gs.recycle_count(),
|
||||
|
||||
@@ -77,6 +77,11 @@ pub struct SolitaireServerClient {
|
||||
username: String,
|
||||
/// Shared `reqwest` client (keeps connection pools alive across calls).
|
||||
client: reqwest::Client,
|
||||
/// Serialises token refreshes. The server rotates refresh tokens and each
|
||||
/// is single-use, so two overlapping 401-retries must not both spend one:
|
||||
/// the loser's (already-consumed) token would be rejected and surface as
|
||||
/// a spurious "session expired" to the player. See [`Self::refresh_token`].
|
||||
refresh_lock: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -90,6 +95,7 @@ impl SolitaireServerClient {
|
||||
base_url: base_url.into().trim_end_matches('/').to_owned(),
|
||||
username: username.into(),
|
||||
client: reqwest::Client::new(),
|
||||
refresh_lock: tokio::sync::Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +178,27 @@ impl SolitaireServerClient {
|
||||
/// The server rotates refresh tokens on each call: the response includes a
|
||||
/// new refresh token that replaces the old one. Both tokens are persisted
|
||||
/// to the OS keychain on success.
|
||||
async fn refresh_token(&self) -> Result<(), SyncError> {
|
||||
///
|
||||
/// `stale_access` is the access token that just earned the caller a 401.
|
||||
/// Refreshes are serialised behind [`Self::refresh_lock`]: with single-use
|
||||
/// rotated refresh tokens, two overlapping 401-retries (e.g. a replay
|
||||
/// upload racing a manual sync) must not both call `/api/auth/refresh` —
|
||||
/// the second call would present an already-consumed token, get rejected,
|
||||
/// and force a re-login for no user-visible reason. Whoever loses the lock
|
||||
/// race checks whether the stored access token has already moved past
|
||||
/// `stale_access`; if so the refresh already happened and there is nothing
|
||||
/// left to do.
|
||||
async fn refresh_token(&self, stale_access: &str) -> Result<(), SyncError> {
|
||||
let _guard = self.refresh_lock.lock().await;
|
||||
|
||||
if let Ok(current) = load_access_token(&self.username)
|
||||
&& current != stale_access
|
||||
{
|
||||
// Another task refreshed while we waited on the lock; retry with
|
||||
// the token it stored rather than spending the new refresh token.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let old_refresh =
|
||||
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?;
|
||||
|
||||
@@ -231,7 +257,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
// Token expired — refresh and retry once.
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -264,7 +290,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
// Token expired — refresh and retry once.
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -331,7 +357,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -366,7 +392,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -406,7 +432,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -446,7 +472,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -480,7 +506,7 @@ impl SyncProvider for SolitaireServerClient {
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
@@ -542,7 +568,7 @@ impl SolitaireServerClient {
|
||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.refresh_token().await?;
|
||||
self.refresh_token(&token).await?;
|
||||
let new_token = self.access_token()?;
|
||||
let resp = self
|
||||
.client
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -30,13 +30,9 @@ use crate::replay_playback::ReplayPlaybackState;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||
spawn_modal_button, spawn_modal_header,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, Z_MODAL_PANEL,
|
||||
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1,
|
||||
};
|
||||
use crate::ui_tooltip::Tooltip;
|
||||
|
||||
@@ -137,8 +133,8 @@ impl Plugin for AchievementPlugin {
|
||||
.after(GameMutation)
|
||||
.after(StatsUpdate),
|
||||
)
|
||||
.add_systems(Update, toggle_achievements_screen)
|
||||
.add_systems(Update, handle_achievements_close_button)
|
||||
// Open/close/tab handling moved to `you_hub_plugin`
|
||||
// (Phase E) — this plugin now owns body content + scroll.
|
||||
.add_systems(Update, scroll_achievements_panel)
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -385,47 +381,6 @@ pub fn display_name_for(id: &str) -> String {
|
||||
achievement_by_id(id).map_or_else(|| id.to_string(), |d| d.name.to_string())
|
||||
}
|
||||
|
||||
/// Marker on the "Done" button inside the Achievements modal.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct AchievementsCloseButton;
|
||||
|
||||
/// Toggle the achievements overlay — `A` keyboard accelerator or
|
||||
/// `ToggleAchievementsRequestEvent` from the HUD Menu popover.
|
||||
fn toggle_achievements_screen(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut requests: MessageReader<ToggleAchievementsRequestEvent>,
|
||||
achievements: Res<AchievementsResource>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
screens: Query<Entity, With<AchievementsScreen>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<AchievementsScreen>)>,
|
||||
) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
if !keys.just_pressed(KeyCode::KeyA) && !button_clicked {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.single() {
|
||||
commands.entity(entity).despawn();
|
||||
} else if other_modal_scrims.is_empty() {
|
||||
spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
/// Click handler for the modal's "Done" button — despawns the overlay
|
||||
/// the same way the `A` accelerator does.
|
||||
fn handle_achievements_close_button(
|
||||
mut commands: Commands,
|
||||
close_buttons: Query<&Interaction, (With<AchievementsCloseButton>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<AchievementsScreen>>,
|
||||
) {
|
||||
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes mouse-wheel events into the Achievements modal's scrollable body
|
||||
/// while the panel is open.
|
||||
///
|
||||
@@ -458,14 +413,18 @@ fn scroll_achievements_panel(
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_achievements_screen(
|
||||
commands: &mut Commands,
|
||||
/// Builds the Achievements tab body inside the You hub's card. The
|
||||
/// unlock-count line that used to live in the standalone modal's
|
||||
/// header renders as the first body line instead (the hub owns the
|
||||
/// header). All markers (`AchievementRow`, `AchievementsScrollable`)
|
||||
/// are unchanged.
|
||||
pub(crate) fn spawn_achievements_body(
|
||||
card: &mut ChildSpawnerCommands,
|
||||
records: &[AchievementRecord],
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect();
|
||||
let total = ALL_ACHIEVEMENTS.len();
|
||||
let header = format!("Achievements ({}/{})", unlocked.len(), total);
|
||||
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_name = TextFont {
|
||||
@@ -486,8 +445,13 @@ fn spawn_achievements_screen(
|
||||
|
||||
let any_unlocked = records.iter().any(|r| r.unlocked);
|
||||
|
||||
let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, header, font_res);
|
||||
{
|
||||
// Unlock progress — formerly the standalone modal's header.
|
||||
card.spawn((
|
||||
Text::new(format!("Unlocked {} / {}", unlocked.len(), total)),
|
||||
font_name.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
|
||||
// First-time hint — shown until the player has unlocked anything.
|
||||
// The list itself describes individual rewards, but a top-level
|
||||
@@ -594,21 +558,7 @@ fn spawn_achievements_screen(
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
AchievementsCloseButton,
|
||||
"Done",
|
||||
Some("A"),
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
// Achievements is a read-only list — clicking the scrim outside
|
||||
// the card dismisses alongside the existing A / Done paths.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reward(reward: Reward) -> String {
|
||||
@@ -668,7 +618,8 @@ mod tests {
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(StatsPlugin::headless())
|
||||
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
|
||||
.add_plugins(AchievementPlugin::headless());
|
||||
.add_plugins(AchievementPlugin::headless())
|
||||
.add_plugins(crate::you_hub_plugin::YouHubPlugin);
|
||||
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
|
||||
// MinimalPlugins it isn't auto-registered.
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
@@ -911,8 +862,7 @@ mod tests {
|
||||
|
||||
// Put the active game in Zen mode. evaluate_on_win reads
|
||||
// GameStateResource.mode directly to populate last_win_is_zen.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.mode =
|
||||
GameMode::Zen;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.mode = GameMode::Zen;
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 0,
|
||||
|
||||
@@ -53,12 +53,12 @@ pub fn set_user_theme_dir(path: PathBuf) -> Result<(), PathBuf> {
|
||||
/// Returns the absolute path of the user-themes directory on the
|
||||
/// current platform.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if [`solitaire_data::data_dir`] returns `None`, which on
|
||||
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration.
|
||||
/// Android always returns `Some`. The panic message names the
|
||||
/// supported workaround ([`set_user_theme_dir`]).
|
||||
/// When [`solitaire_data::data_dir`] returns `None` (broken `$HOME` /
|
||||
/// `$XDG_*` on desktop; always on wasm32, which has no filesystem) this
|
||||
/// returns an empty path — callers treat that as "no user themes" and
|
||||
/// the bundled default theme still works. A warning naming the
|
||||
/// [`set_user_theme_dir`] workaround is logged once. Android always
|
||||
/// resolves.
|
||||
pub fn user_theme_dir() -> PathBuf {
|
||||
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
|
||||
return p.clone();
|
||||
@@ -76,29 +76,32 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
|
||||
/// Per-target-os resolution of the platform's data dir. Delegates
|
||||
/// to [`solitaire_data::data_dir`] which encapsulates the
|
||||
/// per-target shape (desktop: `dirs::data_dir()`; android: the
|
||||
/// hardcoded `/data/data/<package>/files` sandbox path). Panics
|
||||
/// only when the underlying resolver returns `None`, which on
|
||||
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration —
|
||||
/// the panic message names the supported workaround.
|
||||
/// hardcoded `/data/data/<package>/files` sandbox path).
|
||||
///
|
||||
/// When the resolver returns `None` — always on wasm32 (no
|
||||
/// filesystem), or a broken `$HOME` / `$XDG_*` configuration on
|
||||
/// desktop — this degrades to an empty path, which downstream theme
|
||||
/// scanning treats as "no user themes"; the bundled default theme is
|
||||
/// unaffected. CLAUDE.md §2.3 forbids panicking here: losing custom
|
||||
/// themes must not take the whole game down with it.
|
||||
fn detected_platform_data_dir() -> PathBuf {
|
||||
solitaire_data::data_dir().unwrap_or_else(|| {
|
||||
// On wasm32, data_dir() always returns None — there is no filesystem.
|
||||
// User themes are not supported in the browser build; return an empty
|
||||
// path so callers produce a benign empty dir rather than panicking.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
PathBuf::new()
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
panic!(
|
||||
"user_theme_dir(): platform data directory is unavailable. \
|
||||
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
|
||||
the OS reported no Application Support / AppData path. \
|
||||
As a workaround call solitaire_engine::assets::user_dir::\
|
||||
set_user_theme_dir() before App::run()."
|
||||
)
|
||||
use std::sync::Once;
|
||||
static WARN_ONCE: Once = Once::new();
|
||||
WARN_ONCE.call_once(|| {
|
||||
bevy::log::warn!(
|
||||
"user_theme_dir(): platform data directory is unavailable; \
|
||||
user themes are disabled. On Linux check $XDG_DATA_HOME or \
|
||||
$HOME; on macOS / Windows the OS reported no Application \
|
||||
Support / AppData path. As a workaround call \
|
||||
solitaire_engine::assets::user_dir::set_user_theme_dir() \
|
||||
before App::run()."
|
||||
);
|
||||
});
|
||||
}
|
||||
PathBuf::new()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -174,9 +174,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::{Deck, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
@@ -214,7 +214,11 @@ mod tests {
|
||||
}
|
||||
g.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
|
||||
vec![solitaire_core::Card::new(
|
||||
Deck::Deck1,
|
||||
Suit::Clubs,
|
||||
Rank::Ace,
|
||||
)],
|
||||
);
|
||||
g.set_test_auto_completable(true);
|
||||
let expected = (
|
||||
|
||||
@@ -72,9 +72,7 @@ pub struct HoverState {
|
||||
/// Describes a user action that arrived while cards were still animating.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BufferedInput {
|
||||
Move {
|
||||
from: MoveRequestEvent,
|
||||
},
|
||||
Move { from: MoveRequestEvent },
|
||||
Draw,
|
||||
Undo,
|
||||
}
|
||||
|
||||
@@ -10,9 +10,7 @@ use crate::animation_plugin::EffectiveSlideDuration;
|
||||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::resources::DragState;
|
||||
use crate::ui_theme::{
|
||||
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
|
||||
};
|
||||
use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z};
|
||||
|
||||
/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity.
|
||||
///
|
||||
@@ -197,4 +195,3 @@ pub(super) fn update_card_shadows_on_drag(
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #28 — Hint highlight tick system
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
|
||||
use bevy::color::Color;
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::game_state::GameState;
|
||||
@@ -272,5 +271,3 @@ pub(super) fn find_top_card_at(
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #28 — Stock-empty visual indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
|
||||
use bevy::color::Color;
|
||||
use bevy::sprite::Anchor;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
@@ -205,4 +204,3 @@ pub(super) fn add_android_corner_label(
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #34 — Card-flip animation systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -15,10 +15,7 @@ use crate::font_plugin::FontResource;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::table_plugin::PileMarker;
|
||||
use crate::ui_theme::{
|
||||
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG,
|
||||
CARD_SHADOW_PADDING_IDLE,
|
||||
};
|
||||
use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG, CARD_SHADOW_PADDING_IDLE};
|
||||
|
||||
/// Coalesces every `WindowResized` event arriving this frame into the latest
|
||||
/// pending size on [`ResizeThrottle`].
|
||||
@@ -347,5 +344,3 @@ pub(super) fn fill_tableau_fan_on_startup(
|
||||
};
|
||||
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ use std::collections::HashMap;
|
||||
|
||||
use bevy::color::Color;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::{KlondikePile, Tableau};
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::{KlondikePile, Tableau};
|
||||
|
||||
use crate::card_animation::CardAnimation;
|
||||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
|
||||
@@ -577,10 +577,7 @@ impl Plugin for CardPlugin {
|
||||
// the chain each pair is a scheduler ambiguity (#143). All
|
||||
// members are cheap and mostly change-gated; sequential
|
||||
// execution is not a cost that matters here.
|
||||
.configure_sets(
|
||||
Update,
|
||||
LayoutSystem::UpdateOnResize.before(BoardVisuals),
|
||||
)
|
||||
.configure_sets(Update, LayoutSystem::UpdateOnResize.before(BoardVisuals))
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -597,8 +594,7 @@ impl Plugin for CardPlugin {
|
||||
clear_right_click_highlights_on_pause,
|
||||
tick_hint_highlight,
|
||||
update_stock_empty_indicator,
|
||||
update_stock_count_badge
|
||||
.run_if(resource_changed::<GameStateResource>),
|
||||
update_stock_count_badge.run_if(resource_changed::<GameStateResource>),
|
||||
collect_resize_events,
|
||||
snap_cards_on_window_resize,
|
||||
resize_android_corner_labels,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
|
||||
use bevy::color::Color;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::game_state::GameState;
|
||||
@@ -12,10 +11,7 @@ use crate::font_plugin::FontResource;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
|
||||
use crate::ui_theme::{
|
||||
STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY,
|
||||
TYPE_BODY, Z_STOCK_BADGE,
|
||||
};
|
||||
use crate::ui_theme::{STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TYPE_BODY, Z_STOCK_BADGE};
|
||||
|
||||
/// Sprite colour applied to the stock `PileMarker` when the stock pile is empty,
|
||||
/// to signal to the player that there are no more cards to draw. Pure white
|
||||
@@ -288,4 +284,3 @@ pub(super) fn update_stock_count_badge(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ use super::*;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use bevy::color::Color;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
|
||||
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
|
||||
use crate::card_animation::CardAnimation;
|
||||
@@ -700,4 +700,3 @@ pub(super) fn update_card_entity(
|
||||
commands.entity(entity).insert(new_children_key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::layout::TABLEAU_FAN_FRAC;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use solitaire_core::Deck;
|
||||
use bevy::window::WindowResized;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
use std::collections::HashSet;
|
||||
use crate::events::StateChangedEvent;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::layout::TABLEAU_FAN_FRAC;
|
||||
use crate::resources::DragState;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use crate::ui_theme::TEXT_PRIMARY_HC;
|
||||
use bevy::window::WindowResized;
|
||||
use solitaire_core::Deck;
|
||||
use solitaire_core::{Card, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Convenience constructor — all unit tests use Deck1.
|
||||
fn make_card(suit: Suit, rank: Rank) -> Card {
|
||||
@@ -119,8 +118,7 @@ fn waste_draw_one_only_renders_top_card() {
|
||||
for _ in 0..3 {
|
||||
let _ = g.draw();
|
||||
}
|
||||
let waste_ids: HashSet<Card> =
|
||||
g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||
assert_eq!(waste_ids.len(), 3);
|
||||
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
@@ -163,8 +161,7 @@ fn waste_draw_three_renders_up_to_three_fanned_cards() {
|
||||
"need at least 3 waste cards for this test"
|
||||
);
|
||||
|
||||
let waste_ids: HashSet<Card> =
|
||||
waste_pile.iter().map(|c| c.0.clone()).collect();
|
||||
let waste_ids: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect();
|
||||
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
@@ -216,8 +213,7 @@ fn waste_draw_three_fans_correctly_when_pile_smaller_than_visible() {
|
||||
let count = waste_pile.len();
|
||||
assert!(count >= 2, "need at least 2 waste cards");
|
||||
|
||||
let waste_ids: HashSet<Card> =
|
||||
waste_pile.iter().map(|c| c.0.clone()).collect();
|
||||
let waste_ids: HashSet<Card> = waste_pile.iter().map(|c| c.0.clone()).collect();
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
@@ -254,8 +250,7 @@ fn waste_draw_one_buffer_card_at_same_xy_as_top() {
|
||||
for _ in 0..3 {
|
||||
let _ = g.draw();
|
||||
}
|
||||
let waste_ids: HashSet<Card> =
|
||||
g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
let waste_rendered: Vec<_> = positions
|
||||
@@ -745,8 +740,7 @@ fn advance_past_resize_throttle(app: &mut App) {
|
||||
|
||||
fn fire_window_resize(app: &mut App, width: f32, height: f32) {
|
||||
// Any Entity will do — the snap system reads only width/height.
|
||||
let window = Entity::from_raw_u32(0)
|
||||
.expect("Entity::from_raw_u32(0) is a valid placeholder");
|
||||
let window = Entity::from_raw_u32(0).expect("Entity::from_raw_u32(0) is a valid placeholder");
|
||||
app.world_mut().write_message(WindowResized {
|
||||
window,
|
||||
width,
|
||||
@@ -928,8 +922,7 @@ fn resize_in_place_updates_card_label_font_size() {
|
||||
// Sanity-check: the new font size matches FONT_SIZE_FRAC × the
|
||||
// post-resize card width, so the in-place path is using the
|
||||
// refreshed Layout.
|
||||
let expected_layout =
|
||||
crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true);
|
||||
let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0, 0.0, true);
|
||||
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
|
||||
assert!(
|
||||
(after - expected).abs() < 1e-3,
|
||||
@@ -1046,7 +1039,8 @@ fn shadow_offset_increases_during_drag() {
|
||||
q.iter(app.world())
|
||||
.next()
|
||||
.expect("fixture should spawn at least one CardEntity")
|
||||
.card.clone()
|
||||
.card
|
||||
.clone()
|
||||
};
|
||||
|
||||
// Pick a *different* card to act as the negative control —
|
||||
@@ -1101,9 +1095,7 @@ fn shadow_offset_increases_during_drag() {
|
||||
fn shadow_offset_for_card(app: &mut App, card: &Card) -> Vec2 {
|
||||
// Map every CardEntity to its (Entity, card).
|
||||
let card_entity = {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query::<(Entity, &CardEntity)>();
|
||||
let mut q = app.world_mut().query::<(Entity, &CardEntity)>();
|
||||
q.iter(app.world())
|
||||
.find(|(_, c)| c.card == *card)
|
||||
.map(|(e, _)| e)
|
||||
@@ -1198,8 +1190,7 @@ fn stock_badge_updates_when_stock_count_changes() {
|
||||
assert_eq!(stock_badge_text(&mut app), "24");
|
||||
{
|
||||
let mut game = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let mut stock: Vec<Card> =
|
||||
game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
|
||||
let mut stock: Vec<Card> = game.0.stock_cards().into_iter().map(|(c, _)| c).collect();
|
||||
let _ = stock.pop();
|
||||
game.0.set_test_stock_cards(stock);
|
||||
}
|
||||
@@ -1234,8 +1225,7 @@ fn image_set_with_distinct_back_handles() -> CardImageSet {
|
||||
// distinct dummy `Image`. We never render these; we only
|
||||
// compare ids.
|
||||
let mut images = Assets::<Image>::default();
|
||||
let backs: [Handle<Image>; 5] =
|
||||
std::array::from_fn(|_| images.add(Image::default()));
|
||||
let backs: [Handle<Image>; 5] = std::array::from_fn(|_| images.add(Image::default()));
|
||||
CardImageSet {
|
||||
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
|
||||
backs,
|
||||
@@ -1493,8 +1483,7 @@ fn waste_pile_cards_have_strictly_increasing_z() {
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
let waste_ids: HashSet<Card> =
|
||||
g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||
|
||||
let mut waste_zs: Vec<f32> = positions
|
||||
.iter()
|
||||
@@ -1543,8 +1532,7 @@ fn waste_cards_do_not_overlap_stock_column_on_portrait() {
|
||||
|
||||
let stock_x = layout.pile_positions[&KlondikePile::Stock].x;
|
||||
|
||||
let waste_ids: HashSet<Card> =
|
||||
g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||
|
||||
let mut waste_positions: Vec<_> = card_positions(&g, &layout)
|
||||
.into_iter()
|
||||
@@ -1573,8 +1561,7 @@ fn waste_pile_draw_one_cards_have_distinct_z() {
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
let waste_ids: HashSet<Card> =
|
||||
g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||
let waste_ids: HashSet<Card> = g.waste_cards().iter().map(|c| c.0.clone()).collect();
|
||||
|
||||
let mut waste_zs: Vec<f32> = positions
|
||||
.iter()
|
||||
|
||||
@@ -18,13 +18,15 @@ use crate::{
|
||||
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
||||
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
|
||||
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
||||
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider,
|
||||
TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin,
|
||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin,
|
||||
StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||
TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||
WinSummaryPlugin,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::{
|
||||
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
|
||||
ThemeStorePlugin,
|
||||
};
|
||||
|
||||
/// Groups all Ferrous Solitaire gameplay plugins.
|
||||
@@ -92,6 +94,7 @@ impl Plugin for CoreGamePlugin {
|
||||
.add_plugins(FeedbackAnimPlugin)
|
||||
.add_plugins(CardAnimationPlugin)
|
||||
.add_plugins(AutoCompletePlugin)
|
||||
.add_plugins(SolutionPlaybackPlugin)
|
||||
.add_plugins(ReplayPlaybackPlugin)
|
||||
.add_plugins(ReplayOverlayPlugin)
|
||||
.add_plugins(StatsPlugin::default())
|
||||
@@ -108,6 +111,7 @@ impl Plugin for CoreGamePlugin {
|
||||
.add_plugins(HelpPlugin)
|
||||
.add_plugins(HomePlugin::default())
|
||||
.add_plugins(ProfilePlugin)
|
||||
.add_plugins(crate::you_hub_plugin::YouHubPlugin)
|
||||
.add_plugins(PausePlugin)
|
||||
.add_plugins(SettingsPlugin::default())
|
||||
.add_plugins(OnboardingPlugin)
|
||||
@@ -126,6 +130,7 @@ impl Plugin for CoreGamePlugin {
|
||||
.add_plugins(AudioPlugin)
|
||||
.add_plugins(SyncPlugin::new(sync_provider))
|
||||
.add_plugins(SyncSetupPlugin)
|
||||
.add_plugins(ThemeStorePlugin)
|
||||
.add_plugins(AnalyticsPlugin)
|
||||
.add_plugins(LeaderboardPlugin);
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
|
||||
use crate::card_plugin::RightClickHighlight;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
@@ -437,7 +437,8 @@ fn tableau_or_stack_pos(
|
||||
base.x,
|
||||
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
||||
)
|
||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
|
||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree
|
||||
{
|
||||
let pile_len = game.waste_cards().len();
|
||||
let visible_start = pile_len.saturating_sub(3);
|
||||
let slot = index.saturating_sub(visible_start) as f32;
|
||||
@@ -581,7 +582,10 @@ mod tests {
|
||||
|
||||
use crate::layout::compute_layout;
|
||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
||||
use solitaire_core::{
|
||||
DrawStockConfig,
|
||||
game_state::{GameMode, GameState},
|
||||
};
|
||||
|
||||
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
||||
/// registered, plus the resources the system needs. Callers
|
||||
@@ -630,11 +634,7 @@ mod tests {
|
||||
// — same colour family, illegal. Tableau(2) must NOT be
|
||||
// highlighted.
|
||||
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
|
||||
set_tableau_top(
|
||||
&mut game,
|
||||
2,
|
||||
Card::new(Deck::Deck1, Suit::Clubs, Rank::Six),
|
||||
);
|
||||
set_tableau_top(&mut game, 2, Card::new(Deck::Deck1, Suit::Clubs, Rank::Six));
|
||||
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
|
||||
|
||||
let mut app = overlay_test_app(game);
|
||||
|
||||
@@ -54,7 +54,10 @@ impl DifficultyIndexResource {
|
||||
DifficultyLevel::Hard => &mut self.hard,
|
||||
DifficultyLevel::Expert => &mut self.expert,
|
||||
DifficultyLevel::Grandmaster => &mut self.grandmaster,
|
||||
DifficultyLevel::Random => unreachable!("Random has no catalog"),
|
||||
// Random has no catalog today, so seeds_for() already returned
|
||||
// None above; if it ever gains one, a time seed is still the
|
||||
// right answer for "Random" — never a reachable panic.
|
||||
DifficultyLevel::Random => return seed_from_system_time(),
|
||||
};
|
||||
let seed = catalog[*cursor % catalog.len()];
|
||||
*cursor = cursor.wrapping_add(1);
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use bevy::prelude::Message;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::{Card, Suit};
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_core::{Card, Suit};
|
||||
use solitaire_data::AchievementRecord;
|
||||
use solitaire_sync::SyncResponse;
|
||||
|
||||
@@ -134,6 +134,12 @@ pub struct ManualSyncRequestEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct SyncConfigureRequestEvent;
|
||||
|
||||
/// Request to open the in-game theme-store modal. Fired by the
|
||||
/// "Browse theme store" button in the Settings cosmetic section;
|
||||
/// handled by `theme_store_plugin`.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ThemeStoreOpenRequestEvent;
|
||||
|
||||
/// Request to disconnect from the current sync backend, clear stored
|
||||
/// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect"
|
||||
/// button in the Settings sync section.
|
||||
@@ -153,6 +159,12 @@ pub struct DeleteAccountRequestEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct PauseRequestEvent;
|
||||
|
||||
/// Request to solve the current deal and auto-play the winning line.
|
||||
/// Fired by the pause menu's "Show solution" button; consumed by
|
||||
/// `solution_playback_plugin`.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ShowSolutionRequestEvent;
|
||||
|
||||
/// Request to toggle the help / controls overlay. Fired by the HUD "Help"
|
||||
/// button alongside the existing `F1` accelerator so the overlay is
|
||||
/// reachable without a keyboard. Consumed by `help_plugin::toggle_help_screen`.
|
||||
@@ -231,6 +243,11 @@ pub struct ToggleSettingsRequestEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ToggleLeaderboardRequestEvent;
|
||||
|
||||
/// Request to toggle the Home mode launcher. Fired by the HUD
|
||||
/// Menu-popover "Home" row alongside the existing `M` accelerator.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ToggleHomeRequestEvent;
|
||||
|
||||
/// Fired by `SyncPlugin` after a pull task resolves and the merged result has
|
||||
/// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus
|
||||
/// any `ConflictReport`s the merge produced. `Err(String)` carries a
|
||||
|
||||
@@ -852,7 +852,10 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(FeedbackAnimPlugin);
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
||||
app.insert_resource(GameStateResource(GameState::new(
|
||||
1,
|
||||
DrawStockConfig::DrawOne,
|
||||
)));
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
reduce_motion_mode: true,
|
||||
..Settings::default()
|
||||
@@ -906,7 +909,10 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(FeedbackAnimPlugin);
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
||||
app.insert_resource(GameStateResource(GameState::new(
|
||||
1,
|
||||
DrawStockConfig::DrawOne,
|
||||
)));
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
reduce_motion_mode: true,
|
||||
..Settings::default()
|
||||
|
||||
@@ -14,8 +14,13 @@ use bevy::prelude::*;
|
||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||
use bevy::window::AppLifecycle;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
||||
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction};
|
||||
use solitaire_core::{
|
||||
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction,
|
||||
};
|
||||
use solitaire_core::{
|
||||
DrawStockConfig,
|
||||
game_state::{GameMode, GameState},
|
||||
};
|
||||
#[allow(deprecated)]
|
||||
use solitaire_data::latest_replay_path;
|
||||
use solitaire_data::{
|
||||
@@ -188,7 +193,9 @@ impl Plugin for GamePlugin {
|
||||
)
|
||||
} else {
|
||||
(
|
||||
saved.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)),
|
||||
saved.unwrap_or_else(|| {
|
||||
GameState::new(seed_from_system_time(), DrawStockConfig::DrawOne)
|
||||
}),
|
||||
None,
|
||||
)
|
||||
};
|
||||
@@ -1324,7 +1331,10 @@ fn auto_save_game_state(
|
||||
// or there's a pending restore the player hasn't answered — saving
|
||||
// the fresh-deal placeholder we seeded GameStateResource with at
|
||||
// startup would clobber the real saved game on disk.
|
||||
if paused.is_some_and(|p| p.0) || game.0.is_won() || game.0.move_count() == 0 || pending.0.is_some()
|
||||
if paused.is_some_and(|p| p.0)
|
||||
|| game.0.is_won()
|
||||
|| game.0.move_count() == 0
|
||||
|| pending.0.is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -372,9 +372,7 @@ fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
|
||||
});
|
||||
app.update();
|
||||
|
||||
let events = app
|
||||
.world()
|
||||
.resource::<Messages<CardFlippedEvent>>();
|
||||
let events = app.world().resource::<Messages<CardFlippedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(events).collect();
|
||||
assert!(
|
||||
@@ -800,7 +798,10 @@ fn replay_recording_skips_undo() {
|
||||
1,
|
||||
"only the draw is recorded; the undo does not erase it nor add a new entry",
|
||||
);
|
||||
assert!(matches!(recording.moves[0], KlondikeInstruction::RotateStock));
|
||||
assert!(matches!(
|
||||
recording.moves[0],
|
||||
KlondikeInstruction::RotateStock
|
||||
));
|
||||
}
|
||||
|
||||
/// Starting a new game wipes the recording so the next deal begins
|
||||
@@ -872,8 +873,8 @@ fn replay_recording_freezes_into_replay_on_game_won() {
|
||||
});
|
||||
app.update();
|
||||
|
||||
let history = load_replay_history_from(&path)
|
||||
.expect("a winning replay must be persisted to ReplayPath");
|
||||
let history =
|
||||
load_replay_history_from(&path).expect("a winning replay must be persisted to ReplayPath");
|
||||
assert_eq!(
|
||||
history.replays.len(),
|
||||
1,
|
||||
@@ -1059,7 +1060,10 @@ fn new_game_with_solver_toggle_off_random_seed_path() {
|
||||
app.update();
|
||||
|
||||
// Game state was reseeded — move_count is 0 on the new game.
|
||||
assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
|
||||
assert_eq!(
|
||||
app.world().resource::<GameStateResource>().0.move_count(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1133,7 +1137,10 @@ fn new_game_with_solver_toggle_on_retries_until_winnable() {
|
||||
// The chosen seed is non-deterministic (system time),
|
||||
// but the new game must have been started cleanly:
|
||||
// move_count back to 0, undo stack empty.
|
||||
assert_eq!(app.world().resource::<GameStateResource>().0.move_count(), 0);
|
||||
assert_eq!(
|
||||
app.world().resource::<GameStateResource>().0.move_count(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
app.world()
|
||||
.resource::<GameStateResource>()
|
||||
|
||||
@@ -86,13 +86,18 @@ fn toggle_help_screen(
|
||||
}
|
||||
|
||||
/// Click handler for the modal's "Done" button. F1 toggles the overlay
|
||||
/// the same way; this just exposes the close action to mouse / touch.
|
||||
/// the same way; Esc closes too, so dismissal matches every other
|
||||
/// modal (Phase C dismissal audit). Nothing ever stacks above Help,
|
||||
/// so Esc needs no topmost gate.
|
||||
fn handle_help_close_button(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
close_buttons: Query<&Interaction, (With<HelpCloseButton>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<HelpScreen>>,
|
||||
) {
|
||||
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
let clicked = close_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
|
||||
if !clicked && !esc {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
@@ -583,4 +588,33 @@ mod tests {
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/// Esc must dismiss the Help modal like Done and F1 do (Phase C
|
||||
/// dismissal audit).
|
||||
#[test]
|
||||
fn escape_closes_help_screen() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::F1);
|
||||
app.update();
|
||||
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(KeyCode::F1);
|
||||
input.clear();
|
||||
input.press(KeyCode::Escape);
|
||||
}
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&HelpScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0,
|
||||
"Esc must close the Help modal"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::events::{
|
||||
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
||||
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
||||
StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleProfileRequestEvent,
|
||||
StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleHomeRequestEvent,
|
||||
ToggleProfileRequestEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
@@ -264,6 +265,7 @@ impl Plugin for HomePlugin {
|
||||
.add_message::<StartPlayBySeedRequestEvent>()
|
||||
.add_message::<StartDifficultyRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ToggleHomeRequestEvent>()
|
||||
.add_message::<ToggleProfileRequestEvent>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
// Defensively register MouseWheel so `scroll_home_panel`
|
||||
@@ -371,6 +373,7 @@ fn spawn_home_on_launch(
|
||||
fn toggle_home_screen(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut requests: MessageReader<ToggleHomeRequestEvent>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
stats: Option<Res<StatsResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
@@ -380,7 +383,8 @@ fn toggle_home_screen(
|
||||
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
|
||||
diff_expanded: Res<DifficultyExpanded>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::KeyM) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
if !keys.just_pressed(KeyCode::KeyM) && !button_clicked {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.single() {
|
||||
@@ -432,7 +436,9 @@ fn build_home_context<'a>(
|
||||
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
|
||||
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
||||
daily_today,
|
||||
draw_mode: settings.map(|s| s.0.draw_mode).unwrap_or(DrawStockConfig::DrawOne),
|
||||
draw_mode: settings
|
||||
.map(|s| s.0.draw_mode)
|
||||
.unwrap_or(DrawStockConfig::DrawOne),
|
||||
font_res,
|
||||
difficulty_expanded,
|
||||
last_difficulty: settings.and_then(|s| s.0.last_difficulty),
|
||||
@@ -1619,6 +1625,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The HUD Menu popover's "Home" row fires
|
||||
/// `ToggleHomeRequestEvent`; it must open Home exactly like the
|
||||
/// `M` accelerator (Phase C: the popover's Play section replaces
|
||||
/// the old Modes row).
|
||||
#[test]
|
||||
fn toggle_home_event_opens_home_screen() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ToggleHomeRequestEvent>>()
|
||||
.write(ToggleHomeRequestEvent);
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&HomeScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1,
|
||||
"ToggleHomeRequestEvent must open the Home modal"
|
||||
);
|
||||
|
||||
// A second request toggles it closed, matching the M key.
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ToggleHomeRequestEvent>>()
|
||||
.write(ToggleHomeRequestEvent);
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&HomeScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0,
|
||||
"second ToggleHomeRequestEvent must close the Home modal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_m_twice_closes_home_screen() {
|
||||
let mut app = headless_app();
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
|
||||
|
||||
/// Auto-fade state for the action button bar. The bar fades out when
|
||||
/// the cursor is in the play area (below the HUD band) and back in when
|
||||
/// the cursor approaches the top of the window — same UX as a video
|
||||
@@ -49,7 +47,11 @@ const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
|
||||
/// `target` at a fixed rate so the visual transition is smooth across
|
||||
/// variable framerates.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub(super) fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut<HudActionFade>) {
|
||||
pub(super) fn update_action_fade(
|
||||
windows: Query<&Window>,
|
||||
time: Res<Time>,
|
||||
mut fade: ResMut<HudActionFade>,
|
||||
) {
|
||||
let Ok(window) = windows.single() else {
|
||||
return;
|
||||
};
|
||||
@@ -427,4 +429,3 @@ pub(super) fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
|
||||
from.alpha + (to.alpha - from.alpha) * t,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
|
||||
|
||||
/// `Changed<Interaction>` filter ensures we only react on the frame the
|
||||
/// interaction state transitions, avoiding repeat events while the button
|
||||
/// is held down. Each click handler fires the corresponding request event,
|
||||
@@ -313,44 +311,65 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font
|
||||
..default()
|
||||
};
|
||||
|
||||
// Each row carries a tooltip alongside its label so hover reveals
|
||||
// a one-line description of what each overlay shows — mirroring
|
||||
// the tooltips on the action-bar buttons that opened this popover.
|
||||
let rows: [(MenuOption, &'static str, &'static str); 7] = [
|
||||
// One popover row: destination, label, hover tooltip.
|
||||
type MenuRow = (MenuOption, &'static str, &'static str);
|
||||
// Destinations grouped into labelled sections (Phase C of the menu
|
||||
// redesign): Play · You · Community · System. Each row carries a
|
||||
// tooltip alongside its label so hover reveals a one-line
|
||||
// description of what each overlay shows — mirroring the tooltips
|
||||
// on the action-bar buttons that opened this popover. Mode
|
||||
// selection lives on Home now, so there is no Modes row.
|
||||
let sections: [(&'static str, &'static [MenuRow]); 4] = [
|
||||
(
|
||||
MenuOption::Help,
|
||||
"Help",
|
||||
"Show controls, rules, and keyboard shortcuts.",
|
||||
"Play",
|
||||
&[(
|
||||
MenuOption::Home,
|
||||
"Home",
|
||||
"Pick a mode, continue, or start a new game.",
|
||||
)],
|
||||
),
|
||||
(
|
||||
MenuOption::Modes,
|
||||
"Game Modes",
|
||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||
"You",
|
||||
&[
|
||||
(
|
||||
MenuOption::Profile,
|
||||
"Profile",
|
||||
"Your level, XP progress, and sync status.",
|
||||
),
|
||||
(
|
||||
MenuOption::Stats,
|
||||
"Stats",
|
||||
"Lifetime totals: wins, streaks, fastest time, best score.",
|
||||
),
|
||||
(
|
||||
MenuOption::Achievements,
|
||||
"Achievements",
|
||||
"Browse unlocked achievements and the rewards still ahead.",
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
MenuOption::Stats,
|
||||
"Stats",
|
||||
"Lifetime totals: wins, streaks, fastest time, best score.",
|
||||
"Community",
|
||||
&[(
|
||||
MenuOption::Leaderboard,
|
||||
"Leaderboard",
|
||||
"Top players from your sync server. Opt in from Profile.",
|
||||
)],
|
||||
),
|
||||
(
|
||||
MenuOption::Achievements,
|
||||
"Achievements",
|
||||
"Browse unlocked achievements and the rewards still ahead.",
|
||||
),
|
||||
(
|
||||
MenuOption::Profile,
|
||||
"Profile",
|
||||
"Your level, XP progress, and sync status.",
|
||||
),
|
||||
(
|
||||
MenuOption::Settings,
|
||||
"Settings",
|
||||
"Audio, animations, theme, draw mode, and sync.",
|
||||
),
|
||||
(
|
||||
MenuOption::Leaderboard,
|
||||
"Leaderboard",
|
||||
"Top players from your sync server. Opt in from Profile.",
|
||||
"System",
|
||||
&[
|
||||
(
|
||||
MenuOption::Settings,
|
||||
"Settings",
|
||||
"Audio, animations, theme, draw mode, and sync.",
|
||||
),
|
||||
(
|
||||
MenuOption::Help,
|
||||
"Help",
|
||||
"Show controls, rules, and keyboard shortcuts.",
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
@@ -375,27 +394,48 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font
|
||||
ZIndex(Z_HUD_POPOVER),
|
||||
))
|
||||
.with_children(|panel| {
|
||||
for (option, label, tooltip) in rows {
|
||||
let section_font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
for (section, rows) in sections {
|
||||
// Non-interactive section header — a quiet divider
|
||||
// inside the existing panel, not a new widget.
|
||||
panel
|
||||
.spawn((
|
||||
option,
|
||||
ActionButton,
|
||||
PopoverRow,
|
||||
Button,
|
||||
Tooltip::new(tooltip),
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
|
||||
justify_content: JustifyContent::FlexStart,
|
||||
align_items: AlignItems::Center,
|
||||
min_width: Val::Px(150.0),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(ACTION_BTN_IDLE),
|
||||
))
|
||||
.spawn(Node {
|
||||
padding: UiRect::axes(VAL_SPACE_3, Val::Px(2.0)),
|
||||
..default()
|
||||
})
|
||||
.with_children(|b| {
|
||||
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
||||
b.spawn((
|
||||
Text::new(section),
|
||||
section_font.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
});
|
||||
for &(option, label, tooltip) in rows {
|
||||
panel
|
||||
.spawn((
|
||||
option,
|
||||
ActionButton,
|
||||
PopoverRow,
|
||||
Button,
|
||||
Tooltip::new(tooltip),
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
|
||||
justify_content: JustifyContent::FlexStart,
|
||||
align_items: AlignItems::Center,
|
||||
min_width: Val::Px(150.0),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(ACTION_BTN_IDLE),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -424,31 +464,28 @@ pub(super) fn handle_menu_option_click(
|
||||
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
|
||||
popovers: Query<Entity, With<MenuPopover>>,
|
||||
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||
mut home: MessageWriter<ToggleHomeRequestEvent>,
|
||||
mut stats: MessageWriter<ToggleStatsRequestEvent>,
|
||||
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
|
||||
mut profile: MessageWriter<ToggleProfileRequestEvent>,
|
||||
mut settings: MessageWriter<ToggleSettingsRequestEvent>,
|
||||
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
|
||||
mut help: MessageWriter<HelpRequestEvent>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let mut clicked_any = false;
|
||||
let mut open_modes = false;
|
||||
for (interaction, option) in &interaction_query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
clicked_any = true;
|
||||
match option {
|
||||
MenuOption::Home => {
|
||||
home.write(ToggleHomeRequestEvent);
|
||||
}
|
||||
MenuOption::Help => {
|
||||
help.write(HelpRequestEvent);
|
||||
}
|
||||
MenuOption::Modes => {
|
||||
open_modes = true;
|
||||
}
|
||||
MenuOption::Stats => {
|
||||
stats.write(ToggleStatsRequestEvent);
|
||||
}
|
||||
@@ -472,14 +509,6 @@ pub(super) fn handle_menu_option_click(
|
||||
commands.entity(e).despawn();
|
||||
}
|
||||
}
|
||||
if open_modes {
|
||||
spawn_modes_popover(
|
||||
&mut commands,
|
||||
progress.as_deref(),
|
||||
daily.as_deref(),
|
||||
font_res.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back
|
||||
@@ -613,4 +642,3 @@ pub(super) fn toggle_hud_on_tap(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ use interaction::*;
|
||||
use spawn::*;
|
||||
use updates::*;
|
||||
|
||||
|
||||
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
|
||||
// Option<Res<AvatarResource>> parameters below compile without changes.
|
||||
// The resource is never inserted on wasm, so every call resolves to None.
|
||||
@@ -31,9 +30,9 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::events::{
|
||||
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
||||
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
||||
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent,
|
||||
ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent,
|
||||
UndoRequestEvent, WinStreakMilestoneEvent,
|
||||
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent,
|
||||
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
||||
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::{GameMutation, NewGameRequestWriters};
|
||||
@@ -387,8 +386,9 @@ pub enum ModeOption {
|
||||
}
|
||||
|
||||
/// Marker on the "Menu" action button. Click toggles the [`MenuPopover`]
|
||||
/// which exposes the Stats / Achievements / Profile / Settings /
|
||||
/// Leaderboard overlays without needing the S/A/P/O/L hotkeys.
|
||||
/// which exposes the Home / Profile / Stats / Achievements /
|
||||
/// Leaderboard / Settings / Help overlays without needing the
|
||||
/// M/P/S/A/L/O/F1 hotkeys.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct MenuButton;
|
||||
|
||||
@@ -414,16 +414,18 @@ struct MenuPopoverBackdrop;
|
||||
struct ModesPopoverBackdrop;
|
||||
|
||||
/// One row inside the [`MenuPopover`]. The variant selects which
|
||||
/// `Toggle*RequestEvent` the click handler fires.
|
||||
/// `Toggle*RequestEvent` the click handler fires. Rows render grouped
|
||||
/// under section headers (Play · You · Community · System); mode
|
||||
/// selection lives on Home, so there is no Modes row here.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub enum MenuOption {
|
||||
Help,
|
||||
Modes,
|
||||
Home,
|
||||
Profile,
|
||||
Stats,
|
||||
Achievements,
|
||||
Profile,
|
||||
Settings,
|
||||
Leaderboard,
|
||||
Settings,
|
||||
Help,
|
||||
}
|
||||
|
||||
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
||||
@@ -460,6 +462,7 @@ impl Plugin for HudPlugin {
|
||||
.add_message::<StartTimeAttackRequestEvent>()
|
||||
.add_message::<StartDailyChallengeRequestEvent>()
|
||||
.add_message::<ToggleStatsRequestEvent>()
|
||||
.add_message::<ToggleHomeRequestEvent>()
|
||||
.add_message::<ToggleAchievementsRequestEvent>()
|
||||
.add_message::<ToggleProfileRequestEvent>()
|
||||
.add_message::<ToggleSettingsRequestEvent>()
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::avatar_plugin::AvatarResource;
|
||||
|
||||
@@ -292,7 +291,9 @@ pub(super) fn spawn_avatar_child(
|
||||
const SIZE: f32 = 32.0;
|
||||
if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
|
||||
// Logged-in with a downloaded avatar: keep the accent disc behind it.
|
||||
commands.entity(parent).insert(BackgroundColor(ACCENT_PRIMARY));
|
||||
commands
|
||||
.entity(parent)
|
||||
.insert(BackgroundColor(ACCENT_PRIMARY));
|
||||
// Image fills the circle container; border_radius clips it to a disc.
|
||||
commands.entity(parent).with_children(|b| {
|
||||
b.spawn((
|
||||
@@ -549,4 +550,3 @@ pub(super) fn spawn_action_button<M: Component>(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -40,9 +40,16 @@ fn read_hud_text<M: Component>(app: &mut App) -> String {
|
||||
#[test]
|
||||
fn score_reflects_game_state() {
|
||||
let mut app = headless_app();
|
||||
let score = app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(20);
|
||||
let score = app
|
||||
.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.force_test_score(20);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudScore>(&mut app), format!("Score: {score}"));
|
||||
assert_eq!(
|
||||
read_hud_text::<HudScore>(&mut app),
|
||||
format!("Score: {score}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -192,7 +199,9 @@ fn challenge_time_color_zero_is_danger() {
|
||||
fn challenge_hud_empty_when_no_daily_resource() {
|
||||
// No DailyChallengeResource inserted → HudChallenge must be empty.
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.set_changed();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||
}
|
||||
@@ -207,7 +216,9 @@ fn challenge_hud_shows_time_limit_when_resource_present() {
|
||||
target_score: None,
|
||||
max_time_secs: Some(300),
|
||||
});
|
||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.set_changed();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Limit: 5:00");
|
||||
}
|
||||
@@ -222,7 +233,9 @@ fn challenge_hud_shows_score_goal_when_resource_present() {
|
||||
target_score: Some(4000),
|
||||
max_time_secs: None,
|
||||
});
|
||||
app.world_mut().resource_mut::<GameStateResource>().set_changed();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.set_changed();
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "Goal: 4000 pts");
|
||||
}
|
||||
@@ -238,7 +251,10 @@ fn challenge_hud_clears_on_win() {
|
||||
max_time_secs: Some(300),
|
||||
});
|
||||
// Mark the game as won — HudChallenge should be empty.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.set_test_won(true);
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.set_test_won(true);
|
||||
app.update();
|
||||
assert_eq!(read_hud_text::<HudChallenge>(&mut app), "");
|
||||
}
|
||||
@@ -384,7 +400,10 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
|
||||
set_manual_time_step(&mut app, 0.0);
|
||||
// Initial state has score=0; bumping by 50 (the threshold)
|
||||
// is the smallest jump that triggers the floater.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.force_test_score(50);
|
||||
app.update();
|
||||
|
||||
// One floater should now exist.
|
||||
@@ -405,7 +424,10 @@ fn score_increase_above_threshold_spawns_floater_in_accent_primary() {
|
||||
#[test]
|
||||
fn score_floater_despawns_after_full_lifetime() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.force_test_score(50);
|
||||
app.update();
|
||||
assert_eq!(count_with::<ScoreFloater>(&mut app), 1);
|
||||
|
||||
@@ -431,7 +453,10 @@ fn score_increase_below_threshold_does_not_spawn_floater() {
|
||||
let mut app = headless_app();
|
||||
// +5 mirrors a single tableau-to-foundation move; well below
|
||||
// the 50-point threshold so the floater path stays dormant.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(5);
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.force_test_score(5);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
count_with::<ScoreFloater>(&mut app),
|
||||
@@ -507,7 +532,10 @@ fn score_change_skips_pulse_and_floater_under_reduce_motion() {
|
||||
..Settings::default()
|
||||
}));
|
||||
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.force_test_score(50);
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.force_test_score(50);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
count_with::<ScorePulse>(&mut app),
|
||||
@@ -758,8 +786,8 @@ fn popover_rows_carry_tooltip_strings() {
|
||||
menu_tooltips.len()
|
||||
);
|
||||
for expected in [
|
||||
"Pick a mode, continue, or start a new game.",
|
||||
"Show controls, rules, and keyboard shortcuts.",
|
||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||
"Lifetime totals: wins, streaks, fastest time, best score.",
|
||||
"Browse unlocked achievements and the rewards still ahead.",
|
||||
"Your level, XP progress, and sync status.",
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
use super::*;
|
||||
|
||||
use bevy::window::WindowResized;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::Suit;
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
|
||||
use crate::auto_complete_plugin::AutoCompleteState;
|
||||
|
||||
@@ -609,4 +609,3 @@ pub(super) fn resize_action_bar_labels(
|
||||
font.font_size = new_size;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,10 @@ use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use bevy::window::{MonitorSelection, WindowMode};
|
||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||
use solitaire_core::{Card, Suit};
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::{Card, Suit};
|
||||
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||
|
||||
use crate::auto_complete_plugin::AutoCompleteState;
|
||||
use crate::card_animation::tuning::AnimationTuning;
|
||||
@@ -394,7 +394,10 @@ pub fn emit_hint_visuals(
|
||||
|
||||
// Find the top face-up card in the source pile and highlight it.
|
||||
let source_cards = pile_cards(game, from);
|
||||
let top_card = source_cards.last().filter(|(_, face_up)| *face_up).map(|(c, _)| c.clone());
|
||||
let top_card = source_cards
|
||||
.last()
|
||||
.filter(|(_, face_up)| *face_up)
|
||||
.map(|(c, _)| c.clone());
|
||||
if let Some(card) = top_card {
|
||||
for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
|
||||
if card_entity.card == card {
|
||||
@@ -831,9 +834,7 @@ fn end_drag(
|
||||
let origin_cards = pile_cards(&game.0, &origin);
|
||||
if !origin_cards.is_empty() {
|
||||
for card in &drag.cards {
|
||||
let Some(stack_index) =
|
||||
origin_cards.iter().position(|(c, _)| c == card)
|
||||
else {
|
||||
let Some(stack_index) = origin_cards.iter().position(|(c, _)| c == card) else {
|
||||
continue;
|
||||
};
|
||||
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
||||
@@ -1070,8 +1071,7 @@ fn touch_end_drag(
|
||||
let origin_cards = pile_cards(&game.0, &origin);
|
||||
if !origin_cards.is_empty() {
|
||||
for card in &drag.cards {
|
||||
let Some(stack_index) =
|
||||
origin_cards.iter().position(|(c, _)| c == card)
|
||||
let Some(stack_index) = origin_cards.iter().position(|(c, _)| c == card)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
@@ -1174,7 +1174,8 @@ fn card_position(
|
||||
y_offset -= layout.card_size.y * step;
|
||||
}
|
||||
Vec2::new(base.x, base.y + y_offset)
|
||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
|
||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree
|
||||
{
|
||||
// In Draw-Three mode the top 3 waste cards are fanned in X to match
|
||||
// card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
|
||||
// so clicking the visually rightmost (top) card actually registers — a
|
||||
@@ -1248,7 +1249,10 @@ fn find_draggable_at(
|
||||
}
|
||||
(i, i + 1)
|
||||
};
|
||||
let cards: Vec<Card> = pile_cards[start..end].iter().map(|(c, _)| c.clone()).collect();
|
||||
let cards: Vec<Card> = pile_cards[start..end]
|
||||
.iter()
|
||||
.map(|(c, _)| c.clone())
|
||||
.collect();
|
||||
return Some((pile, start, cards));
|
||||
}
|
||||
}
|
||||
@@ -1550,8 +1554,7 @@ fn handle_double_tap(
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((found_card, found_face_up)) =
|
||||
pile_cards.iter().find(|(c, _)| c == top_card)
|
||||
let Some((found_card, found_face_up)) = pile_cards.iter().find(|(c, _)| c == top_card)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -1783,8 +1786,6 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const fn tableau_number(tableau: Tableau) -> u8 {
|
||||
match tableau {
|
||||
Tableau::Tableau1 => 1,
|
||||
|
||||
@@ -89,9 +89,11 @@ fn find_draggable_picks_waste_top_with_multiple_cards() {
|
||||
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
clear_test_piles(&mut game);
|
||||
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)];
|
||||
let waste = vec![
|
||||
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
|
||||
];
|
||||
game.set_test_waste_cards(waste.clone());
|
||||
|
||||
let top_index = waste.len() - 1; // 2 = the visible top
|
||||
@@ -99,7 +101,11 @@ fn find_draggable_picks_waste_top_with_multiple_cards() {
|
||||
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
|
||||
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
|
||||
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
|
||||
assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only");
|
||||
assert_eq!(
|
||||
result.2,
|
||||
vec![waste[top_index].clone()],
|
||||
"drags the top card only"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -176,8 +182,7 @@ fn find_draggable_skips_face_down_cards() {
|
||||
// face-up card, but the iterator should skip face-down cards and
|
||||
// the cursor sits above the face-up card's AABB, so the result
|
||||
// is None.
|
||||
let face_down_pos =
|
||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
||||
let face_down_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
||||
let result = find_draggable_at(face_down_pos, &game, &layout);
|
||||
assert!(result.is_none(), "face-down cards should not be draggable");
|
||||
}
|
||||
@@ -195,8 +200,7 @@ fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() {
|
||||
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
||||
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
||||
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
|
||||
let face_up_pos =
|
||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||
let face_up_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||
let result = find_draggable_at(face_up_pos, &game, &layout)
|
||||
.expect("clicking the face-up card's visible centre must initiate a drag");
|
||||
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
||||
@@ -213,18 +217,14 @@ fn find_draggable_returns_run_when_picking_mid_stack() {
|
||||
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
|
||||
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
|
||||
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
|
||||
game.set_test_tableau_cards(
|
||||
Tableau::Tableau1,
|
||||
vec![king, queen.clone(), jack.clone()],
|
||||
);
|
||||
game.set_test_tableau_cards(Tableau::Tableau1, vec![king, queen.clone(), jack.clone()]);
|
||||
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// The Queen's geometric center (index 1) is inside the Jack's bounding box
|
||||
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
||||
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
||||
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
||||
let queen_center =
|
||||
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
||||
let queen_center = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
||||
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
||||
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
@@ -322,7 +322,11 @@ fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
|
||||
);
|
||||
let (pile, _start, ids) = result.unwrap();
|
||||
assert_eq!(pile, KlondikePile::Stock);
|
||||
assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste");
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![four_clubs],
|
||||
"only the top card is draggable from waste"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -558,8 +562,7 @@ fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
||||
fn rejected_drag_animation_targets_origin_resting_position() {
|
||||
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
|
||||
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
|
||||
let anim =
|
||||
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
|
||||
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
|
||||
|
||||
assert!(
|
||||
(anim.end - target_pos).length() < 1e-6,
|
||||
@@ -577,8 +580,7 @@ fn rejected_drag_animation_targets_origin_resting_position() {
|
||||
fn rejected_drag_animation_starts_from_drag_position() {
|
||||
let drag_pos = Vec2::new(640.0, 200.0);
|
||||
let target_pos = Vec2::new(80.0, -120.0);
|
||||
let anim =
|
||||
build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
|
||||
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
|
||||
|
||||
assert!(
|
||||
(anim.start - drag_pos).length() < 1e-6,
|
||||
@@ -601,12 +603,8 @@ fn rejected_drag_animation_starts_from_drag_position() {
|
||||
/// the call site honest.
|
||||
#[test]
|
||||
fn rejected_drag_animation_uses_correct_duration() {
|
||||
let anim = build_drag_reject_animation(
|
||||
Vec2::new(640.0, 200.0),
|
||||
DRAG_Z,
|
||||
Vec2::new(80.0, -120.0),
|
||||
0,
|
||||
);
|
||||
let anim =
|
||||
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
|
||||
assert!(
|
||||
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
|
||||
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
|
||||
@@ -620,12 +618,8 @@ fn rejected_drag_animation_uses_correct_duration() {
|
||||
/// jittery rather than forgiving.
|
||||
#[test]
|
||||
fn rejected_drag_animation_uses_responsive_curve() {
|
||||
let anim = build_drag_reject_animation(
|
||||
Vec2::new(640.0, 200.0),
|
||||
DRAG_Z,
|
||||
Vec2::new(80.0, -120.0),
|
||||
0,
|
||||
);
|
||||
let anim =
|
||||
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
|
||||
assert_eq!(
|
||||
anim.curve,
|
||||
MotionCurve::Responsive,
|
||||
@@ -683,10 +677,16 @@ fn pressing_h_spawns_pending_hint_task() {
|
||||
app.init_resource::<HintSolverConfig>();
|
||||
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.insert_resource(LayoutResource(
|
||||
compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true),
|
||||
));
|
||||
app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne)));
|
||||
app.insert_resource(LayoutResource(compute_layout(
|
||||
Vec2::new(1280.0, 800.0),
|
||||
0.0,
|
||||
0.0,
|
||||
true,
|
||||
)));
|
||||
app.insert_resource(GameStateResource(GameState::new(
|
||||
42,
|
||||
DrawStockConfig::DrawOne,
|
||||
)));
|
||||
app.add_systems(Update, handle_keyboard_hint);
|
||||
|
||||
// Simulate the H key being pressed this frame.
|
||||
|
||||
@@ -343,13 +343,21 @@ fn scroll_leaderboard_panel(
|
||||
}
|
||||
}
|
||||
|
||||
/// Done click or Esc dismisses the leaderboard (Phase C dismissal
|
||||
/// audit). Esc only fires when the leaderboard is the topmost modal —
|
||||
/// with the display-name dialog stacked on top, that dialog owns Esc.
|
||||
fn handle_leaderboard_close_button(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<LeaderboardScreen>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>,
|
||||
mut closed_flag: ResMut<ClosedThisFrame>,
|
||||
) {
|
||||
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
let clicked = close_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||
let esc =
|
||||
keys.just_pressed(KeyCode::Escape) && !screens.is_empty() && other_modal_scrims.is_empty();
|
||||
if !clicked && !esc {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
@@ -862,10 +870,8 @@ fn handle_display_name_confirm(
|
||||
.leaderboard_display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| {
|
||||
if let SyncBackend::SolitaireServer {
|
||||
ref username,
|
||||
..
|
||||
} = settings.0.sync_backend
|
||||
if let SyncBackend::SolitaireServer { ref username, .. } =
|
||||
settings.0.sync_backend
|
||||
{
|
||||
username.chars().take(32).collect()
|
||||
} else {
|
||||
@@ -890,12 +896,18 @@ fn handle_display_name_confirm(
|
||||
}
|
||||
|
||||
/// Discards any typed text and closes the display-name editor modal.
|
||||
/// Cancel click or Esc dismisses the display-name dialog without
|
||||
/// saving (Phase C dismissal audit — same contract as the sync-setup
|
||||
/// dialog's Cancel/Esc pair).
|
||||
fn handle_display_name_cancel(
|
||||
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameCancelButton>)>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
screens: Query<Entity, With<DisplayNameModal>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
|
||||
let clicked = button_q.iter().any(|i| *i == Interaction::Pressed);
|
||||
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
|
||||
if !clicked && !esc {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
|
||||
@@ -46,6 +46,7 @@ pub mod safe_area;
|
||||
mod schedule_checks;
|
||||
pub mod selection_plugin;
|
||||
pub mod settings_plugin;
|
||||
pub mod solution_playback_plugin;
|
||||
pub mod splash_plugin;
|
||||
pub mod stats_plugin;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -54,6 +55,8 @@ pub mod sync_plugin;
|
||||
pub mod sync_setup_plugin;
|
||||
pub mod table_plugin;
|
||||
pub mod theme;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod theme_store_plugin;
|
||||
pub mod time_attack_plugin;
|
||||
pub mod touch_selection_plugin;
|
||||
pub mod ui_focus;
|
||||
@@ -62,6 +65,7 @@ pub mod ui_theme;
|
||||
pub mod ui_tooltip;
|
||||
pub mod weekly_goals_plugin;
|
||||
pub mod win_summary_plugin;
|
||||
pub mod you_hub_plugin;
|
||||
|
||||
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -159,6 +163,7 @@ pub use settings_plugin::{
|
||||
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
|
||||
};
|
||||
pub use solitaire_data::SyncProvider;
|
||||
pub use solution_playback_plugin::{SolutionPlayback, SolutionPlaybackPlugin, SolutionSolveTask};
|
||||
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
|
||||
pub use stats_plugin::{
|
||||
LatestReplayPath, ReplayHistoryResource, ReplayNextButton, ReplayPrevButton,
|
||||
@@ -176,6 +181,8 @@ pub use theme::{
|
||||
ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry,
|
||||
ThemeRegistryPlugin, set_theme,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use theme_store_plugin::{ThemeStorePlugin, ThemeStoreScreen};
|
||||
pub use time_attack_plugin::{
|
||||
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
|
||||
};
|
||||
@@ -191,3 +198,4 @@ pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
||||
pub use win_summary_plugin::{
|
||||
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
|
||||
};
|
||||
pub use you_hub_plugin::{ActiveYouTab, YouHubPlugin, YouHubScreen, YouTab};
|
||||
|
||||
@@ -27,6 +27,7 @@ use solitaire_data::{Settings, save_settings_to};
|
||||
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
use crate::splash_plugin::SplashRoot;
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||
spawn_modal_header,
|
||||
@@ -36,7 +37,6 @@ use crate::ui_theme::{
|
||||
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
|
||||
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||
};
|
||||
use crate::splash_plugin::SplashRoot;
|
||||
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -71,6 +71,12 @@ struct PauseResumeButton;
|
||||
#[derive(Component, Debug)]
|
||||
struct PauseForfeitButton;
|
||||
|
||||
/// Marker on the "Show solution" secondary button on the pause modal.
|
||||
/// A click resumes the game and fires `ShowSolutionRequestEvent`;
|
||||
/// `solution_playback_plugin` takes it from there.
|
||||
#[derive(Component, Debug)]
|
||||
struct PauseSolutionButton;
|
||||
|
||||
/// Marker on the forfeit-confirm modal scrim.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ForfeitConfirmScreen;
|
||||
@@ -107,6 +113,7 @@ impl Plugin for PausePlugin {
|
||||
.add_message::<PauseRequestEvent>()
|
||||
.add_message::<ForfeitRequestEvent>()
|
||||
.add_message::<ForfeitEvent>()
|
||||
.add_message::<crate::events::ShowSolutionRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.init_resource::<PausedResource>()
|
||||
.add_systems(
|
||||
@@ -125,6 +132,7 @@ impl Plugin for PausePlugin {
|
||||
handle_pause_draw_buttons,
|
||||
handle_pause_resume_button,
|
||||
handle_pause_forfeit_button,
|
||||
handle_pause_solution_button,
|
||||
handle_forfeit_request,
|
||||
handle_forfeit_confirm_buttons,
|
||||
handle_forfeit_keyboard,
|
||||
@@ -304,6 +312,22 @@ fn handle_pause_resume_button(
|
||||
}
|
||||
}
|
||||
|
||||
/// Translates a click on the pause modal's "Show solution" button into
|
||||
/// a resume (`PauseRequestEvent` — playback can't run while paused)
|
||||
/// plus a `ShowSolutionRequestEvent` for `solution_playback_plugin`.
|
||||
fn handle_pause_solution_button(
|
||||
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseSolutionButton>)>,
|
||||
mut pause: MessageWriter<PauseRequestEvent>,
|
||||
mut solution: MessageWriter<crate::events::ShowSolutionRequestEvent>,
|
||||
) {
|
||||
for interaction in &interaction_query {
|
||||
if *interaction == Interaction::Pressed {
|
||||
pause.write(PauseRequestEvent);
|
||||
solution.write(crate::events::ShowSolutionRequestEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translates a click on the pause modal's Forfeit button into a
|
||||
/// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the
|
||||
/// confirm modal — same code path as the `G` accelerator.
|
||||
@@ -498,6 +522,14 @@ fn spawn_pause_screen(
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
PauseSolutionButton,
|
||||
"Show solution",
|
||||
None,
|
||||
ButtonVariant::Secondary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
PauseResumeButton,
|
||||
@@ -969,7 +1001,10 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
||||
app.insert_resource(GameStateResource(GameState::new(
|
||||
1,
|
||||
DrawStockConfig::DrawOne,
|
||||
)));
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
@@ -178,9 +178,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::events::HintVisualEvent;
|
||||
use crate::input_plugin::HintSolverConfig;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
|
||||
/// Build a minimal Bevy app exercising only the polling system
|
||||
/// and the resources/messages it touches.
|
||||
@@ -249,10 +249,7 @@ mod tests {
|
||||
.into_iter()
|
||||
.zip(suits.iter())
|
||||
{
|
||||
game.set_test_tableau_cards(
|
||||
tableau,
|
||||
vec![Card::new(Deck::Deck1, *suit, Rank::King)],
|
||||
);
|
||||
game.set_test_tableau_cards(tableau, vec![Card::new(Deck::Deck1, *suit, Rank::King)]);
|
||||
}
|
||||
game
|
||||
}
|
||||
@@ -267,9 +264,11 @@ mod tests {
|
||||
let mut app = pending_hint_app();
|
||||
app.insert_resource(GameStateResource(near_finished_state()));
|
||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
||||
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
||||
near_finished_state(),
|
||||
cfg.moves_budget,
|
||||
cfg.states_budget,
|
||||
);
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||
while app.world().resource::<PendingHintTask>().is_pending() {
|
||||
@@ -306,9 +305,11 @@ mod tests {
|
||||
let mut app = pending_hint_app();
|
||||
app.insert_resource(GameStateResource(near_finished_state()));
|
||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
||||
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
||||
near_finished_state(),
|
||||
cfg.moves_budget,
|
||||
cfg.states_budget,
|
||||
);
|
||||
assert!(
|
||||
app.world().resource::<PendingHintTask>().is_pending(),
|
||||
"task is in flight after spawn",
|
||||
@@ -344,18 +345,22 @@ mod tests {
|
||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
||||
|
||||
// First spawn.
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
||||
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
||||
near_finished_state(),
|
||||
cfg.moves_budget,
|
||||
cfg.states_budget,
|
||||
);
|
||||
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
|
||||
assert!(first_handle_present);
|
||||
|
||||
// Second spawn. The `spawn` helper drops the prior task
|
||||
// before assigning the new one — at no point are two tasks
|
||||
// in flight.
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingHintTask>()
|
||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
||||
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
||||
near_finished_state(),
|
||||
cfg.moves_budget,
|
||||
cfg.states_budget,
|
||||
);
|
||||
// Resource still pending (the second task), but the first
|
||||
// is gone. We can't directly observe the first handle once
|
||||
// it's been overwritten — what we *can* assert is that the
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! summary in a single scrollable panel. Spawned on the first `P` keypress and
|
||||
//! despawned on the second.
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use chrono::{Duration, Local, NaiveDate};
|
||||
@@ -13,23 +12,19 @@ use solitaire_data::SyncBackend;
|
||||
|
||||
use crate::achievement_plugin::AchievementsResource;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::avatar_plugin::AvatarResource;
|
||||
pub(crate) use crate::avatar_plugin::AvatarResource;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[derive(bevy::prelude::Resource)]
|
||||
struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
|
||||
pub(crate) struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
|
||||
use crate::events::ToggleProfileRequestEvent;
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::{SyncStatus, SyncStatusResource};
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::stats_plugin::{StatsResource, format_fastest_win, format_win_rate};
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||
spawn_modal_button, spawn_modal_header,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY,
|
||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL,
|
||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2,
|
||||
};
|
||||
|
||||
/// Number of days surfaced in the daily-challenge calendar row.
|
||||
@@ -60,13 +55,11 @@ pub struct DailyCalendarDot {
|
||||
pub is_today: bool,
|
||||
}
|
||||
|
||||
/// Registers the `P` key toggle for the profile overlay.
|
||||
/// Registers the Profile body's scroll handling. Opening/closing moved
|
||||
/// to the You hub (`you_hub_plugin`), which calls
|
||||
/// [`spawn_profile_body`] for this tab's content.
|
||||
pub struct ProfilePlugin;
|
||||
|
||||
/// Marker on the "Done" button inside the Profile modal.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ProfileCloseButton;
|
||||
|
||||
/// Marker on the scrollable body Node inside the Profile modal.
|
||||
///
|
||||
/// The Profile panel renders sync info, progression (incl. 14-day
|
||||
@@ -87,14 +80,9 @@ impl Plugin for ProfilePlugin {
|
||||
// profile-scroll system also runs cleanly under
|
||||
// `MinimalPlugins` in tests.
|
||||
.add_message::<MouseWheel>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
toggle_profile_screen,
|
||||
handle_profile_close_button,
|
||||
scroll_profile_panel,
|
||||
),
|
||||
);
|
||||
// Open/close/tab handling moved to `you_hub_plugin`
|
||||
// (Phase E) — this plugin now owns body content + scroll.
|
||||
.add_systems(Update, scroll_profile_panel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,70 +112,12 @@ fn scroll_profile_panel(
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_profile_close_button(
|
||||
mut commands: Commands,
|
||||
close_buttons: Query<&Interaction, (With<ProfileCloseButton>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<ProfileScreen>>,
|
||||
) {
|
||||
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the Profile tab body inside the You hub's card. All markers
|
||||
/// (`ProfileScrollable`, `DailyCalendarDot`, …) are unchanged, so the
|
||||
/// scroll and test queries keep working.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn toggle_profile_screen(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut requests: MessageReader<ToggleProfileRequestEvent>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
sync_status: Option<Res<SyncStatusResource>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
achievements: Option<Res<AchievementsResource>>,
|
||||
stats: Option<Res<StatsResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
avatar: Option<Res<AvatarResource>>,
|
||||
screens: Query<Entity, With<ProfileScreen>>,
|
||||
scrims: Query<(), With<ModalScrim>>,
|
||||
) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
let p_pressed = keys.just_pressed(KeyCode::KeyP);
|
||||
let esc_pressed = keys.just_pressed(KeyCode::Escape);
|
||||
let already_open = !screens.is_empty();
|
||||
// P / button toggles open-or-close. Esc only ever closes — when
|
||||
// Profile is layered over Home (clicking the new Home stats chip
|
||||
// opens this on top), Esc must dismiss the *topmost* modal.
|
||||
// Without this branch, Esc fell through to Home's cancel handler
|
||||
// and closed the wrong modal.
|
||||
let want_open = !already_open && (p_pressed || button_clicked);
|
||||
let want_close = already_open && (p_pressed || button_clicked || esc_pressed);
|
||||
if !want_open && !want_close {
|
||||
return;
|
||||
}
|
||||
if want_open && !scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.single() {
|
||||
commands.entity(entity).despawn();
|
||||
} else {
|
||||
spawn_profile_screen(
|
||||
&mut commands,
|
||||
settings.as_deref(),
|
||||
sync_status.as_deref(),
|
||||
progress.as_deref(),
|
||||
achievements.as_deref(),
|
||||
stats.as_deref(),
|
||||
font_res.as_deref(),
|
||||
avatar.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_profile_screen(
|
||||
commands: &mut Commands,
|
||||
pub(crate) fn spawn_profile_body(
|
||||
card: &mut ChildSpawnerCommands,
|
||||
settings: Option<&SettingsResource>,
|
||||
sync_status: Option<&SyncStatusResource>,
|
||||
progress: Option<&ProgressResource>,
|
||||
@@ -208,9 +138,7 @@ fn spawn_profile_screen(
|
||||
..default()
|
||||
};
|
||||
|
||||
let scrim = spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Profile", font_res);
|
||||
|
||||
{
|
||||
// Scrollable body — the Profile panel renders sync info,
|
||||
// progression (incl. a 14-day calendar), every unlocked
|
||||
// achievement (up to ~18), and a stats summary, which can
|
||||
@@ -472,20 +400,7 @@ fn spawn_profile_screen(
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
ProfileCloseButton,
|
||||
"Done",
|
||||
Some("P"),
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
// Profile is read-only — opt into click-outside-to-dismiss.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a fixed-height vertical spacer node.
|
||||
@@ -627,6 +542,7 @@ mod tests {
|
||||
use crate::settings_plugin::SettingsPlugin;
|
||||
use crate::stats_plugin::StatsPlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use crate::ui_modal::ModalScrim;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
@@ -637,7 +553,8 @@ mod tests {
|
||||
.add_plugins(ProgressPlugin::headless())
|
||||
.add_plugins(AchievementPlugin::headless())
|
||||
.add_plugins(SettingsPlugin::headless())
|
||||
.add_plugins(ProfilePlugin);
|
||||
.add_plugins(ProfilePlugin)
|
||||
.add_plugins(crate::you_hub_plugin::YouHubPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
|
||||
@@ -47,10 +47,10 @@ use bevy::input::touch::Touches;
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
|
||||
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
|
||||
use crate::events::{MoveRejectedEvent, MoveRequestEvent};
|
||||
@@ -360,9 +360,6 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// Builds the `(destination, anchor)` list for a fresh radial open.
|
||||
///
|
||||
/// `half_extents` is the window half-size in world space — icons are clamped
|
||||
@@ -381,8 +378,10 @@ fn build_radial_destinations(
|
||||
.map(|(i, d)| {
|
||||
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
|
||||
let clamped = Vec2::new(
|
||||
raw.x.clamp(-half_extents.x + margin, half_extents.x - margin),
|
||||
raw.y.clamp(-half_extents.y + margin, half_extents.y - margin),
|
||||
raw.x
|
||||
.clamp(-half_extents.x + margin, half_extents.x - margin),
|
||||
raw.y
|
||||
.clamp(-half_extents.y + margin, half_extents.y - margin),
|
||||
);
|
||||
(d, clamped)
|
||||
})
|
||||
|
||||
@@ -246,7 +246,11 @@ pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str {
|
||||
/// known card, or `"--"` for an absent top card (empty pile).
|
||||
pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String {
|
||||
match card {
|
||||
Some((c, _)) => format!("{}{}", format_rank_short(c.rank()), format_suit_glyph(c.suit())),
|
||||
Some((c, _)) => format!(
|
||||
"{}{}",
|
||||
format_rank_short(c.rank()),
|
||||
format_suit_glyph(c.suit())
|
||||
),
|
||||
None => "--".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ use std::sync::Arc;
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::Resource;
|
||||
use chrono::{DateTime, Utc};
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
|
||||
|
||||
@@ -82,9 +82,11 @@ impl Plugin for SafeAreaInsetsPlugin {
|
||||
);
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
app.init_resource::<android::SafeAreaPollTries>()
|
||||
app.add_message::<crate::events::StateChangedEvent>()
|
||||
.init_resource::<android::SafeAreaPollTries>()
|
||||
.add_systems(Update, android::refresh_insets)
|
||||
.add_systems(Update, android::rearm_on_resumed);
|
||||
.add_systems(Update, android::rearm_on_resumed)
|
||||
.add_systems(Update, android::refresh_surface_size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +227,7 @@ fn on_app_resumed(
|
||||
mod android {
|
||||
use super::{AppLifecycle, SafeAreaInsets};
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::WindowResized;
|
||||
|
||||
/// Tracks how many frames `refresh_insets` has polled. Stored as a
|
||||
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
|
||||
@@ -299,11 +302,114 @@ mod android {
|
||||
) {
|
||||
for event in lifecycle.read() {
|
||||
if matches!(event, AppLifecycle::WillResume) {
|
||||
// Evidence line for #130: winit's Android backend has open
|
||||
// TODOs around forwarding resume notifications, so whether
|
||||
// this ever fires on a given device is an open question.
|
||||
info!("safe_area: AppLifecycle::WillResume received; re-arming inset poll");
|
||||
poll.0 = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the decor view's size via JNI and forces a relayout when it
|
||||
/// disagrees with Bevy's cached `Window` resolution (#130).
|
||||
///
|
||||
/// winit's Android backend does not forward content-rect changes that
|
||||
/// happen while the app is backgrounded (fold/unfold on foldables), so
|
||||
/// after a fold cycle Bevy can keep rendering and laying out for the
|
||||
/// previous screen's dimensions. Unlike `refresh_insets` this poller
|
||||
/// never settles: it cannot rely on `AppLifecycle::WillResume` to re-arm
|
||||
/// it, because that event is itself delivered through the same unreliable
|
||||
/// lifecycle plumbing. A JNI round-trip every `POLL_INTERVAL_FRAMES`
|
||||
/// frames is cheap.
|
||||
///
|
||||
/// On a mismatch it:
|
||||
/// 1. writes the real size into `window.resolution` so the renderer
|
||||
/// reconfigures the surface and systems reading `window.width()` see
|
||||
/// the truth,
|
||||
/// 2. emits a synthetic `WindowResized` (logical pixels) so
|
||||
/// `on_window_resized` in `table_plugin` recomputes the board layout,
|
||||
/// 3. re-arms the inset poller, because a screen change almost always
|
||||
/// moves the system bars too — covering the "re-poll never fires"
|
||||
/// hole left open in #116,
|
||||
/// 4. emits `StateChangedEvent` so `card_plugin`'s sync pipeline
|
||||
/// re-renders every card sprite from scratch — belt-and-braces for
|
||||
/// the transient tableau clip of #130, where geometry converged but
|
||||
/// stale card visuals survived the relayout.
|
||||
pub(super) fn refresh_surface_size(
|
||||
mut frame: Local<u32>,
|
||||
mut windows: Query<(Entity, &mut Window)>,
|
||||
mut resize_events: MessageWriter<WindowResized>,
|
||||
mut state_events: MessageWriter<crate::events::StateChangedEvent>,
|
||||
mut poll: ResMut<SafeAreaPollTries>,
|
||||
) {
|
||||
const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps
|
||||
|
||||
*frame += 1;
|
||||
if !frame.is_multiple_of(POLL_INTERVAL_FRAMES) {
|
||||
return;
|
||||
}
|
||||
let Some((entity, mut window)) = windows.iter_mut().next() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (decor_w, decor_h) = match query_decor_size() {
|
||||
Ok(size) => size,
|
||||
Err(e) => {
|
||||
// One-time note; the bridge simply isn't up yet during the
|
||||
// first frames of a launch.
|
||||
if *frame == POLL_INTERVAL_FRAMES {
|
||||
warn!("safe_area: decor size query failed (will retry): {e}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
if decor_w == 0 || decor_h == 0 {
|
||||
return; // decor view not laid out yet
|
||||
}
|
||||
|
||||
// Reads go through `Deref` and do not trip change detection; only
|
||||
// mutate `window` once a mismatch is confirmed.
|
||||
let cached_w = window.resolution.physical_width();
|
||||
let cached_h = window.resolution.physical_height();
|
||||
if decor_w == cached_w && decor_h == cached_h {
|
||||
return;
|
||||
}
|
||||
|
||||
info!(
|
||||
"safe_area: decor view is {decor_w}x{decor_h} but cached resolution is \
|
||||
{cached_w}x{cached_h}; forcing relayout (fold/unfold missed by winit?)"
|
||||
);
|
||||
window.resolution.set_physical_resolution(decor_w, decor_h);
|
||||
let scale = window.scale_factor();
|
||||
resize_events.write(WindowResized {
|
||||
window: entity,
|
||||
width: decor_w as f32 / scale,
|
||||
height: decor_h as f32 / scale,
|
||||
});
|
||||
state_events.write(crate::events::StateChangedEvent);
|
||||
poll.0 = 0;
|
||||
}
|
||||
|
||||
/// Physical pixel size of the activity's decor view — the ground truth
|
||||
/// for the surface we are actually being displayed on, independent of
|
||||
/// whatever winit last told Bevy.
|
||||
fn query_decor_size() -> Result<(u32, u32), String> {
|
||||
use solitaire_data::android_jni;
|
||||
|
||||
android_jni::with_activity_env(|env, activity| {
|
||||
let window = env
|
||||
.call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
|
||||
.l()?;
|
||||
let decor = env
|
||||
.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
|
||||
.l()?;
|
||||
let w = env.call_method(&decor, "getWidth", "()I", &[])?.i()?;
|
||||
let h = env.call_method(&decor, "getHeight", "()I", &[])?.i()?;
|
||||
Ok((w.max(0) as u32, h.max(0) as u32))
|
||||
})
|
||||
}
|
||||
|
||||
fn query_insets() -> Result<SafeAreaInsets, String> {
|
||||
use solitaire_data::android_jni;
|
||||
|
||||
|
||||
@@ -90,9 +90,7 @@ mod tests {
|
||||
.and_then(|prefix| prefix.split_whitespace().last())
|
||||
.and_then(|n| n.parse::<usize>().ok());
|
||||
parsed.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"ambiguity panic message no longer parseable (Bevy upgrade?): {msg}"
|
||||
)
|
||||
panic!("ambiguity panic message no longer parseable (Bevy upgrade?): {msg}")
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
@@ -37,9 +37,9 @@
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
|
||||
use crate::card_plugin::CardEntityIndex;
|
||||
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
|
||||
@@ -487,8 +487,10 @@ fn handle_selection_keys(
|
||||
1
|
||||
};
|
||||
let start = source_cards.len().saturating_sub(count);
|
||||
let lifted_cards: Vec<Card> =
|
||||
source_cards[start..].iter().map(|(c, _)| c.clone()).collect();
|
||||
let lifted_cards: Vec<Card> = source_cards[start..]
|
||||
.iter()
|
||||
.map(|(c, _)| c.clone())
|
||||
.collect();
|
||||
let Some((bottom, _)) = source_cards.get(start) else {
|
||||
return;
|
||||
};
|
||||
@@ -597,10 +599,7 @@ fn face_up_run_len(cards: &[(Card, bool)]) -> usize {
|
||||
/// This is intentionally separated from [`best_destination`] so the Enter
|
||||
/// handler can attempt a foundation move first and fall through to a
|
||||
/// multi-card stack move rather than accepting a single-card tableau move.
|
||||
fn try_foundation_dest(
|
||||
card: &Card,
|
||||
game: &GameState,
|
||||
) -> Option<KlondikePile> {
|
||||
fn try_foundation_dest(card: &Card, game: &GameState) -> Option<KlondikePile> {
|
||||
let source = game.pile_containing_card(card.clone())?;
|
||||
for foundation in [
|
||||
Foundation::Foundation1,
|
||||
@@ -697,13 +696,7 @@ fn update_selection_highlight(
|
||||
if let Some(ref pile) = source_pile
|
||||
&& let Some(card) = top_face_up_card(pile, &game.0)
|
||||
{
|
||||
spawn_highlight_on_card(
|
||||
&mut commands,
|
||||
&card_index,
|
||||
&card,
|
||||
card_size,
|
||||
source_color,
|
||||
);
|
||||
spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, source_color);
|
||||
}
|
||||
|
||||
// Destination highlight while lifted.
|
||||
@@ -714,13 +707,7 @@ fn update_selection_highlight(
|
||||
// in destination-pick mode and the focused index is observable
|
||||
// via the resource.
|
||||
if let Some(card) = top_face_up_card(dest, &game.0) {
|
||||
spawn_highlight_on_card(
|
||||
&mut commands,
|
||||
&card_index,
|
||||
&card,
|
||||
card_size,
|
||||
dest_color,
|
||||
);
|
||||
spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, dest_color);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1047,10 +1034,7 @@ mod tests {
|
||||
press_key(&mut app, KeyCode::Tab);
|
||||
app.update();
|
||||
|
||||
let selected = app
|
||||
.world()
|
||||
.resource::<SelectionState>()
|
||||
.selected_pile;
|
||||
let selected = app.world().resource::<SelectionState>().selected_pile;
|
||||
// The cycle order starts at Waste, but Waste is empty so the next
|
||||
// available pile (Tableau(0)) is selected.
|
||||
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||
@@ -1216,16 +1200,10 @@ mod tests {
|
||||
drag.active_touch_id = None;
|
||||
}
|
||||
|
||||
let before = app
|
||||
.world()
|
||||
.resource::<SelectionState>()
|
||||
.selected_pile;
|
||||
let before = app.world().resource::<SelectionState>().selected_pile;
|
||||
press_key(&mut app, KeyCode::Tab);
|
||||
app.update();
|
||||
let after = app
|
||||
.world()
|
||||
.resource::<SelectionState>()
|
||||
.selected_pile;
|
||||
let after = app.world().resource::<SelectionState>().selected_pile;
|
||||
|
||||
assert_eq!(
|
||||
before, after,
|
||||
|
||||
@@ -50,15 +50,21 @@ pub(super) fn handle_volume_keys(
|
||||
}
|
||||
|
||||
/// Opens or closes the Settings panel — `O` keyboard accelerator or
|
||||
/// `ToggleSettingsRequestEvent` from the HUD Menu popover.
|
||||
/// `ToggleSettingsRequestEvent` from the HUD Menu popover. Esc closes
|
||||
/// too (Phase C dismissal audit), but only when Settings is the
|
||||
/// topmost modal — with sync-setup or the theme store stacked on top,
|
||||
/// the stacked dialog owns Esc.
|
||||
pub(super) fn toggle_settings_screen(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut requests: MessageReader<ToggleSettingsRequestEvent>,
|
||||
mut screen: ResMut<SettingsScreen>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
|
||||
) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
if keys.just_pressed(KeyCode::KeyO) || button_clicked {
|
||||
screen.0 = !screen.0;
|
||||
} else if keys.just_pressed(KeyCode::Escape) && screen.0 && other_modal_scrims.is_empty() {
|
||||
screen.0 = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +413,10 @@ pub(super) fn handle_settings_buttons(
|
||||
| SettingsButton::DeleteAccount => {
|
||||
// Handled by `handle_sync_buttons`.
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SettingsButton::OpenThemeStore => {
|
||||
// Handled by `handle_sync_buttons`.
|
||||
}
|
||||
SettingsButton::Done => {
|
||||
screen.0 = false;
|
||||
}
|
||||
@@ -414,6 +424,25 @@ pub(super) fn handle_settings_buttons(
|
||||
}
|
||||
}
|
||||
|
||||
/// Switches the active Settings tab when a chip is pressed. The
|
||||
/// rebuild itself happens in `rebuild_panel_on_tab_change`, driven by
|
||||
/// change detection on [`ActiveSettingsTab`].
|
||||
pub(super) fn handle_tab_buttons(
|
||||
interactions: Query<(&Interaction, &SettingsTabButton), Changed<Interaction>>,
|
||||
mut active: ResMut<ActiveSettingsTab>,
|
||||
) {
|
||||
for (interaction, chip) in &interactions {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
// Compare before writing so re-clicking the active chip doesn't
|
||||
// trip change detection and rebuild the panel for nothing.
|
||||
if active.0 != chip.0 {
|
||||
active.0 = chip.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
|
||||
/// and Delete Account. Split from `handle_settings_buttons` to stay within
|
||||
/// Bevy's 16-parameter system limit.
|
||||
@@ -423,6 +452,9 @@ pub(super) fn handle_sync_buttons(
|
||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
||||
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
||||
#[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter<
|
||||
crate::events::ThemeStoreOpenRequestEvent,
|
||||
>,
|
||||
mut screen: ResMut<SettingsScreen>,
|
||||
) {
|
||||
for (interaction, button) in &interaction_query {
|
||||
@@ -439,6 +471,13 @@ pub(super) fn handle_sync_buttons(
|
||||
screen.0 = false;
|
||||
configure_sync.write(SyncConfigureRequestEvent);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SettingsButton::OpenThemeStore => {
|
||||
// Same shape as ConnectSync: close settings first so the
|
||||
// store modal's own-scrim guard doesn't block.
|
||||
screen.0 = false;
|
||||
open_theme_store.write(crate::events::ThemeStoreOpenRequestEvent);
|
||||
}
|
||||
SettingsButton::DisconnectSync => {
|
||||
logout_sync.write(SyncLogoutRequestEvent);
|
||||
}
|
||||
|
||||
@@ -170,6 +170,51 @@ struct SmartDefaultSizeText;
|
||||
#[derive(Component, Debug)]
|
||||
struct AnalyticsEnabledText;
|
||||
|
||||
/// Which tab of the Settings panel is showing. Tabs replace the old
|
||||
/// single-scroll five-section layout (Phase A of
|
||||
/// `docs/ui-redesign-2026-07.md`) so each group fits on screen without
|
||||
/// scroll-hunting.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub(super) enum SettingsTab {
|
||||
#[default]
|
||||
Audio,
|
||||
Gameplay,
|
||||
Appearance,
|
||||
Accessibility,
|
||||
Account,
|
||||
}
|
||||
|
||||
impl SettingsTab {
|
||||
/// Every tab, in display order.
|
||||
pub(super) const ALL: [Self; 5] = [
|
||||
Self::Audio,
|
||||
Self::Gameplay,
|
||||
Self::Appearance,
|
||||
Self::Accessibility,
|
||||
Self::Account,
|
||||
];
|
||||
|
||||
/// Chip label.
|
||||
pub(super) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Audio => "Audio",
|
||||
Self::Gameplay => "Gameplay",
|
||||
Self::Appearance => "Appearance",
|
||||
Self::Accessibility => "Access",
|
||||
Self::Account => "Account",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The active Settings tab. Session-only — deliberately not persisted;
|
||||
/// reopening Settings within a session returns to the last tab.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub(super) struct ActiveSettingsTab(pub(super) SettingsTab);
|
||||
|
||||
/// Marker on each tab chip button in the Settings header row.
|
||||
#[derive(Component, Debug)]
|
||||
pub(super) struct SettingsTabButton(pub(super) SettingsTab);
|
||||
|
||||
/// Marks the scrollable inner card so the mouse-wheel system can target it.
|
||||
#[derive(Component, Debug)]
|
||||
struct SettingsPanelScrollable;
|
||||
@@ -250,6 +295,9 @@ enum SettingsButton {
|
||||
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
ScanThemes,
|
||||
/// Open the in-game theme-store modal (closes Settings first).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
OpenThemeStore,
|
||||
SyncNow,
|
||||
/// Open the sync-server Connect modal (shown when backend = Local).
|
||||
ConnectSync,
|
||||
@@ -313,6 +361,8 @@ impl SettingsButton {
|
||||
SettingsButton::SelectTheme(_) => 85,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SettingsButton::ScanThemes => 86,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SettingsButton::OpenThemeStore => 87,
|
||||
// Sync section
|
||||
SettingsButton::SyncNow => 90,
|
||||
SettingsButton::ConnectSync => 91,
|
||||
@@ -364,6 +414,7 @@ impl Plugin for SettingsPlugin {
|
||||
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
||||
.init_resource::<SettingsScreen>()
|
||||
.init_resource::<SettingsScrollPos>()
|
||||
.init_resource::<ActiveSettingsTab>()
|
||||
.init_resource::<PendingWindowGeometry>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
@@ -371,6 +422,7 @@ impl Plugin for SettingsPlugin {
|
||||
.add_message::<SyncLogoutRequestEvent>()
|
||||
.add_message::<DeleteAccountRequestEvent>()
|
||||
.add_message::<ToggleSettingsRequestEvent>()
|
||||
.add_message::<crate::events::ThemeStoreOpenRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<MouseWheel>()
|
||||
.add_message::<TouchInput>()
|
||||
@@ -417,7 +469,15 @@ impl Plugin for SettingsPlugin {
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
sync_settings_panel_visibility,
|
||||
// Chained: a tab click must observe this frame's
|
||||
// visibility state, and the rebuild must observe the
|
||||
// click — total order avoids panel double-spawns.
|
||||
(
|
||||
sync_settings_panel_visibility,
|
||||
handle_tab_buttons,
|
||||
rebuild_panel_on_tab_change,
|
||||
)
|
||||
.chain(),
|
||||
handle_settings_buttons,
|
||||
handle_sync_buttons,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -98,6 +98,25 @@ fn volume_clamped_at_zero_does_not_emit_event() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Opens the Settings panel showing `tab`, then waits out the
|
||||
/// spawn / child-flush / marker-attach frames.
|
||||
fn open_settings_on(app: &mut App, tab: SettingsTab) {
|
||||
app.world_mut().resource_mut::<ActiveSettingsTab>().0 = tab;
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
}
|
||||
|
||||
/// Switches the already-open panel to `tab` (drives the
|
||||
/// `rebuild_panel_on_tab_change` path) and waits out the rebuild frames.
|
||||
fn switch_settings_tab(app: &mut App, tab: SettingsTab) {
|
||||
app.world_mut().resource_mut::<ActiveSettingsTab>().0 = tab;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_o_toggles_settings_screen_flag() {
|
||||
let mut app = headless_app();
|
||||
@@ -121,6 +140,31 @@ fn pressing_o_toggles_settings_screen_flag() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Esc closes the Settings panel like O / Done do (Phase C dismissal
|
||||
/// audit). Esc while the panel is closed must NOT open it.
|
||||
#[test]
|
||||
fn escape_closes_settings_screen_flag() {
|
||||
let mut app = headless_app();
|
||||
|
||||
press(&mut app, KeyCode::Escape);
|
||||
app.update();
|
||||
assert!(
|
||||
!app.world().resource::<SettingsScreen>().0,
|
||||
"Esc on a closed panel stays closed"
|
||||
);
|
||||
|
||||
press(&mut app, KeyCode::KeyO);
|
||||
app.update();
|
||||
assert!(app.world().resource::<SettingsScreen>().0, "O opens");
|
||||
|
||||
press(&mut app, KeyCode::Escape);
|
||||
app.update();
|
||||
assert!(
|
||||
!app.world().resource::<SettingsScreen>().0,
|
||||
"Esc closes settings"
|
||||
);
|
||||
}
|
||||
|
||||
// cycle_unlocked pure-function tests
|
||||
#[test]
|
||||
fn cycle_unlocked_wraps_at_end() {
|
||||
@@ -243,40 +287,40 @@ fn headless_app_with_focus() -> App {
|
||||
fn settings_buttons_get_focusable_marker() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
// Open the panel.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
// Two more ticks: the first runs `sync_settings_panel_visibility`
|
||||
// and queues the spawn commands; the second flushes them and
|
||||
// runs `attach_focusable_to_settings_buttons`.
|
||||
app.update();
|
||||
app.update();
|
||||
// Walk every tab: each rebuild must leave no bespoke
|
||||
// `SettingsButton` (not `Done`, which is also a `ModalButton`)
|
||||
// without a `Focusable`.
|
||||
let mut total_tagged = 0usize;
|
||||
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
|
||||
if i == 0 {
|
||||
open_settings_on(&mut app, tab);
|
||||
} else {
|
||||
switch_settings_tab(&mut app, tab);
|
||||
}
|
||||
|
||||
// Every bespoke `SettingsButton` (not `Done`, which is also a
|
||||
// `ModalButton`) must carry a `Focusable`.
|
||||
let untagged: Vec<&SettingsButton> = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
let untagged: Vec<&SettingsButton> = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
assert!(
|
||||
untagged.is_empty(),
|
||||
"every bespoke Settings button on tab {tab:?} must carry Focusable; missing: {untagged:?}"
|
||||
);
|
||||
|
||||
total_tagged += app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, With<Focusable>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
}
|
||||
|
||||
// Floor across all five tabs so the assertion above isn't
|
||||
// vacuously true (audio 4 + gameplay 8 + appearance ≥5 +
|
||||
// accessibility 6 + account ≥1).
|
||||
assert!(
|
||||
untagged.is_empty(),
|
||||
"every bespoke Settings button must carry Focusable; missing: {:?}",
|
||||
untagged
|
||||
);
|
||||
|
||||
// And there must be at least one tagged `SettingsButton` so the
|
||||
// assertion above isn't vacuously true (the panel really did
|
||||
// spawn).
|
||||
let tagged_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, With<Focusable>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
tagged_count >= 6,
|
||||
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
|
||||
total_tagged >= 20,
|
||||
"expected the five tabs to spawn many bespoke buttons; got {total_tagged}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -290,41 +334,45 @@ fn settings_buttons_get_focusable_marker() {
|
||||
fn settings_buttons_carry_tooltip() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
// Open the panel and let spawn + child-flush run.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
// Walk every tab: no bespoke `SettingsButton` (i.e. excluding
|
||||
// `Done`, which is also a `ModalButton`) may be missing a
|
||||
// `Tooltip` on any of them.
|
||||
let mut total_tipped = 0usize;
|
||||
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
|
||||
if i == 0 {
|
||||
open_settings_on(&mut app, tab);
|
||||
} else {
|
||||
switch_settings_tab(&mut app, tab);
|
||||
}
|
||||
|
||||
// No bespoke `SettingsButton` (i.e. excluding `Done`, which is
|
||||
// also a `ModalButton`) may be missing a `Tooltip`.
|
||||
let untipped: Vec<&SettingsButton> = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
let untipped: Vec<&SettingsButton> = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
assert!(
|
||||
untipped.is_empty(),
|
||||
"every bespoke Settings button on tab {tab:?} must carry Tooltip; missing: {untipped:?}"
|
||||
);
|
||||
|
||||
total_tipped += app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, With<Tooltip>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
}
|
||||
|
||||
// Floor across all five tabs so the assertion above isn't
|
||||
// vacuously true.
|
||||
assert!(
|
||||
untipped.is_empty(),
|
||||
"every bespoke Settings button must carry Tooltip; missing: {:?}",
|
||||
untipped
|
||||
total_tipped >= 20,
|
||||
"expected the five tabs to spawn many tooltipped buttons; got {total_tipped}"
|
||||
);
|
||||
|
||||
// And there must be at least 6 tipped buttons so the assertion
|
||||
// above isn't vacuously true: SFX +/−, Music +/−, Draw Mode,
|
||||
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
|
||||
// card-back and one background swatch — well over the floor.
|
||||
let tipped_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, With<Tooltip>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert!(
|
||||
tipped_count >= 6,
|
||||
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
|
||||
);
|
||||
// The loop ends on the Account tab, where with default (Local)
|
||||
// settings the Connect button spawns.
|
||||
|
||||
// Spot-check: with default (Local) settings the Connect button
|
||||
// spawns. We verify its tooltip carries the canonical microcopy.
|
||||
// We verify its tooltip carries the canonical microcopy.
|
||||
let connect_tip = app
|
||||
.world_mut()
|
||||
.query::<(&SettingsButton, &Tooltip)>()
|
||||
@@ -342,10 +390,8 @@ fn settings_buttons_carry_tooltip() {
|
||||
fn settings_picker_rows_get_focus_row_marker() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
// Picker rows live on the Appearance tab.
|
||||
open_settings_on(&mut app, SettingsTab::Appearance);
|
||||
|
||||
// Two picker rows are spawned (card-back + background); each
|
||||
// must carry the FocusRow marker.
|
||||
@@ -360,6 +406,45 @@ fn settings_picker_rows_get_focus_row_marker() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Switching tabs despawns the old rows and spawns the new tab's —
|
||||
/// exactly one panel exists afterwards (no double-spawn from the
|
||||
/// visibility system racing the rebuild system).
|
||||
#[test]
|
||||
fn switching_tab_rebuilds_panel_with_new_rows() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
fn has_button(app: &mut App, wanted: fn(&SettingsButton) -> bool) -> bool {
|
||||
app.world_mut()
|
||||
.query::<&SettingsButton>()
|
||||
.iter(app.world())
|
||||
.any(wanted)
|
||||
}
|
||||
|
||||
open_settings_on(&mut app, SettingsTab::Audio);
|
||||
assert!(has_button(&mut app, |b| matches!(b, SettingsButton::SfxUp)));
|
||||
assert!(
|
||||
!has_button(&mut app, |b| matches!(b, SettingsButton::ToggleDrawMode)),
|
||||
"Gameplay rows must not spawn while the Audio tab is active"
|
||||
);
|
||||
|
||||
switch_settings_tab(&mut app, SettingsTab::Gameplay);
|
||||
assert!(
|
||||
!has_button(&mut app, |b| matches!(b, SettingsButton::SfxUp)),
|
||||
"Audio rows must despawn when leaving the Audio tab"
|
||||
);
|
||||
assert!(has_button(&mut app, |b| matches!(
|
||||
b,
|
||||
SettingsButton::ToggleDrawMode
|
||||
)));
|
||||
|
||||
let panel_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<SettingsPanel>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(panel_count, 1, "tab switch must not stack panels");
|
||||
}
|
||||
|
||||
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
|
||||
/// at least one theme and the [`ThemeThumbnailCache`] holds a
|
||||
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
|
||||
@@ -404,11 +489,9 @@ fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
|
||||
);
|
||||
app.insert_resource(cache);
|
||||
|
||||
// Open the panel and let the spawn + child-flush systems run.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
// Open the panel on the Appearance tab (the theme picker's home)
|
||||
// and let the spawn + child-flush systems run.
|
||||
open_settings_on(&mut app, SettingsTab::Appearance);
|
||||
|
||||
// Find every ImageNode tagged with ThemeThumbnailMarker — the
|
||||
// theme picker chip for "test_theme" must contribute exactly
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::theme::{ImportError, import_theme, refresh_registry};
|
||||
use crate::ui_focus::FocusRow;
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header,
|
||||
spawn_tab_chip,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM,
|
||||
@@ -45,13 +46,34 @@ pub(super) fn spawn_settings_panel(
|
||||
scroll_offset: f32,
|
||||
font_res: Option<&FontResource>,
|
||||
theme_overrides_back: bool,
|
||||
active_tab: SettingsTab,
|
||||
) {
|
||||
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Settings", font_res);
|
||||
|
||||
// Scrollable body — contains every section so tall content stays
|
||||
// reachable on short windows. The Done button below stays fixed
|
||||
// outside the scroll so it's always one click away.
|
||||
// Tab chips — one group of rows on screen at a time, no
|
||||
// scroll-hunting (Phase A, docs/ui-redesign-2026-07.md).
|
||||
card.spawn((
|
||||
FocusRow,
|
||||
Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
column_gap: VAL_SPACE_2,
|
||||
row_gap: VAL_SPACE_2,
|
||||
margin: UiRect::bottom(VAL_SPACE_3),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|row| {
|
||||
for tab in SettingsTab::ALL {
|
||||
tab_chip(row, tab, tab == active_tab, font_res);
|
||||
}
|
||||
});
|
||||
|
||||
// Scrollable body — most tabs fit without scrolling, but the
|
||||
// Appearance pickers can still overflow short windows. The Done
|
||||
// button below stays fixed outside the scroll so it's always one
|
||||
// click away.
|
||||
card.spawn((
|
||||
SettingsPanelScrollable,
|
||||
SettingsScrollNode,
|
||||
@@ -65,198 +87,20 @@ pub(super) fn spawn_settings_panel(
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
// --- Audio ---
|
||||
section_label(body, "Audio", font_res);
|
||||
volume_row(
|
||||
.with_children(|body| match active_tab {
|
||||
SettingsTab::Audio => spawn_audio_tab(body, settings, font_res),
|
||||
SettingsTab::Gameplay => spawn_gameplay_tab(body, settings, font_res),
|
||||
SettingsTab::Appearance => spawn_appearance_tab(
|
||||
body,
|
||||
"SFX Volume",
|
||||
settings.sfx_volume,
|
||||
SfxVolumeText,
|
||||
SettingsButton::SfxDown,
|
||||
SettingsButton::SfxUp,
|
||||
"Lower sound effects volume.",
|
||||
"Raise sound effects volume.",
|
||||
font_res,
|
||||
);
|
||||
volume_row(
|
||||
body,
|
||||
"Music Volume",
|
||||
settings.music_volume,
|
||||
MusicVolumeText,
|
||||
SettingsButton::MusicDown,
|
||||
SettingsButton::MusicUp,
|
||||
"Lower music and ambience volume.",
|
||||
"Raise music and ambience volume.",
|
||||
font_res,
|
||||
);
|
||||
|
||||
// --- Gameplay ---
|
||||
section_label(body, "Gameplay", font_res);
|
||||
toggle_row(
|
||||
body,
|
||||
"Draw Mode",
|
||||
DrawModeText,
|
||||
draw_mode_label(&settings.draw_mode),
|
||||
SettingsButton::ToggleDrawMode,
|
||||
"Switch between Draw 1 and Draw 3. Takes effect next deal.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Winnable deals only",
|
||||
WinnableDealsOnlyText,
|
||||
winnable_deals_only_label(settings.winnable_deals_only),
|
||||
SettingsButton::ToggleWinnableDealsOnly,
|
||||
"When on, fresh Classic deals are filtered through a solver \
|
||||
(may take a moment when on).",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Anim Speed",
|
||||
AnimSpeedText,
|
||||
anim_speed_label(&settings.animation_speed),
|
||||
SettingsButton::CycleAnimSpeed,
|
||||
"Cycle animation speed: Normal, Fast, Instant.",
|
||||
font_res,
|
||||
);
|
||||
tooltip_delay_row(
|
||||
body,
|
||||
settings.tooltip_delay_secs,
|
||||
font_res,
|
||||
);
|
||||
time_bonus_multiplier_row(
|
||||
body,
|
||||
settings.time_bonus_multiplier,
|
||||
font_res,
|
||||
);
|
||||
replay_move_interval_row(
|
||||
body,
|
||||
settings.replay_move_interval_secs,
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Smart window size",
|
||||
SmartDefaultSizeText,
|
||||
smart_default_size_label(!settings.disable_smart_default_size),
|
||||
SettingsButton::ToggleSmartDefaultSize,
|
||||
"When ON, fresh launches resize the window to ~70 % of the \
|
||||
monitor. OFF pins the 1280\u{00D7}800 baseline. Saved \
|
||||
window size always wins.",
|
||||
font_res,
|
||||
);
|
||||
|
||||
// --- Cosmetic ---
|
||||
section_label(body, "Cosmetic", font_res);
|
||||
toggle_row(
|
||||
body,
|
||||
"Theme",
|
||||
ThemeText,
|
||||
theme_label(&settings.theme),
|
||||
SettingsButton::ToggleTheme,
|
||||
"Cycle felt color: Green, Blue, Dark.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Color-blind Mode",
|
||||
ColorBlindText,
|
||||
color_blind_label(settings.color_blind_mode),
|
||||
SettingsButton::ToggleColorBlind,
|
||||
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"High Contrast",
|
||||
HighContrastText,
|
||||
on_off_label(settings.high_contrast_mode),
|
||||
SettingsButton::ToggleHighContrast,
|
||||
"Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Reduce Motion",
|
||||
ReduceMotionText,
|
||||
on_off_label(settings.reduce_motion_mode),
|
||||
SettingsButton::ToggleReduceMotion,
|
||||
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Touch Input Mode",
|
||||
TouchInputModeText,
|
||||
touch_input_mode_label(&settings.touch_input_mode),
|
||||
SettingsButton::ToggleTouchInputMode,
|
||||
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
|
||||
font_res,
|
||||
);
|
||||
if theme_overrides_back {
|
||||
// The active theme provides its own back; the legacy
|
||||
// picker has no visible effect, so we replace its
|
||||
// swatch row with an informational caption. The
|
||||
// player's `selected_card_back` value still
|
||||
// round-trips through `settings.json` — the moment
|
||||
// they switch to a theme without a back, the picker
|
||||
// re-appears with their previous choice intact.
|
||||
picker_row_overridden_by_theme(body, "Card Back", font_res);
|
||||
} else {
|
||||
picker_row(
|
||||
body,
|
||||
"Card Back",
|
||||
unlocked_card_backs,
|
||||
settings.selected_card_back,
|
||||
SettingsButton::SelectCardBack,
|
||||
"Choose your deck art. New backs unlock at higher levels.",
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
picker_row(
|
||||
body,
|
||||
"Background",
|
||||
settings,
|
||||
unlocked_card_backs,
|
||||
unlocked_backgrounds,
|
||||
settings.selected_background,
|
||||
SettingsButton::SelectBackground,
|
||||
"Choose your felt art. New felts unlock at higher levels.",
|
||||
themes,
|
||||
theme_overrides_back,
|
||||
font_res,
|
||||
);
|
||||
// Card-art theme picker — only renders when the registry has
|
||||
// entries (production: always; tests: only when
|
||||
// ThemeRegistryPlugin is installed).
|
||||
if !themes.is_empty() {
|
||||
theme_picker_row(
|
||||
body,
|
||||
"Card Theme",
|
||||
themes,
|
||||
&settings.selected_theme_id,
|
||||
"Choose card-face artwork. Imported themes appear here.",
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
import_themes_row(body, font_res);
|
||||
|
||||
// --- Privacy (only shown when a Matomo URL is configured) ---
|
||||
if settings.matomo_url.is_some() {
|
||||
section_label(body, "Privacy", font_res);
|
||||
toggle_row(
|
||||
body,
|
||||
"Share usage data",
|
||||
AnalyticsEnabledText,
|
||||
on_off_label(settings.analytics_enabled),
|
||||
SettingsButton::ToggleAnalytics,
|
||||
"Sends anonymous game events to Matomo for aggregate analytics.",
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sync ---
|
||||
section_label(body, "Sync", font_res);
|
||||
sync_row(body, sync_status, &settings.sync_backend, font_res);
|
||||
),
|
||||
SettingsTab::Accessibility => spawn_accessibility_tab(body, settings, font_res),
|
||||
SettingsTab::Account => spawn_account_tab(body, settings, sync_status, font_res),
|
||||
});
|
||||
|
||||
// Done is the only action — primary so the player always knows
|
||||
@@ -274,6 +118,244 @@ pub(super) fn spawn_settings_panel(
|
||||
});
|
||||
}
|
||||
|
||||
/// One Settings tab chip — thin wrapper over the shared
|
||||
/// [`spawn_tab_chip`] widget so Settings and the You hub stay visually
|
||||
/// identical.
|
||||
fn tab_chip(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
tab: SettingsTab,
|
||||
active: bool,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
spawn_tab_chip(
|
||||
parent,
|
||||
SettingsTabButton(tab),
|
||||
tab.label(),
|
||||
active,
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
|
||||
/// Audio tab: the two volume rows.
|
||||
fn spawn_audio_tab(
|
||||
body: &mut ChildSpawnerCommands,
|
||||
settings: &Settings,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
volume_row(
|
||||
body,
|
||||
"SFX Volume",
|
||||
settings.sfx_volume,
|
||||
SfxVolumeText,
|
||||
SettingsButton::SfxDown,
|
||||
SettingsButton::SfxUp,
|
||||
"Lower sound effects volume.",
|
||||
"Raise sound effects volume.",
|
||||
font_res,
|
||||
);
|
||||
volume_row(
|
||||
body,
|
||||
"Music Volume",
|
||||
settings.music_volume,
|
||||
MusicVolumeText,
|
||||
SettingsButton::MusicDown,
|
||||
SettingsButton::MusicUp,
|
||||
"Lower music and ambience volume.",
|
||||
"Raise music and ambience volume.",
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
|
||||
/// Gameplay tab: rules and pacing.
|
||||
fn spawn_gameplay_tab(
|
||||
body: &mut ChildSpawnerCommands,
|
||||
settings: &Settings,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
toggle_row(
|
||||
body,
|
||||
"Draw Mode",
|
||||
DrawModeText,
|
||||
draw_mode_label(&settings.draw_mode),
|
||||
SettingsButton::ToggleDrawMode,
|
||||
"Switch between Draw 1 and Draw 3. Takes effect next deal.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Winnable deals only",
|
||||
WinnableDealsOnlyText,
|
||||
winnable_deals_only_label(settings.winnable_deals_only),
|
||||
SettingsButton::ToggleWinnableDealsOnly,
|
||||
"When on, fresh Classic deals are filtered through a solver \
|
||||
(may take a moment when on).",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Anim Speed",
|
||||
AnimSpeedText,
|
||||
anim_speed_label(&settings.animation_speed),
|
||||
SettingsButton::CycleAnimSpeed,
|
||||
"Cycle animation speed: Normal, Fast, Instant.",
|
||||
font_res,
|
||||
);
|
||||
time_bonus_multiplier_row(body, settings.time_bonus_multiplier, font_res);
|
||||
replay_move_interval_row(body, settings.replay_move_interval_secs, font_res);
|
||||
toggle_row(
|
||||
body,
|
||||
"Smart window size",
|
||||
SmartDefaultSizeText,
|
||||
smart_default_size_label(!settings.disable_smart_default_size),
|
||||
SettingsButton::ToggleSmartDefaultSize,
|
||||
"When ON, fresh launches resize the window to ~70 % of the \
|
||||
monitor. OFF pins the 1280\u{00D7}800 baseline. Saved \
|
||||
window size always wins.",
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
|
||||
/// Appearance tab: felt, deck art, backgrounds, and the theme system.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_appearance_tab(
|
||||
body: &mut ChildSpawnerCommands,
|
||||
settings: &Settings,
|
||||
unlocked_card_backs: &[usize],
|
||||
unlocked_backgrounds: &[usize],
|
||||
themes: &[ThemePickerEntry],
|
||||
theme_overrides_back: bool,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
toggle_row(
|
||||
body,
|
||||
"Theme",
|
||||
ThemeText,
|
||||
theme_label(&settings.theme),
|
||||
SettingsButton::ToggleTheme,
|
||||
"Cycle felt color: Green, Blue, Dark.",
|
||||
font_res,
|
||||
);
|
||||
if theme_overrides_back {
|
||||
// The active theme provides its own back; the legacy
|
||||
// picker has no visible effect, so we replace its
|
||||
// swatch row with an informational caption. The
|
||||
// player's `selected_card_back` value still
|
||||
// round-trips through `settings.json` — the moment
|
||||
// they switch to a theme without a back, the picker
|
||||
// re-appears with their previous choice intact.
|
||||
picker_row_overridden_by_theme(body, "Card Back", font_res);
|
||||
} else {
|
||||
picker_row(
|
||||
body,
|
||||
"Card Back",
|
||||
unlocked_card_backs,
|
||||
settings.selected_card_back,
|
||||
SettingsButton::SelectCardBack,
|
||||
"Choose your deck art. New backs unlock at higher levels.",
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
picker_row(
|
||||
body,
|
||||
"Background",
|
||||
unlocked_backgrounds,
|
||||
settings.selected_background,
|
||||
SettingsButton::SelectBackground,
|
||||
"Choose your felt art. New felts unlock at higher levels.",
|
||||
font_res,
|
||||
);
|
||||
// Card-art theme picker — only renders when the registry has
|
||||
// entries (production: always; tests: only when
|
||||
// ThemeRegistryPlugin is installed).
|
||||
if !themes.is_empty() {
|
||||
theme_picker_row(
|
||||
body,
|
||||
"Card Theme",
|
||||
themes,
|
||||
&settings.selected_theme_id,
|
||||
"Choose card-face artwork. Imported themes appear here.",
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
import_themes_row(body, font_res);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
theme_store_row(body, font_res);
|
||||
}
|
||||
|
||||
/// Accessibility tab: perception and input assists, gathered from the
|
||||
/// old Gameplay/Cosmetic sections so they're findable in one place.
|
||||
fn spawn_accessibility_tab(
|
||||
body: &mut ChildSpawnerCommands,
|
||||
settings: &Settings,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
toggle_row(
|
||||
body,
|
||||
"Color-blind Mode",
|
||||
ColorBlindText,
|
||||
color_blind_label(settings.color_blind_mode),
|
||||
SettingsButton::ToggleColorBlind,
|
||||
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"High Contrast",
|
||||
HighContrastText,
|
||||
on_off_label(settings.high_contrast_mode),
|
||||
SettingsButton::ToggleHighContrast,
|
||||
"Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Reduce Motion",
|
||||
ReduceMotionText,
|
||||
on_off_label(settings.reduce_motion_mode),
|
||||
SettingsButton::ToggleReduceMotion,
|
||||
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Touch Input Mode",
|
||||
TouchInputModeText,
|
||||
touch_input_mode_label(&settings.touch_input_mode),
|
||||
SettingsButton::ToggleTouchInputMode,
|
||||
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
|
||||
font_res,
|
||||
);
|
||||
tooltip_delay_row(body, settings.tooltip_delay_secs, font_res);
|
||||
}
|
||||
|
||||
/// Account tab: privacy opt-in (when analytics is configured) and the
|
||||
/// sync backend controls.
|
||||
fn spawn_account_tab(
|
||||
body: &mut ChildSpawnerCommands,
|
||||
settings: &Settings,
|
||||
sync_status: &str,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
// --- Privacy (only shown when a Matomo URL is configured) ---
|
||||
if settings.matomo_url.is_some() {
|
||||
section_label(body, "Privacy", font_res);
|
||||
toggle_row(
|
||||
body,
|
||||
"Share usage data",
|
||||
AnalyticsEnabledText,
|
||||
on_off_label(settings.analytics_enabled),
|
||||
SettingsButton::ToggleAnalytics,
|
||||
"Sends anonymous game events to Matomo for aggregate analytics.",
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sync ---
|
||||
section_label(body, "Sync", font_res);
|
||||
sync_row(body, sync_status, &settings.sync_backend, font_res);
|
||||
}
|
||||
|
||||
/// Section divider — small lavender label inside the scrollable body.
|
||||
pub(super) fn section_label(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
@@ -1193,6 +1275,31 @@ pub(super) fn import_themes_row(
|
||||
});
|
||||
}
|
||||
|
||||
/// "Theme store" row: one pill button that closes Settings and opens
|
||||
/// the in-game theme-store modal (`theme_store_plugin`). Sits directly
|
||||
/// under the Import row so both install paths live together.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn theme_store_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) {
|
||||
parent
|
||||
.spawn((
|
||||
FocusRow,
|
||||
Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|row| {
|
||||
pill_button(
|
||||
row,
|
||||
SettingsButton::OpenThemeStore,
|
||||
"Browse theme store",
|
||||
"Download themes from your sync server without leaving the game.",
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn icon_button(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
label: &str,
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBord
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn sync_settings_panel_visibility(
|
||||
screen: Res<SettingsScreen>,
|
||||
active_tab: Res<ActiveSettingsTab>,
|
||||
panels: Query<Entity, With<SettingsPanel>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
|
||||
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
|
||||
@@ -35,60 +36,17 @@ pub(super) fn sync_settings_panel_visibility(
|
||||
}
|
||||
if screen.0 {
|
||||
if panels.is_empty() && other_modal_scrims.is_empty() {
|
||||
let status_label = sync_status.map_or_else(
|
||||
|| "Status: local only".to_string(),
|
||||
|s| sync_status_label(&s.0),
|
||||
);
|
||||
let unlocked_backs = progress
|
||||
.as_ref()
|
||||
.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
|
||||
let unlocked_bgs = progress
|
||||
.as_ref()
|
||||
.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
|
||||
// Snapshot themes by id, display_name and (optional)
|
||||
// thumbnail pair so spawn_settings_panel doesn't have to
|
||||
// know about the registry / cache shapes. Empty when
|
||||
// ThemeRegistryPlugin isn't installed (tests under
|
||||
// MinimalPlugins) — the picker row simply won't render.
|
||||
// Missing thumbnails (cache not ready, or partial user
|
||||
// theme) leave `thumbnails: None` so the chip renders its
|
||||
// plain-text fallback instead of a broken sprite.
|
||||
let themes: Vec<ThemePickerEntry> = theme_registry
|
||||
.as_deref()
|
||||
.map(|r| {
|
||||
r.iter()
|
||||
.map(|e| ThemePickerEntry {
|
||||
id: e.id.clone(),
|
||||
display_name: e.display_name.clone(),
|
||||
thumbnails: theme_thumbs
|
||||
.as_deref()
|
||||
.and_then(|c| c.get(&e.id))
|
||||
.filter(|p| p.is_fully_populated())
|
||||
.cloned(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// The active card-art theme can supply its own back image —
|
||||
// see `card_plugin::CardImageSet::theme_back`. When that is
|
||||
// populated the legacy "Card Back" picker has no visible
|
||||
// effect, so we render it muted with an explanatory caption
|
||||
// rather than letting the player click swatches that do
|
||||
// nothing. Absent under `MinimalPlugins`; treated as
|
||||
// "no override" in that case.
|
||||
let theme_overrides_back = card_images
|
||||
.as_ref()
|
||||
.is_some_and(|cs| cs.theme_back.is_some());
|
||||
spawn_settings_panel(
|
||||
build_panel(
|
||||
&mut commands,
|
||||
&settings.0,
|
||||
&status_label,
|
||||
unlocked_backs,
|
||||
unlocked_bgs,
|
||||
&themes,
|
||||
scroll_pos.0,
|
||||
sync_status.as_deref(),
|
||||
progress.as_deref(),
|
||||
font_res.as_deref(),
|
||||
theme_overrides_back,
|
||||
theme_registry.as_deref(),
|
||||
theme_thumbs.as_deref(),
|
||||
card_images.as_deref(),
|
||||
scroll_pos.0,
|
||||
active_tab.0,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -102,6 +60,116 @@ pub(super) fn sync_settings_panel_visibility(
|
||||
}
|
||||
}
|
||||
|
||||
/// Gathers the spawn context (sync label, unlocks, theme snapshot,
|
||||
/// back-override flag) and spawns the panel showing `active_tab`.
|
||||
/// Shared by [`sync_settings_panel_visibility`] (open) and
|
||||
/// [`rebuild_panel_on_tab_change`] (tab switch).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_panel(
|
||||
commands: &mut Commands,
|
||||
settings: &Settings,
|
||||
sync_status: Option<&SyncStatusResource>,
|
||||
progress: Option<&ProgressResource>,
|
||||
font_res: Option<&FontResource>,
|
||||
theme_registry: Option<&crate::theme::ThemeRegistry>,
|
||||
theme_thumbs: Option<&ThemeThumbnailCache>,
|
||||
card_images: Option<&crate::card_plugin::CardImageSet>,
|
||||
scroll_offset: f32,
|
||||
active_tab: SettingsTab,
|
||||
) {
|
||||
let status_label = sync_status.map_or_else(
|
||||
|| "Status: local only".to_string(),
|
||||
|s| sync_status_label(&s.0),
|
||||
);
|
||||
let unlocked_backs = progress.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
|
||||
let unlocked_bgs = progress.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
|
||||
// Snapshot themes by id, display_name and (optional)
|
||||
// thumbnail pair so spawn_settings_panel doesn't have to
|
||||
// know about the registry / cache shapes. Empty when
|
||||
// ThemeRegistryPlugin isn't installed (tests under
|
||||
// MinimalPlugins) — the picker row simply won't render.
|
||||
// Missing thumbnails (cache not ready, or partial user
|
||||
// theme) leave `thumbnails: None` so the chip renders its
|
||||
// plain-text fallback instead of a broken sprite.
|
||||
let themes: Vec<ThemePickerEntry> = theme_registry
|
||||
.map(|r| {
|
||||
r.iter()
|
||||
.map(|e| ThemePickerEntry {
|
||||
id: e.id.clone(),
|
||||
display_name: e.display_name.clone(),
|
||||
thumbnails: theme_thumbs
|
||||
.and_then(|c| c.get(&e.id))
|
||||
.filter(|p| p.is_fully_populated())
|
||||
.cloned(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// The active card-art theme can supply its own back image —
|
||||
// see `card_plugin::CardImageSet::theme_back`. When that is
|
||||
// populated the legacy "Card Back" picker has no visible
|
||||
// effect, so we render it muted with an explanatory caption
|
||||
// rather than letting the player click swatches that do
|
||||
// nothing. Absent under `MinimalPlugins`; treated as
|
||||
// "no override" in that case.
|
||||
let theme_overrides_back = card_images.is_some_and(|cs| cs.theme_back.is_some());
|
||||
spawn_settings_panel(
|
||||
commands,
|
||||
settings,
|
||||
&status_label,
|
||||
unlocked_backs,
|
||||
unlocked_bgs,
|
||||
&themes,
|
||||
scroll_offset,
|
||||
font_res,
|
||||
theme_overrides_back,
|
||||
active_tab,
|
||||
);
|
||||
}
|
||||
|
||||
/// Rebuilds the open panel when [`ActiveSettingsTab`] changes (tab chip
|
||||
/// clicked). No-op while the panel is closed — the next open reads the
|
||||
/// resource directly.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn rebuild_panel_on_tab_change(
|
||||
active_tab: Res<ActiveSettingsTab>,
|
||||
panels: Query<Entity, With<SettingsPanel>>,
|
||||
mut scroll_pos: ResMut<SettingsScrollPos>,
|
||||
mut commands: Commands,
|
||||
settings: Res<SettingsResource>,
|
||||
sync_status: Option<Res<SyncStatusResource>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
||||
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
|
||||
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
||||
) {
|
||||
if !active_tab.is_changed() || active_tab.is_added() {
|
||||
return;
|
||||
}
|
||||
if panels.is_empty() {
|
||||
return;
|
||||
}
|
||||
for entity in &panels {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
// A fresh tab starts at the top; per-tab scroll memory isn't worth
|
||||
// the bookkeeping now that no tab needs much scrolling.
|
||||
scroll_pos.0 = 0.0;
|
||||
build_panel(
|
||||
&mut commands,
|
||||
&settings.0,
|
||||
sync_status.as_deref(),
|
||||
progress.as_deref(),
|
||||
font_res.as_deref(),
|
||||
theme_registry.as_deref(),
|
||||
theme_thumbs.as_deref(),
|
||||
card_images.as_deref(),
|
||||
0.0,
|
||||
active_tab.0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Keeps the sync-status text node current while the panel is open.
|
||||
pub(super) fn update_sync_status_text(
|
||||
sync_status: Option<Res<SyncStatusResource>>,
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
//! "Show solution" — solve the current deal off-thread and auto-play
|
||||
//! the winning line through the normal move pipeline.
|
||||
//!
|
||||
//! The pause menu's "Show solution" button fires
|
||||
//! [`crate::events::ShowSolutionRequestEvent`]. This plugin snapshots
|
||||
//! the live [`GameState`], runs [`GameState::winning_line`] on
|
||||
//! [`AsyncComputeTaskPool`] (the solver plus `clean_solution` can take
|
||||
//! seconds — §2.4 never block the main thread), then steps the returned
|
||||
//! instructions on a cadence, one `MoveRequestEvent` /
|
||||
//! `DrawRequestEvent` per tick — the same events player input produces,
|
||||
//! so animations, scoring, undo history, and win detection all behave
|
||||
//! exactly as if the player made the moves.
|
||||
//!
|
||||
//! Playback cancels on Esc, on pause, on undo / new-game requests, and
|
||||
//! on any rejected move (which is what a player interfering mid-line
|
||||
//! produces — their move diverges the state, the next scripted step
|
||||
//! becomes illegal, and the rejection stops the run cleanly).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::{
|
||||
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction,
|
||||
};
|
||||
|
||||
use crate::events::{
|
||||
DrawRequestEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
|
||||
ShowSolutionRequestEvent, UndoRequestEvent,
|
||||
};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
|
||||
/// Seconds between scripted moves — slow enough to follow, fast enough
|
||||
/// not to drag on a 100-move line.
|
||||
const STEP_INTERVAL_SECS: f32 = 0.45;
|
||||
|
||||
/// Initial delay before the first scripted move, giving the pause modal
|
||||
/// time to close and the player a beat to see what's happening.
|
||||
const FIRST_STEP_DELAY_SECS: f32 = 0.9;
|
||||
|
||||
/// In-flight solver task plus the `move_count` snapshot used to detect
|
||||
/// a stale result (player moved while the solver ran). Mirrors
|
||||
/// `PendingHintTask`.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct SolutionSolveTask {
|
||||
inner: Option<(u32, Task<SolveTaskOutput>)>,
|
||||
}
|
||||
|
||||
/// What the solver task carries back to the main thread.
|
||||
enum SolveTaskOutput {
|
||||
Line(Vec<KlondikeInstruction>),
|
||||
Unwinnable,
|
||||
Inconclusive,
|
||||
}
|
||||
|
||||
/// Queue of instructions currently being auto-played, or empty when no
|
||||
/// playback is active. HUD/UI may read `is_active` to badge the state.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct SolutionPlayback {
|
||||
queue: VecDeque<KlondikeInstruction>,
|
||||
cooldown: f32,
|
||||
}
|
||||
|
||||
impl SolutionPlayback {
|
||||
/// `true` while a solution line is being auto-played.
|
||||
pub fn is_active(&self) -> bool {
|
||||
!self.queue.is_empty()
|
||||
}
|
||||
|
||||
fn stop(&mut self) {
|
||||
self.queue.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bevy plugin for the Show-solution flow. See the module docs.
|
||||
pub struct SolutionPlaybackPlugin;
|
||||
|
||||
impl Plugin for SolutionPlaybackPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// add_message is idempotent — GamePlugin registers most of these
|
||||
// too, but this plugin must also boot standalone under
|
||||
// MinimalPlugins in tests.
|
||||
app.init_resource::<SolutionSolveTask>()
|
||||
.init_resource::<SolutionPlayback>()
|
||||
.add_message::<ShowSolutionRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<MoveRequestEvent>()
|
||||
.add_message::<DrawRequestEvent>()
|
||||
.add_message::<MoveRejectedEvent>()
|
||||
.add_message::<UndoRequestEvent>()
|
||||
.add_message::<NewGameRequestEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
handle_show_solution_request,
|
||||
poll_solution_task,
|
||||
cancel_playback_on_interrupt,
|
||||
drive_solution_playback,
|
||||
)
|
||||
.chain()
|
||||
.before(GameMutation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a solver task from the live game state. A repeat request
|
||||
/// while one is already in flight (or playback is running) is ignored
|
||||
/// — the button is idempotent, not a queue.
|
||||
fn handle_show_solution_request(
|
||||
mut requests: MessageReader<ShowSolutionRequestEvent>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut task: ResMut<SolutionSolveTask>,
|
||||
playback: Res<SolutionPlayback>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
if requests.is_empty() {
|
||||
return;
|
||||
}
|
||||
requests.clear();
|
||||
if task.inner.is_some() || playback.is_active() {
|
||||
return;
|
||||
}
|
||||
let Some(game) = game else { return };
|
||||
if game.0.is_won() {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.write(InfoToastEvent("Searching for a solution…".to_string()));
|
||||
let snapshot: GameState = game.0.clone();
|
||||
let move_count = snapshot.move_count();
|
||||
let handle = AsyncComputeTaskPool::get().spawn(async move {
|
||||
match snapshot.winning_line(DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET) {
|
||||
Ok(Some(line)) => SolveTaskOutput::Line(line),
|
||||
Ok(None) => SolveTaskOutput::Unwinnable,
|
||||
Err(_) => SolveTaskOutput::Inconclusive,
|
||||
}
|
||||
});
|
||||
task.inner = Some((move_count, handle));
|
||||
}
|
||||
|
||||
/// Polls the solver; on completion either starts playback or explains
|
||||
/// why there is nothing to play. A result computed for a position the
|
||||
/// player has since moved past is discarded silently.
|
||||
fn poll_solution_task(
|
||||
mut task: ResMut<SolutionSolveTask>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut playback: ResMut<SolutionPlayback>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
let Some((move_count_at_spawn, handle)) = task.inner.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(output) = future::block_on(future::poll_once(handle)) else {
|
||||
return;
|
||||
};
|
||||
let move_count_at_spawn = *move_count_at_spawn;
|
||||
task.inner = None;
|
||||
|
||||
let Some(game) = game else { return };
|
||||
if game.0.move_count() != move_count_at_spawn {
|
||||
return; // Stale — the board moved while we were solving.
|
||||
}
|
||||
|
||||
match output {
|
||||
SolveTaskOutput::Line(line) if line.is_empty() => {}
|
||||
SolveTaskOutput::Line(line) => {
|
||||
toast.write(InfoToastEvent(format!(
|
||||
"Solution found — playing {} moves. Press Esc to stop.",
|
||||
line.len()
|
||||
)));
|
||||
playback.queue = line.into();
|
||||
playback.cooldown = FIRST_STEP_DELAY_SECS;
|
||||
}
|
||||
SolveTaskOutput::Unwinnable => {
|
||||
toast.write(InfoToastEvent(
|
||||
"No winning line exists from this position.".to_string(),
|
||||
));
|
||||
}
|
||||
SolveTaskOutput::Inconclusive => {
|
||||
toast.write(InfoToastEvent(
|
||||
"Couldn't find a solution within the search budget.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops playback on Esc, pause, undo / new-game requests, or a
|
||||
/// rejected move (the signature of the player diverging the board
|
||||
/// mid-line). Runs before `drive_solution_playback` so a cancel takes
|
||||
/// effect without one extra scripted move slipping out.
|
||||
fn cancel_playback_on_interrupt(
|
||||
mut playback: ResMut<SolutionPlayback>,
|
||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut rejected: MessageReader<MoveRejectedEvent>,
|
||||
mut undos: MessageReader<UndoRequestEvent>,
|
||||
mut new_games: MessageReader<NewGameRequestEvent>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
if !playback.is_active() {
|
||||
rejected.clear();
|
||||
undos.clear();
|
||||
new_games.clear();
|
||||
return;
|
||||
}
|
||||
let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape));
|
||||
let interrupted = esc
|
||||
|| paused.is_some_and(|p| p.0)
|
||||
|| rejected.read().next().is_some()
|
||||
|| undos.read().next().is_some()
|
||||
|| new_games.read().next().is_some();
|
||||
if interrupted {
|
||||
playback.stop();
|
||||
toast.write(InfoToastEvent("Solution playback stopped.".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits the next scripted instruction every [`STEP_INTERVAL_SECS`]
|
||||
/// while playback is active, translated to the same request events
|
||||
/// player input produces. Instructions that no longer decode against
|
||||
/// the live state stop the run instead of guessing.
|
||||
fn drive_solution_playback(
|
||||
mut playback: ResMut<SolutionPlayback>,
|
||||
game: Option<Res<GameStateResource>>,
|
||||
time: Res<Time>,
|
||||
mut moves: MessageWriter<MoveRequestEvent>,
|
||||
mut draws: MessageWriter<DrawRequestEvent>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
) {
|
||||
if !playback.is_active() {
|
||||
return;
|
||||
}
|
||||
let Some(game) = game else {
|
||||
playback.stop();
|
||||
return;
|
||||
};
|
||||
if game.0.is_won() {
|
||||
playback.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
playback.cooldown -= time.delta_secs();
|
||||
if playback.cooldown > 0.0 {
|
||||
return;
|
||||
}
|
||||
playback.cooldown = STEP_INTERVAL_SECS;
|
||||
|
||||
let Some(instruction) = playback.queue.pop_front() else {
|
||||
return;
|
||||
};
|
||||
|
||||
match instruction {
|
||||
KlondikeInstruction::RotateStock => {
|
||||
draws.write(DrawRequestEvent);
|
||||
}
|
||||
other => {
|
||||
// Decode against the LIVE state — tableau run lengths depend on
|
||||
// the current face-up counts, so this must happen at step time,
|
||||
// not at solve time.
|
||||
let Some((from, to, count)) = game.0.instruction_to_piles(other) else {
|
||||
playback.stop();
|
||||
toast.write(InfoToastEvent("Solution playback stopped.".to_string()));
|
||||
return;
|
||||
};
|
||||
moves.write(MoveRequestEvent { from, to, count });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use crate::table_plugin::TablePlugin;
|
||||
use bevy::ecs::message::Messages;
|
||||
use solitaire_core::DrawStockConfig;
|
||||
|
||||
/// Seed proven Winnable at 5k budgets by the core
|
||||
/// `budget_is_passed_through_not_clamped` test. Solved here under
|
||||
/// standard rules (no take-from-foundation) to match that baseline.
|
||||
const WINNABLE_SEED: u64 = 0xD1FF_0000_0000_0012;
|
||||
|
||||
fn winnable_state() -> GameState {
|
||||
let mut game = GameState::new(WINNABLE_SEED, DrawStockConfig::DrawOne);
|
||||
game.take_from_foundation = false;
|
||||
game
|
||||
}
|
||||
|
||||
/// Full pipeline: GamePlugin consumes the Move/Draw requests the
|
||||
/// playback driver emits, exactly as in production.
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
.add_plugins(SolutionPlaybackPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn request_solution(app: &mut App) {
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ShowSolutionRequestEvent>>()
|
||||
.write(ShowSolutionRequestEvent);
|
||||
}
|
||||
|
||||
/// Pump updates until the solver task resolves (wall-clock bounded,
|
||||
/// mirroring `winnable_solver_emits_hint_after_async_completes`).
|
||||
fn pump_until_solved(app: &mut App) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while app.world().resource::<SolutionSolveTask>().inner.is_some() {
|
||||
app.update();
|
||||
std::thread::yield_now();
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
app.world().resource::<SolutionSolveTask>().inner.is_none(),
|
||||
"solver task should have completed within 30 s wall-clock",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_solves_and_arms_playback() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(GameStateResource(winnable_state()));
|
||||
|
||||
request_solution(&mut app);
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<SolutionSolveTask>().inner.is_some(),
|
||||
"request must spawn a solver task",
|
||||
);
|
||||
pump_until_solved(&mut app);
|
||||
assert!(
|
||||
app.world().resource::<SolutionPlayback>().is_active(),
|
||||
"a winnable position must arm playback",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_reaches_win_through_normal_pipeline() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(GameStateResource(winnable_state()));
|
||||
request_solution(&mut app);
|
||||
app.update();
|
||||
pump_until_solved(&mut app);
|
||||
assert!(app.world().resource::<SolutionPlayback>().is_active());
|
||||
|
||||
// Force each step instead of waiting out the real cadence; a line
|
||||
// is at most a few hundred instructions.
|
||||
for _ in 0..600 {
|
||||
app.world_mut().resource_mut::<SolutionPlayback>().cooldown = 0.0;
|
||||
app.update();
|
||||
if app.world().resource::<GameStateResource>().0.is_won() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
app.world().resource::<GameStateResource>().0.is_won(),
|
||||
"auto-played line must drive the real game to a win",
|
||||
);
|
||||
assert!(
|
||||
!app.world().resource::<SolutionPlayback>().is_active(),
|
||||
"playback must deactivate once the game is won",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_playback() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(GameStateResource(winnable_state()));
|
||||
app.world_mut()
|
||||
.resource_mut::<SolutionPlayback>()
|
||||
.queue
|
||||
.push_back(KlondikeInstruction::RotateStock);
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::Escape);
|
||||
app.update();
|
||||
assert!(
|
||||
!app.world().resource::<SolutionPlayback>().is_active(),
|
||||
"Esc must stop playback",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_request_is_ignored_while_active() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(GameStateResource(winnable_state()));
|
||||
app.world_mut()
|
||||
.resource_mut::<SolutionPlayback>()
|
||||
.queue
|
||||
.push_back(KlondikeInstruction::RotateStock);
|
||||
|
||||
request_solution(&mut app);
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<SolutionSolveTask>().inner.is_none(),
|
||||
"a request during active playback must not spawn a solver task",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use solitaire_data::{
|
||||
@@ -25,17 +24,13 @@ use crate::events::{
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::platform::ClipboardBackendResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, ModalButton, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||
spawn_modal_button, spawn_modal_header,
|
||||
};
|
||||
use crate::ui_modal::{ButtonVariant, ModalButton, spawn_modal_button};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
|
||||
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
||||
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
||||
};
|
||||
|
||||
/// Bevy resource wrapping the current stats.
|
||||
@@ -225,8 +220,9 @@ impl Plugin for StatsPlugin {
|
||||
.before(GameMutation)
|
||||
.before(update_stats_on_new_game),
|
||||
)
|
||||
.add_systems(Update, toggle_stats_screen.after(GameMutation))
|
||||
.add_systems(Update, handle_stats_close_button)
|
||||
// Open/close/tab handling moved to `you_hub_plugin`
|
||||
// (Phase E) — this plugin now owns body content, replay
|
||||
// selector behavior, and scroll.
|
||||
.add_systems(Update, refresh_replay_history_on_win.after(GameMutation))
|
||||
.add_systems(Update, handle_watch_replay_button)
|
||||
.add_systems(Update, handle_copy_share_link_button)
|
||||
@@ -632,70 +628,17 @@ fn handle_forfeit(
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker on the "Done" button inside the Stats modal. Click despawns
|
||||
/// the overlay; `S` keyboard shortcut toggles it the same way.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StatsCloseButton;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn toggle_stats_screen(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut requests: MessageReader<ToggleStatsRequestEvent>,
|
||||
stats: Res<StatsResource>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
time_attack: Option<Res<TimeAttackResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
latest_replay: Res<ReplayHistoryResource>,
|
||||
selected_index: Res<SelectedReplayIndex>,
|
||||
screens: Query<Entity, With<StatsScreen>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<StatsScreen>)>,
|
||||
) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
if !keys.just_pressed(KeyCode::KeyS) && !button_clicked {
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.single() {
|
||||
commands.entity(entity).despawn();
|
||||
} else {
|
||||
if !other_modal_scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
spawn_stats_screen(
|
||||
&mut commands,
|
||||
&stats.0,
|
||||
progress.as_deref().map(|p| &p.0),
|
||||
time_attack.as_deref(),
|
||||
font_res.as_deref(),
|
||||
&latest_replay.0.replays,
|
||||
selected_index.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Click handler for the modal's "Done" button — despawns the overlay
|
||||
/// the same way the `S` accelerator does.
|
||||
fn handle_stats_close_button(
|
||||
mut commands: Commands,
|
||||
close_buttons: Query<&Interaction, (With<StatsCloseButton>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<StatsScreen>>,
|
||||
) {
|
||||
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_stats_screen(
|
||||
commands: &mut Commands,
|
||||
/// Builds the Stats tab body inside the You hub's card: the 8-cell
|
||||
/// grid plus per-mode bests, progression, weekly goals, unlocks, and
|
||||
/// the optional Time Attack line. The replay selector lives on its own
|
||||
/// hub tab now — see [`spawn_replays_body`]. All markers
|
||||
/// (`StatsCell`, `PerModeBestsRow`, `StatsScrollable`) are unchanged.
|
||||
pub(crate) fn spawn_stats_body(
|
||||
card: &mut ChildSpawnerCommands,
|
||||
stats: &StatsSnapshot,
|
||||
progress: Option<&PlayerProgress>,
|
||||
time_attack: Option<&TimeAttackResource>,
|
||||
font_res: Option<&FontResource>,
|
||||
replays: &[Replay],
|
||||
selected_index: usize,
|
||||
) {
|
||||
// --- primary stat cells ---
|
||||
// First-launch zero-state: when no games have been played yet, render
|
||||
@@ -756,9 +699,7 @@ fn spawn_stats_screen(
|
||||
..default()
|
||||
};
|
||||
|
||||
let scrim = spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "Statistics", font_res);
|
||||
|
||||
{
|
||||
// Scrollable body — the Stats panel renders an 8-cell grid plus
|
||||
// multiple sections (per-mode bests, progression, weekly goals,
|
||||
// unlocks, optional Time Attack, latest replay caption) and
|
||||
@@ -935,7 +876,45 @@ fn spawn_stats_screen(
|
||||
TextColor(STATE_WARNING),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the Replays tab body inside the You hub's card: the
|
||||
/// Prev/Next selector, detail line, and the Watch / Copy-share-link
|
||||
/// actions (buttons live in the body now — the hub owns the single
|
||||
/// Done in the action row). All markers (`ReplayPrevButton`,
|
||||
/// `ReplayNextButton`, `ReplaySelectorCaption`, `ReplaySelectorDetail`,
|
||||
/// `WatchReplayButton`, `CopyShareLinkButton`) are unchanged, so the
|
||||
/// selector/watch/copy systems keep working.
|
||||
pub(crate) fn spawn_replays_body(
|
||||
card: &mut ChildSpawnerCommands,
|
||||
replays: &[Replay],
|
||||
selected_index: usize,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_row = TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
};
|
||||
|
||||
{
|
||||
// Scrollable body — mirrors the other hub tabs so short
|
||||
// windows can still reach every control.
|
||||
card.spawn((
|
||||
StatsScrollable,
|
||||
ScrollPosition::default(),
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_3,
|
||||
max_height: Val::Vh(70.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
// --- Replay selector ---
|
||||
// Prev / Next chips step through the full replay history;
|
||||
// `repaint_replay_selector_caption` and
|
||||
@@ -1019,12 +998,21 @@ fn spawn_stats_screen(
|
||||
));
|
||||
});
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
// The Watch Replay button is always rendered so the
|
||||
// affordance is discoverable from a fresh install. When no
|
||||
// replay exists, the click handler surfaces a clear
|
||||
// "No replay recorded yet" toast rather than silently
|
||||
// doing nothing.
|
||||
// Tab-local actions. The Watch Replay button is always
|
||||
// rendered so the affordance is discoverable from a fresh
|
||||
// install — with no replay, its click handler surfaces a
|
||||
// "No replay recorded yet" toast rather than silently doing
|
||||
// nothing. Same policy for Copy share link (toast explains
|
||||
// when no shareable upload exists).
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
column_gap: VAL_SPACE_2,
|
||||
row_gap: VAL_SPACE_2,
|
||||
margin: UiRect::top(VAL_SPACE_2),
|
||||
..default()
|
||||
})
|
||||
.with_children(|actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
WatchReplayButton,
|
||||
@@ -1033,11 +1021,6 @@ fn spawn_stats_screen(
|
||||
ButtonVariant::Secondary,
|
||||
font_res,
|
||||
);
|
||||
// Copy share link only renders when a sharable URL is in
|
||||
// hand. The button is intentionally absent (rather than
|
||||
// disabled) when no upload has happened yet — keeps the
|
||||
// action bar free of dead controls in the local-only and
|
||||
// first-launch cases.
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
CopyShareLinkButton,
|
||||
@@ -1046,18 +1029,8 @@ fn spawn_stats_screen(
|
||||
ButtonVariant::Secondary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
StatsCloseButton,
|
||||
"Done",
|
||||
Some("S"),
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
// Stats is read-only — opt into click-outside-to-dismiss.
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn one row of the "Per-mode bests" section: the mode label on the
|
||||
@@ -1290,6 +1263,7 @@ mod tests {
|
||||
// ProgressResource is an optional dependency for the stats screen;
|
||||
// include it so toggle tests exercise the progression panel.
|
||||
app.add_plugins(crate::progress_plugin::ProgressPlugin::headless());
|
||||
app.add_plugins(crate::you_hub_plugin::YouHubPlugin);
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
@@ -1500,10 +1474,8 @@ mod tests {
|
||||
#[test]
|
||||
fn zen_win_event_updates_zen_best_score_only() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.mode = solitaire_core::game_state::GameMode::Zen;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.mode =
|
||||
solitaire_core::game_state::GameMode::Zen;
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 1800,
|
||||
@@ -1834,6 +1806,25 @@ mod tests {
|
||||
// Prev/Next replay selector spawn-site tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Opens the You hub via `S`, then switches to the Replays tab
|
||||
/// (where the selector lives since Phase E) and settles the rebuild.
|
||||
fn open_replays_tab(app: &mut App) {
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyS);
|
||||
app.update();
|
||||
// MinimalPlugins never clears input, so the sticky `just_pressed`
|
||||
// would keep re-requesting the Stats tab every frame.
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.clear();
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::you_hub_plugin::ActiveYouTab>()
|
||||
.0 = crate::you_hub_plugin::YouTab::Replays;
|
||||
app.update();
|
||||
app.update();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_row_spawns_when_stats_screen_opens() {
|
||||
let mut app = headless_app();
|
||||
@@ -1842,10 +1833,7 @@ mod tests {
|
||||
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
|
||||
hist.0.replays.push(make_test_replay(90, None));
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyS);
|
||||
app.update();
|
||||
open_replays_tab(&mut app);
|
||||
|
||||
let prev = app
|
||||
.world_mut()
|
||||
@@ -1880,10 +1868,7 @@ mod tests {
|
||||
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
|
||||
hist.0.replays.push(make_test_replay(120, None));
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyS);
|
||||
app.update();
|
||||
open_replays_tab(&mut app);
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
@@ -1903,10 +1888,7 @@ mod tests {
|
||||
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
|
||||
hist.0.replays.push(make_test_replay(65, None)); // 65s → "1:05"
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::KeyS);
|
||||
app.update();
|
||||
open_replays_tab(&mut app);
|
||||
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
|
||||
@@ -301,9 +301,9 @@ fn push_on_exit(
|
||||
exit_events.clear();
|
||||
|
||||
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||
let result = rt
|
||||
.0
|
||||
.block_on(async { tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await });
|
||||
let result = rt.0.block_on(async {
|
||||
tokio::time::timeout(EXIT_PUSH_TIMEOUT, provider.0.push(&payload)).await
|
||||
});
|
||||
match result {
|
||||
Ok(Ok(_)) | Ok(Err(SyncError::UnsupportedPlatform)) => {}
|
||||
Ok(Err(e)) => warn!("sync push on exit failed: {e}"),
|
||||
@@ -667,9 +667,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// In-memory contract: replays[0].share_url is now Some(url).
|
||||
let live = app
|
||||
.world()
|
||||
.resource::<ReplayHistoryResource>();
|
||||
let live = app.world().resource::<ReplayHistoryResource>();
|
||||
assert_eq!(
|
||||
live.0.replays.first().and_then(|r| r.share_url.clone()),
|
||||
Some(url.clone()),
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::WindowResized;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||
use solitaire_core::Suit;
|
||||
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||
|
||||
use crate::events::{HintVisualEvent, StateChangedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
@@ -385,7 +385,11 @@ fn on_window_resized(
|
||||
>,
|
||||
mut marker_outlines: Query<
|
||||
&mut Sprite,
|
||||
(Without<PileMarker>, Without<TableBackground>, Without<Text2d>),
|
||||
(
|
||||
Without<PileMarker>,
|
||||
Without<TableBackground>,
|
||||
Without<Text2d>,
|
||||
),
|
||||
>,
|
||||
mut marker_labels: Query<&mut TextFont, With<Text2d>>,
|
||||
) {
|
||||
@@ -437,8 +441,7 @@ fn on_window_resized(
|
||||
// must be re-derived here too or a resize (fold/unfold, rotation)
|
||||
// leaves them at the stale size — visible as oversized grey
|
||||
// frames on empty piles.
|
||||
let outline_size =
|
||||
new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
|
||||
let outline_size = new_layout.card_size + Vec2::splat(PILE_MARKER_OUTLINE_WIDTH * 2.0);
|
||||
let font_size = new_layout.card_size.x * 0.28;
|
||||
for child in children.into_iter().flatten() {
|
||||
if let Ok(mut outline) = marker_outlines.get_mut(*child) {
|
||||
@@ -593,8 +596,6 @@ fn pile_cards(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -703,7 +704,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
assert_eq!(outlines, 12, "all 12 markers carry an outline child");
|
||||
assert!(labels >= 11, "tableau + foundation markers carry watermarks");
|
||||
assert!(
|
||||
labels >= 11,
|
||||
"tableau + foundation markers carry watermarks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -939,10 +943,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn suit_symbol_all_four_are_distinct() {
|
||||
let symbols: Vec<&str> = Suit::SUITS
|
||||
.iter()
|
||||
.map(suit_symbol)
|
||||
.collect();
|
||||
let symbols: Vec<&str> = Suit::SUITS.iter().map(suit_symbol).collect();
|
||||
let unique: std::collections::HashSet<&&str> = symbols.iter().collect();
|
||||
assert_eq!(unique.len(), 4, "all four suit symbols must be distinct");
|
||||
}
|
||||
|
||||
@@ -116,24 +116,32 @@ impl Plugin for ThemePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the manifest URL for `theme_id`: the bundled themes
|
||||
/// (`"dark"`, `"classic"`) load from the embedded source; any other id
|
||||
/// is a user theme served from the `themes://` directory source.
|
||||
fn theme_manifest_url(theme_id: &str) -> String {
|
||||
bundled_theme_url(theme_id)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("themes://{theme_id}/theme.ron"))
|
||||
}
|
||||
|
||||
/// Kicks off the initial theme load — the one named by
|
||||
/// `Settings::selected_theme_id` if available, falling back to the
|
||||
/// embedded default. The actual rasterisation runs asynchronously on
|
||||
/// the asset task pool; the sync system below picks up the
|
||||
/// `LoadedWithDependencies` event when every face + back is ready.
|
||||
/// fresh-install default from `solitaire_data::Settings`. The actual
|
||||
/// rasterisation runs asynchronously on the asset task pool; the sync
|
||||
/// system below picks up the `LoadedWithDependencies` event when every
|
||||
/// face + back is ready.
|
||||
fn load_initial_theme(
|
||||
asset_server: Res<AssetServer>,
|
||||
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let default_id = solitaire_data::Settings::default().selected_theme_id;
|
||||
let id = settings
|
||||
.as_deref()
|
||||
.map(|s| s.0.selected_theme_id.as_str())
|
||||
.unwrap_or("classic");
|
||||
let url = bundled_theme_url(id)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("themes://{id}/theme.ron"));
|
||||
let handle: Handle<CardTheme> = asset_server.load(url);
|
||||
.unwrap_or(&default_id);
|
||||
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(id));
|
||||
commands.insert_resource(ActiveTheme(handle));
|
||||
}
|
||||
|
||||
@@ -162,10 +170,7 @@ fn react_to_settings_theme_change(
|
||||
return;
|
||||
}
|
||||
|
||||
let url = bundled_theme_url(new_id)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("themes://{new_id}/theme.ron"));
|
||||
let handle: Handle<CardTheme> = asset_server.load(url);
|
||||
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(new_id));
|
||||
commands.insert_resource(ActiveTheme(handle));
|
||||
}
|
||||
|
||||
@@ -252,12 +257,11 @@ fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet
|
||||
image_set.theme_back = Some(theme.back.clone());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Switches the active theme to the one served at
|
||||
/// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>`
|
||||
/// so callers can poll `Assets<CardTheme>` if they want to wait for
|
||||
/// the load before changing UI state.
|
||||
/// Switches the active theme to `theme_id` — the embedded source for
|
||||
/// the bundled `"dark"` / `"classic"` themes, `themes://` for user
|
||||
/// themes. Returns the new `Handle<CardTheme>` so callers can poll
|
||||
/// `Assets<CardTheme>` if they want to wait for the load before
|
||||
/// changing UI state.
|
||||
///
|
||||
/// The handle is also written to the [`ActiveTheme`] resource — the
|
||||
/// per-frame sync system picks up the `LoadedWithDependencies` event
|
||||
@@ -268,8 +272,7 @@ pub fn set_theme(
|
||||
asset_server: &AssetServer,
|
||||
theme_id: &str,
|
||||
) -> Handle<CardTheme> {
|
||||
let url = format!("themes://{theme_id}/theme.ron");
|
||||
let handle: Handle<CardTheme> = asset_server.load(url);
|
||||
let handle: Handle<CardTheme> = asset_server.load(theme_manifest_url(theme_id));
|
||||
commands.insert_resource(ActiveTheme(handle.clone()));
|
||||
handle
|
||||
}
|
||||
@@ -477,13 +480,43 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_theme_url_format_matches_themes_source() {
|
||||
// The format string is the only behavioural surface of
|
||||
// set_theme that doesn't require an App. We assert the URL
|
||||
// shape so a future refactor doesn't accidentally change the
|
||||
// path layout.
|
||||
let url = format!("themes://{}/theme.ron", "user_uploaded");
|
||||
assert_eq!(url, "themes://user_uploaded/theme.ron");
|
||||
fn theme_manifest_url_routes_bundled_ids_to_embedded_source() {
|
||||
// The bundled themes live in the binary, not in the user
|
||||
// themes directory — resolving them to `themes://` would
|
||||
// NotFound and silently leave the previous theme active.
|
||||
assert_eq!(
|
||||
theme_manifest_url("dark"),
|
||||
crate::assets::DARK_THEME_MANIFEST_URL
|
||||
);
|
||||
assert_eq!(
|
||||
theme_manifest_url("classic"),
|
||||
crate::assets::CLASSIC_THEME_MANIFEST_URL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_manifest_url_routes_user_ids_to_themes_source() {
|
||||
// We assert the URL shape so a future refactor doesn't
|
||||
// accidentally change the path layout of the user-themes
|
||||
// source.
|
||||
assert_eq!(
|
||||
theme_manifest_url("user_uploaded"),
|
||||
"themes://user_uploaded/theme.ron"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_theme_fallback_matches_settings_default() {
|
||||
// `load_initial_theme` falls back to the data-crate default
|
||||
// when `SettingsResource` is absent. That default must always
|
||||
// name a bundled theme, or a fresh minimal setup would resolve
|
||||
// to a nonexistent user theme (see the v0.33.0 "classic"
|
||||
// fallback drift in CHANGELOG.md).
|
||||
let default_id = solitaire_data::Settings::default().selected_theme_id;
|
||||
assert!(
|
||||
bundled_theme_url(&default_id).is_some(),
|
||||
"default theme id {default_id:?} must be a bundled theme"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 1: the bundled dark theme always has embedded SVG bytes
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
//! 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>()
|
||||
// Esc-close reads keyboard input; register defensively so
|
||||
// the plugin works under MinimalPlugins in tests.
|
||||
.init_resource::<ButtonInput<KeyCode>>()
|
||||
.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 or on Esc (Phase C
|
||||
/// dismissal audit). The store only ever stacks over Settings and
|
||||
/// nothing stacks over the store, so it owns Esc whenever it is open
|
||||
/// (Settings' own Esc handler is gated on being topmost).
|
||||
fn handle_close_button(
|
||||
interactions: Query<&Interaction, (Changed<Interaction>, With<ThemeStoreCloseButton>)>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let clicked = interactions.iter().any(|i| *i == Interaction::Pressed);
|
||||
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
|
||||
if !clicked && !esc {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Despawn + respawn the modal for every open screen (Bevy despawns
|
||||
/// are deferred, so this is safe within one frame).
|
||||
fn rebuild_open_modal(
|
||||
screens: &Query<Entity, With<ThemeStoreScreen>>,
|
||||
commands: &mut Commands,
|
||||
catalog_state: &CatalogState,
|
||||
registry: Option<&ThemeRegistry>,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
for entity in screens {
|
||||
commands.entity(entity).despawn();
|
||||
spawn_store_modal(commands, catalog_state, registry, font_res);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the store modal for the current catalog state.
|
||||
fn spawn_store_modal(
|
||||
commands: &mut Commands,
|
||||
catalog_state: &CatalogState,
|
||||
registry: Option<&ThemeRegistry>,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let body_font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
};
|
||||
let caption_font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
|
||||
let scrim = spawn_modal(commands, ThemeStoreScreen, Z_MODAL_PANEL + 1, |card| {
|
||||
spawn_modal_header(card, "Theme Store", font_res);
|
||||
|
||||
match catalog_state {
|
||||
CatalogState::Idle | CatalogState::Loading => {
|
||||
card.spawn((
|
||||
Text::new("Loading catalog…"),
|
||||
body_font.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
CatalogState::Error(msg) => {
|
||||
card.spawn((
|
||||
Text::new(format!("Could not load the catalog: {msg}")),
|
||||
body_font.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
CatalogState::Loaded(entries) if entries.is_empty() => {
|
||||
card.spawn((
|
||||
Text::new("The server has no themes yet."),
|
||||
body_font.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
CatalogState::Loaded(entries) => {
|
||||
for entry in entries {
|
||||
let installed =
|
||||
registry.is_some_and(|registry| registry.find(&entry.id).is_some());
|
||||
spawn_store_row(card, entry, installed, &body_font, &caption_font, font_res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
ThemeStoreCloseButton,
|
||||
"Close",
|
||||
None,
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
commands.entity(scrim).insert(ScrimDismissible);
|
||||
}
|
||||
|
||||
/// One catalog row: name + author/size caption on the left, Install
|
||||
/// button (or "Installed" caption) on the right.
|
||||
fn spawn_store_row(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
entry: &ThemeCatalogEntry,
|
||||
installed: bool,
|
||||
body_font: &TextFont,
|
||||
caption_font: &TextFont,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
parent
|
||||
.spawn((
|
||||
Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
justify_content: JustifyContent::SpaceBetween,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
padding: UiRect::vertical(VAL_SPACE_2),
|
||||
border: UiRect::bottom(Val::Px(1.0)),
|
||||
..default()
|
||||
},
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|row| {
|
||||
row.spawn(Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
})
|
||||
.with_children(|col| {
|
||||
col.spawn((
|
||||
Text::new(entry.name.clone()),
|
||||
body_font.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
col.spawn((
|
||||
Text::new(format!(
|
||||
"by {} — {} KB",
|
||||
entry.author,
|
||||
entry.size_bytes.div_ceil(1024)
|
||||
)),
|
||||
caption_font.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
});
|
||||
if installed {
|
||||
row.spawn((
|
||||
Text::new("Installed"),
|
||||
caption_font.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
} else {
|
||||
spawn_modal_button(
|
||||
row,
|
||||
ThemeStoreInstallButton(entry.clone()),
|
||||
"Install",
|
||||
None,
|
||||
ButtonVariant::Secondary,
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy::ecs::message::Messages;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(ThemeStorePlugin);
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn settings_with_server() -> SettingsResource {
|
||||
SettingsResource(Settings {
|
||||
sync_backend: SyncBackend::SolitaireServer {
|
||||
url: "http://127.0.0.1:1".to_string(),
|
||||
username: "tester".to_string(),
|
||||
avatar_url: None,
|
||||
},
|
||||
..Settings::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn screen_count(app: &mut App) -> usize {
|
||||
app.world_mut()
|
||||
.query::<&ThemeStoreScreen>()
|
||||
.iter(app.world())
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_base_url_requires_server_backend() {
|
||||
assert_eq!(store_base_url(&Settings::default()), None);
|
||||
assert_eq!(
|
||||
store_base_url(&settings_with_server().0).as_deref(),
|
||||
Some("http://127.0.0.1:1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_request_without_server_warns_and_spawns_nothing() {
|
||||
let mut app = headless_app();
|
||||
// Default settings → SyncBackend::Local → no store.
|
||||
app.insert_resource(SettingsResource(Settings::default()));
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||
.write(ThemeStoreOpenRequestEvent);
|
||||
app.update();
|
||||
|
||||
assert_eq!(screen_count(&mut app), 0);
|
||||
assert!(
|
||||
!app.world()
|
||||
.resource::<Messages<WarningToastEvent>>()
|
||||
.is_empty(),
|
||||
"player must be told why the store did not open"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_request_without_runtime_warns_and_spawns_nothing() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(settings_with_server());
|
||||
// No TokioRuntimeResource inserted.
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||
.write(ThemeStoreOpenRequestEvent);
|
||||
app.update();
|
||||
|
||||
assert_eq!(screen_count(&mut app), 0);
|
||||
assert!(
|
||||
!app.world()
|
||||
.resource::<Messages<WarningToastEvent>>()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_request_with_server_spawns_modal_in_loading_state() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(settings_with_server());
|
||||
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||
.write(ThemeStoreOpenRequestEvent);
|
||||
app.update();
|
||||
|
||||
assert_eq!(screen_count(&mut app), 1);
|
||||
assert!(matches!(
|
||||
*app.world().resource::<CatalogState>(),
|
||||
CatalogState::Loading | CatalogState::Error(_)
|
||||
));
|
||||
assert_eq!(
|
||||
app.world().resource::<StoreBaseUrl>().0.as_deref(),
|
||||
Some("http://127.0.0.1:1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_open_request_does_not_stack_a_second_modal() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(settings_with_server());
|
||||
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
|
||||
for _ in 0..2 {
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||
.write(ThemeStoreOpenRequestEvent);
|
||||
app.update();
|
||||
}
|
||||
assert_eq!(screen_count(&mut app), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_catalog_fetch_rebuilds_modal_with_error_state() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(settings_with_server());
|
||||
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||
.write(ThemeStoreOpenRequestEvent);
|
||||
app.update();
|
||||
|
||||
// 127.0.0.1:1 refuses connections; the fetch task must resolve
|
||||
// to an error within a bounded number of frames and the modal
|
||||
// must survive the rebuild.
|
||||
for _ in 0..200 {
|
||||
app.update();
|
||||
if matches!(
|
||||
*app.world().resource::<CatalogState>(),
|
||||
CatalogState::Error(_)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
assert!(matches!(
|
||||
*app.world().resource::<CatalogState>(),
|
||||
CatalogState::Error(_)
|
||||
));
|
||||
assert_eq!(screen_count(&mut app), 1);
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,8 @@
|
||||
|
||||
use bevy::ecs::message::MessageReader;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::KlondikePile;
|
||||
use solitaire_core::Card;
|
||||
use solitaire_core::KlondikePile;
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::StateChangedEvent;
|
||||
|
||||
@@ -60,9 +60,9 @@ use crate::settings_plugin::SettingsResource;
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI,
|
||||
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
|
||||
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG,
|
||||
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5,
|
||||
scaled_duration,
|
||||
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_PRIMARY,
|
||||
TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
|
||||
VAL_SPACE_4, VAL_SPACE_5, scaled_duration,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -402,6 +402,46 @@ pub fn spawn_modal_button<M: Component>(
|
||||
});
|
||||
}
|
||||
|
||||
/// One tab chip for a tabbed modal (Settings, the You hub). The active
|
||||
/// chip is filled + bright with a success-green border; inactive chips
|
||||
/// are quiet outlines. `marker` is the plugin's click-target component
|
||||
/// carrying which tab the chip selects.
|
||||
pub fn spawn_tab_chip<M: Component>(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
marker: M,
|
||||
label: &str,
|
||||
active: bool,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
parent
|
||||
.spawn((
|
||||
marker,
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||||
justify_content: JustifyContent::Center,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(if active { BG_ELEVATED_HI } else { BG_BASE }),
|
||||
BorderColor::all(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
|
||||
HighContrastBorder::with_default(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new(label),
|
||||
font,
|
||||
TextColor(if active { TEXT_PRIMARY } else { TEXT_SECONDARY }),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic touch-scroll helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -304,10 +304,7 @@ impl HighContrastBackground {
|
||||
/// [`BORDER_SUBTLE_HC`]. Currently used by the WIN MOVE scrub-bar
|
||||
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
|
||||
/// than to a neutral gray.
|
||||
pub const fn with_hc(
|
||||
default_color: Color,
|
||||
hc_color: Color,
|
||||
) -> Self {
|
||||
pub const fn with_hc(default_color: Color, hc_color: Color) -> Self {
|
||||
Self {
|
||||
default_color,
|
||||
hc_color,
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
//! started), a full-screen modal is spawned showing score, time, XP, and a
|
||||
//! "Play Again" button that fires `NewGameRequestEvent` and closes the modal.
|
||||
//!
|
||||
//! # Phase G (docs/ui-redesign-2026-07.md) — action hierarchy
|
||||
//! The modal leads with actions: **Play Again** (primary, Enter
|
||||
//! accelerator), then **Watch Replay** and **Share Replay** (shared
|
||||
//! `stats_plugin` markers, so the global handlers there act on the
|
||||
//! just-won replay), with the score/time/XP recap reading quietly
|
||||
//! below.
|
||||
//!
|
||||
//! # Task #47 — Win fanfare screen-shake
|
||||
//! When `GameWonEvent` fires, `ScreenShakeResource` is set. A system offsets
|
||||
//! the `Camera2d` `Transform` each frame with a decaying oscillation until the
|
||||
@@ -23,7 +30,7 @@ use crate::game_plugin::GameMutation;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||
use crate::stats_plugin::{CopyShareLinkButton, StatsResource, StatsUpdate, WatchReplayButton};
|
||||
use crate::ui_modal::ModalScrim;
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
|
||||
@@ -163,12 +170,15 @@ pub struct SessionAchievements {
|
||||
#[derive(Component, Debug)]
|
||||
pub struct WinSummaryOverlay;
|
||||
|
||||
/// Marker on the "Play Again" / "Watch Replay" buttons inside the win-summary modal.
|
||||
/// Marker on the "Play Again" button inside the win-summary modal.
|
||||
///
|
||||
/// Watch Replay and Share Replay carry the shared
|
||||
/// [`WatchReplayButton`] / [`CopyShareLinkButton`] markers from
|
||||
/// `stats_plugin`, so the global handlers there drive them (both act
|
||||
/// on [`crate::stats_plugin::SelectedReplayIndex`], which snaps to the
|
||||
/// just-won replay on every `GameWonEvent`).
|
||||
#[derive(Component, Debug)]
|
||||
enum WinSummaryButton {
|
||||
PlayAgain,
|
||||
WatchReplay,
|
||||
}
|
||||
struct WinSummaryPlayAgainButton;
|
||||
|
||||
/// Marker for one row of the win-modal score-breakdown reveal.
|
||||
///
|
||||
@@ -230,6 +240,7 @@ impl Plugin for WinSummaryPlugin {
|
||||
collect_session_achievements,
|
||||
spawn_win_summary_after_delay,
|
||||
handle_win_summary_buttons,
|
||||
close_overlay_on_watch_replay,
|
||||
handle_win_summary_keyboard,
|
||||
apply_screen_shake,
|
||||
reveal_score_breakdown,
|
||||
@@ -604,50 +615,48 @@ fn spawn_win_summary_after_delay(
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
|
||||
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
|
||||
/// Handles "Play Again" in the win-summary modal: collapses the
|
||||
/// overlay and requests a fresh deal. `NewGameRequestEvent::default()`
|
||||
/// reuses the current game's `GameMode`, and `handle_new_game` reads
|
||||
/// the deal options (draw mode, difficulty) from `Settings` — so the
|
||||
/// rematch is "same mode + same options" in one tap.
|
||||
fn handle_win_summary_buttons(
|
||||
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>,
|
||||
interaction_query: Query<&Interaction, (Changed<Interaction>, With<WinSummaryPlayAgainButton>)>,
|
||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||
mut commands: Commands,
|
||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
|
||||
mut playback: Option<ResMut<crate::replay_playback::ReplayPlaybackState>>,
|
||||
) {
|
||||
// Collect all pressed buttons first to avoid moving `playback` inside the loop.
|
||||
let pressed: Vec<&WinSummaryButton> = interaction_query
|
||||
.iter()
|
||||
.filter(|(i, _)| **i == Interaction::Pressed)
|
||||
.map(|(_, b)| b)
|
||||
.collect();
|
||||
if !interaction_query.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
for entity in &overlays {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
new_game.write(NewGameRequestEvent::default());
|
||||
}
|
||||
|
||||
for button in pressed {
|
||||
match button {
|
||||
WinSummaryButton::PlayAgain => {
|
||||
for entity in &overlays {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
new_game.write(NewGameRequestEvent::default());
|
||||
}
|
||||
WinSummaryButton::WatchReplay => {
|
||||
let latest = history.as_ref().and_then(|h| h.0.replays.last()).cloned();
|
||||
match (latest, playback.as_mut()) {
|
||||
(Some(replay), Some(pb)) => {
|
||||
for entity in &overlays {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
crate::replay_playback::start_replay_playback(&mut commands, pb, replay);
|
||||
}
|
||||
(Some(_), None) => {
|
||||
toast.write(InfoToastEvent("Replay playback not available".to_string()));
|
||||
}
|
||||
(None, _) => {
|
||||
toast.write(InfoToastEvent("No replay saved yet".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Collapses the win-summary overlay when its "Watch Replay" button is
|
||||
/// pressed, so playback (started by `stats_plugin`'s global
|
||||
/// [`WatchReplayButton`] handler reacting to the same press) is not
|
||||
/// hidden behind the celebration scrim. No-op while the overlay is
|
||||
/// closed — the Replays-tab copy of the button manages its own modal.
|
||||
///
|
||||
/// "Share Replay" ([`CopyShareLinkButton`]) deliberately does NOT
|
||||
/// close the overlay: the player stays on the celebration while the
|
||||
/// copy-feedback toast confirms the link.
|
||||
fn close_overlay_on_watch_replay(
|
||||
buttons: Query<&Interaction, (Changed<Interaction>, With<WatchReplayButton>)>,
|
||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if overlays.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
for entity in &overlays {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -818,18 +827,68 @@ fn spawn_overlay(
|
||||
));
|
||||
}
|
||||
|
||||
// Score breakdown reveal — replaces the previous single
|
||||
// "Score:" line with a per-component multi-row layout.
|
||||
// --- Action hierarchy (Phase G) ---
|
||||
// Play Again is the hero action; the two replay
|
||||
// actions sit under it; the stats recap reads quietly
|
||||
// below all three. Rematch is one tap.
|
||||
|
||||
// Play Again (primary, full row)
|
||||
card.spawn((
|
||||
WinSummaryPlayAgainButton,
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_self: AlignSelf::Stretch,
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
margin: UiRect::top(VAL_SPACE_2),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(ACCENT_PRIMARY),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new("Play Again \u{21B5}"),
|
||||
TextFont {
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
},
|
||||
TextColor(BG_BASE),
|
||||
));
|
||||
});
|
||||
|
||||
// Watch Replay + Share Replay (secondary, side by side).
|
||||
// Both reuse the global stats_plugin handlers, which
|
||||
// target the just-won replay (`SelectedReplayIndex`
|
||||
// snaps to 0 on every win). Each is always rendered —
|
||||
// with no replay / no share URL the handler explains
|
||||
// itself in a toast instead of silently doing nothing.
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
justify_content: JustifyContent::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
spawn_replay_action(row, WatchReplayButton, "Watch Replay");
|
||||
spawn_replay_action(row, CopyShareLinkButton, "Share Replay");
|
||||
});
|
||||
|
||||
// --- Quiet stats recap, below the actions ---
|
||||
|
||||
// Score breakdown reveal — per-component multi-row
|
||||
// layout with the staggered fade-in.
|
||||
spawn_score_breakdown(card, &breakdown, anim_speed);
|
||||
|
||||
// Time
|
||||
// Time (demoted to body/secondary — part of the quiet
|
||||
// recap, not the celebration headline)
|
||||
card.spawn((
|
||||
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
|
||||
TextFont {
|
||||
font_size: TYPE_HEADLINE,
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_PRIMARY),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
|
||||
// XP total
|
||||
@@ -859,68 +918,40 @@ fn spawn_overlay(
|
||||
if !session.names.is_empty() {
|
||||
spawn_achievements_section(card, &session.names);
|
||||
}
|
||||
|
||||
// Button row: Watch Replay + Play Again side by side.
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
justify_content: JustifyContent::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
margin: UiRect::top(VAL_SPACE_2),
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
// Watch Replay (secondary style)
|
||||
row.spawn((
|
||||
WinSummaryButton::WatchReplay,
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
||||
justify_content: JustifyContent::Center,
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::NONE),
|
||||
BorderColor::all(ACCENT_PRIMARY),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new("Watch Replay"),
|
||||
TextFont {
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
},
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
});
|
||||
|
||||
// Play Again (primary style)
|
||||
row.spawn((
|
||||
WinSummaryButton::PlayAgain,
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
||||
justify_content: JustifyContent::Center,
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(ACCENT_PRIMARY),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new("Play Again \u{21B5}"),
|
||||
TextFont {
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
},
|
||||
TextColor(BG_BASE),
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawns one secondary (outline-style) replay action button in the
|
||||
/// win modal's replay row. `marker` is the shared `stats_plugin`
|
||||
/// click-target component (`WatchReplayButton` / `CopyShareLinkButton`)
|
||||
/// whose global handler reacts to the press.
|
||||
fn spawn_replay_action<M: Component>(row: &mut ChildSpawnerCommands, marker: M, label: &str) {
|
||||
row.spawn((
|
||||
marker,
|
||||
Button,
|
||||
Node {
|
||||
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
||||
justify_content: JustifyContent::Center,
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::NONE),
|
||||
BorderColor::all(ACCENT_PRIMARY),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new(label.to_string()),
|
||||
TextFont {
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
},
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Maximum number of achievement names shown explicitly in the win modal before
|
||||
/// the overflow "...and N more" line is shown instead.
|
||||
const MAX_ACHIEVEMENTS_SHOWN: usize = 3;
|
||||
@@ -1863,4 +1894,134 @@ mod tests {
|
||||
assert_eq!(stagger, 0.0);
|
||||
assert_eq!(fade, 0.0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase G — action hierarchy
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Like [`make_app`] but with `TimePlugin` disabled and a manual
|
||||
/// `Time` resource, so tests can step the win-summary delay timer
|
||||
/// deterministically via `Time::advance_by` (the real clock's
|
||||
/// microsecond deltas would never reach the 0.5 s threshold).
|
||||
fn make_app_manual_clock() -> App {
|
||||
use bevy::time::TimePlugin;
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins.build().disable::<TimePlugin>())
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.insert_resource(StatsResource(StatsSnapshot::default()))
|
||||
.insert_resource(GameStateResource(GameState::new(
|
||||
0,
|
||||
solitaire_core::DrawStockConfig::DrawOne,
|
||||
)))
|
||||
.insert_resource(ProgressResource(PlayerProgress::default()));
|
||||
app.init_resource::<Time>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
/// Drives the real spawn path: fire `GameWonEvent`, then advance
|
||||
/// `Time` past the 0.5 s celebration delay so
|
||||
/// `spawn_win_summary_after_delay` spawns the overlay.
|
||||
fn open_win_overlay(app: &mut App) {
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 1200,
|
||||
time_seconds: 90,
|
||||
});
|
||||
app.update();
|
||||
{
|
||||
let mut time = app.world_mut().resource_mut::<Time>();
|
||||
time.advance_by(std::time::Duration::from_secs_f32(
|
||||
WIN_SUMMARY_DELAY_SECS + 0.1,
|
||||
));
|
||||
}
|
||||
app.update();
|
||||
// One more frame so the deferred spawn commands flush.
|
||||
app.update();
|
||||
}
|
||||
|
||||
fn overlay_count(app: &mut App) -> usize {
|
||||
app.world_mut()
|
||||
.query::<&WinSummaryOverlay>()
|
||||
.iter(app.world())
|
||||
.count()
|
||||
}
|
||||
|
||||
fn button_entity<M: Component>(app: &mut App) -> Entity {
|
||||
let entities: Vec<Entity> = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<M>>()
|
||||
.iter(app.world())
|
||||
.collect();
|
||||
assert_eq!(entities.len(), 1, "expected exactly one button");
|
||||
entities[0]
|
||||
}
|
||||
|
||||
/// The win modal must render all three Phase G actions: the
|
||||
/// primary Play Again plus the shared Watch/Share replay buttons.
|
||||
#[test]
|
||||
fn win_modal_renders_play_again_watch_and_share_actions() {
|
||||
let mut app = make_app_manual_clock();
|
||||
open_win_overlay(&mut app);
|
||||
assert_eq!(overlay_count(&mut app), 1, "overlay must spawn after delay");
|
||||
button_entity::<WinSummaryPlayAgainButton>(&mut app);
|
||||
button_entity::<WatchReplayButton>(&mut app);
|
||||
button_entity::<CopyShareLinkButton>(&mut app);
|
||||
}
|
||||
|
||||
/// Play Again collapses the overlay and requests a fresh deal.
|
||||
#[test]
|
||||
fn play_again_press_closes_overlay_and_requests_new_game() {
|
||||
use bevy::ecs::message::Messages;
|
||||
|
||||
let mut app = make_app_manual_clock();
|
||||
open_win_overlay(&mut app);
|
||||
let button = button_entity::<WinSummaryPlayAgainButton>(&mut app);
|
||||
app.world_mut()
|
||||
.entity_mut(button)
|
||||
.insert(Interaction::Pressed);
|
||||
app.update();
|
||||
|
||||
assert_eq!(overlay_count(&mut app), 0, "Play Again must close overlay");
|
||||
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||
assert!(
|
||||
!events.is_empty(),
|
||||
"Play Again must write NewGameRequestEvent"
|
||||
);
|
||||
}
|
||||
|
||||
/// Watch Replay collapses the overlay so playback (driven by the
|
||||
/// global `stats_plugin` handler on the same press) is visible.
|
||||
#[test]
|
||||
fn watch_replay_press_closes_the_overlay() {
|
||||
let mut app = make_app_manual_clock();
|
||||
open_win_overlay(&mut app);
|
||||
let button = button_entity::<WatchReplayButton>(&mut app);
|
||||
app.world_mut()
|
||||
.entity_mut(button)
|
||||
.insert(Interaction::Pressed);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
overlay_count(&mut app),
|
||||
0,
|
||||
"Watch Replay must close the celebration overlay"
|
||||
);
|
||||
}
|
||||
|
||||
/// Share Replay leaves the overlay open — the player stays on the
|
||||
/// celebration while the copy-feedback toast confirms the link.
|
||||
#[test]
|
||||
fn share_replay_press_keeps_the_overlay_open() {
|
||||
let mut app = make_app_manual_clock();
|
||||
open_win_overlay(&mut app);
|
||||
let button = button_entity::<CopyShareLinkButton>(&mut app);
|
||||
app.world_mut()
|
||||
.entity_mut(button)
|
||||
.insert(Interaction::Pressed);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
overlay_count(&mut app),
|
||||
1,
|
||||
"Share Replay must not close the overlay"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
//! The "You" hub — Profile · Stats · Achievements · Replays folded
|
||||
//! into one tabbed modal (Phase E of `docs/ui-redesign-2026-07.md`).
|
||||
//!
|
||||
//! The four screens were previously standalone modals reached one at a
|
||||
//! time through the HUD popover. The hub owns the modal shell (header,
|
||||
//! tab chips via the shared [`spawn_tab_chip`] widget, a single Done
|
||||
//! button); each tab's content is a body builder that lives in its
|
||||
//! original plugin (`spawn_profile_body`, `spawn_stats_body`,
|
||||
//! `spawn_achievements_body`, `spawn_replays_body`) so every marker
|
||||
//! component and per-row update system keeps working unchanged.
|
||||
//!
|
||||
//! Legacy screen markers (`ProfileScreen`, `StatsScreen`,
|
||||
//! `AchievementsScreen`) are inserted on the hub scrim while their tab
|
||||
//! is active, so existing queries and tests keep their meaning.
|
||||
//!
|
||||
//! Open paths: `ToggleProfileRequestEvent` / `ToggleStatsRequestEvent`
|
||||
//! / `ToggleAchievementsRequestEvent` (HUD popover) and the P / S / A
|
||||
//! accelerators — each opens the hub pre-selected to its tab, toggles
|
||||
//! the hub closed when its tab is already showing, or switches tabs
|
||||
//! when a different tab is showing. Esc, Done, and scrim-click close.
|
||||
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use solitaire_data::StatsSnapshot;
|
||||
|
||||
use crate::achievement_plugin::{AchievementsResource, AchievementsScreen};
|
||||
use crate::events::{
|
||||
ToggleAchievementsRequestEvent, ToggleProfileRequestEvent, ToggleStatsRequestEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::profile_plugin::{AvatarResource, ProfileScreen};
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::SyncStatusResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::stats_plugin::{ReplayHistoryResource, SelectedReplayIndex, StatsResource, StatsScreen};
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
use crate::ui_focus::FocusRow;
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||
spawn_modal_button, spawn_modal_header, spawn_tab_chip,
|
||||
};
|
||||
use crate::ui_theme::{VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL};
|
||||
|
||||
/// Which tab of the You hub is showing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum YouTab {
|
||||
#[default]
|
||||
Profile,
|
||||
Stats,
|
||||
Achievements,
|
||||
Replays,
|
||||
}
|
||||
|
||||
impl YouTab {
|
||||
/// Every tab, in display order.
|
||||
pub const ALL: [Self; 4] = [
|
||||
Self::Profile,
|
||||
Self::Stats,
|
||||
Self::Achievements,
|
||||
Self::Replays,
|
||||
];
|
||||
|
||||
/// Chip label.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Profile => "Profile",
|
||||
Self::Stats => "Stats",
|
||||
Self::Achievements => "Awards",
|
||||
Self::Replays => "Replays",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The active hub tab. Session-only; reopening returns to the last tab
|
||||
/// unless an open request names a different one.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct ActiveYouTab(pub YouTab);
|
||||
|
||||
/// Marker on the hub modal's scrim root.
|
||||
#[derive(Component)]
|
||||
pub struct YouHubScreen;
|
||||
|
||||
/// Per-chip tab selector button.
|
||||
#[derive(Component, Debug)]
|
||||
struct YouHubTabButton(YouTab);
|
||||
|
||||
/// Marker on the hub's Done button.
|
||||
#[derive(Component)]
|
||||
struct YouHubCloseButton;
|
||||
|
||||
/// Read-only bundle of everything the tab bodies render from. Split
|
||||
/// out as a [`SystemParam`] because the open + rebuild systems both
|
||||
/// need the full set and Bevy caps systems at 16 parameters.
|
||||
#[derive(SystemParam)]
|
||||
struct YouHubContext<'w> {
|
||||
settings: Option<Res<'w, SettingsResource>>,
|
||||
sync_status: Option<Res<'w, SyncStatusResource>>,
|
||||
progress: Option<Res<'w, ProgressResource>>,
|
||||
achievements: Option<Res<'w, AchievementsResource>>,
|
||||
stats: Option<Res<'w, StatsResource>>,
|
||||
avatar: Option<Res<'w, AvatarResource>>,
|
||||
time_attack: Option<Res<'w, TimeAttackResource>>,
|
||||
replay_history: Option<Res<'w, ReplayHistoryResource>>,
|
||||
selected_replay: Option<Res<'w, SelectedReplayIndex>>,
|
||||
font_res: Option<Res<'w, FontResource>>,
|
||||
}
|
||||
|
||||
/// Bevy plugin owning the You hub lifecycle. Requires the profile,
|
||||
/// stats, and achievement plugins for live data; degrades to empty tab
|
||||
/// bodies without them (headless tests).
|
||||
pub struct YouHubPlugin;
|
||||
|
||||
impl Plugin for YouHubPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<ActiveYouTab>()
|
||||
.add_message::<ToggleProfileRequestEvent>()
|
||||
.add_message::<ToggleStatsRequestEvent>()
|
||||
.add_message::<ToggleAchievementsRequestEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
// Chained: a toggle/chip press must be observed before
|
||||
// the rebuild, and the rebuild before close — total
|
||||
// order prevents double-spawns within one frame.
|
||||
(
|
||||
open_or_toggle_you_hub,
|
||||
handle_tab_buttons,
|
||||
rebuild_on_tab_change,
|
||||
handle_close_button,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps this frame's toggle events + accelerator keys to a requested
|
||||
/// tab, mirroring the semantics the three standalone screens had.
|
||||
fn requested_tab(
|
||||
profile_events: &mut MessageReader<ToggleProfileRequestEvent>,
|
||||
stats_events: &mut MessageReader<ToggleStatsRequestEvent>,
|
||||
achievements_events: &mut MessageReader<ToggleAchievementsRequestEvent>,
|
||||
keys: &ButtonInput<KeyCode>,
|
||||
) -> Option<YouTab> {
|
||||
let profile = profile_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyP);
|
||||
let stats = stats_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyS);
|
||||
let achievements = achievements_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyA);
|
||||
if profile {
|
||||
Some(YouTab::Profile)
|
||||
} else if stats {
|
||||
Some(YouTab::Stats)
|
||||
} else if achievements {
|
||||
Some(YouTab::Achievements)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the hub on the requested tab, switches tabs when it's already
|
||||
/// open on a different one, toggles it closed on a same-tab request or
|
||||
/// Esc.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open_or_toggle_you_hub(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut profile_events: MessageReader<ToggleProfileRequestEvent>,
|
||||
mut stats_events: MessageReader<ToggleStatsRequestEvent>,
|
||||
mut achievements_events: MessageReader<ToggleAchievementsRequestEvent>,
|
||||
screens: Query<Entity, With<YouHubScreen>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<YouHubScreen>)>,
|
||||
mut active: ResMut<ActiveYouTab>,
|
||||
ctx: YouHubContext,
|
||||
) {
|
||||
let requested = requested_tab(
|
||||
&mut profile_events,
|
||||
&mut stats_events,
|
||||
&mut achievements_events,
|
||||
&keys,
|
||||
);
|
||||
let open = !screens.is_empty();
|
||||
|
||||
if open {
|
||||
// Esc closes the topmost modal — the hub, when it's showing.
|
||||
if keys.just_pressed(KeyCode::Escape) {
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
return;
|
||||
}
|
||||
match requested {
|
||||
Some(tab) if tab == active.0 => {
|
||||
// Same-tab request toggles closed (P opens Profile,
|
||||
// P again closes — parity with the old screens).
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
Some(tab) => {
|
||||
// Different tab: switch in place. The rebuild system
|
||||
// (next in the chain) observes the change.
|
||||
active.0 = tab;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(tab) = requested else { return };
|
||||
if !other_modal_scrims.is_empty() {
|
||||
return; // Another modal is already visible (§14.2).
|
||||
}
|
||||
if active.0 != tab {
|
||||
// Written before the spawn; the rebuild system also runs this
|
||||
// frame but sees no live hub (the spawn below is deferred), so
|
||||
// no double-spawn.
|
||||
active.0 = tab;
|
||||
}
|
||||
spawn_you_hub(&mut commands, tab, &ctx);
|
||||
}
|
||||
|
||||
/// Switches the active tab when a chip is pressed.
|
||||
fn handle_tab_buttons(
|
||||
interactions: Query<(&Interaction, &YouHubTabButton), Changed<Interaction>>,
|
||||
mut active: ResMut<ActiveYouTab>,
|
||||
) {
|
||||
for (interaction, chip) in &interactions {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
if active.0 != chip.0 {
|
||||
active.0 = chip.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds the open hub when [`ActiveYouTab`] changes.
|
||||
fn rebuild_on_tab_change(
|
||||
active: Res<ActiveYouTab>,
|
||||
screens: Query<Entity, With<YouHubScreen>>,
|
||||
mut commands: Commands,
|
||||
ctx: YouHubContext,
|
||||
) {
|
||||
if !active.is_changed() || active.is_added() {
|
||||
return;
|
||||
}
|
||||
if screens.is_empty() {
|
||||
return;
|
||||
}
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
spawn_you_hub(&mut commands, active.0, &ctx);
|
||||
}
|
||||
|
||||
/// Despawns the hub when Done is pressed.
|
||||
fn handle_close_button(
|
||||
interactions: Query<&Interaction, (Changed<Interaction>, With<YouHubCloseButton>)>,
|
||||
screens: Query<Entity, With<YouHubScreen>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
for interaction in &interactions {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
for entity in &screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the hub modal showing `tab`, and stamps the scrim with the
|
||||
/// tab's legacy screen marker so pre-hub queries keep working.
|
||||
fn spawn_you_hub(commands: &mut Commands, tab: YouTab, ctx: &YouHubContext) {
|
||||
let font_res = ctx.font_res.as_deref();
|
||||
let scrim = spawn_modal(commands, YouHubScreen, Z_MODAL_PANEL, |card| {
|
||||
spawn_modal_header(card, "You", font_res);
|
||||
|
||||
// Tab chips — shared widget with the Settings panel.
|
||||
card.spawn((
|
||||
FocusRow,
|
||||
Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
column_gap: VAL_SPACE_2,
|
||||
row_gap: VAL_SPACE_2,
|
||||
margin: UiRect::bottom(VAL_SPACE_3),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|row| {
|
||||
for chip_tab in YouTab::ALL {
|
||||
spawn_tab_chip(
|
||||
row,
|
||||
YouHubTabButton(chip_tab),
|
||||
chip_tab.label(),
|
||||
chip_tab == tab,
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
match tab {
|
||||
YouTab::Profile => crate::profile_plugin::spawn_profile_body(
|
||||
card,
|
||||
ctx.settings.as_deref(),
|
||||
ctx.sync_status.as_deref(),
|
||||
ctx.progress.as_deref(),
|
||||
ctx.achievements.as_deref(),
|
||||
ctx.stats.as_deref(),
|
||||
font_res,
|
||||
ctx.avatar.as_deref(),
|
||||
),
|
||||
YouTab::Stats => {
|
||||
let default_stats = StatsSnapshot::default();
|
||||
crate::stats_plugin::spawn_stats_body(
|
||||
card,
|
||||
ctx.stats.as_deref().map_or(&default_stats, |s| &s.0),
|
||||
ctx.progress.as_deref().map(|p| &p.0),
|
||||
ctx.time_attack.as_deref(),
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
YouTab::Achievements => crate::achievement_plugin::spawn_achievements_body(
|
||||
card,
|
||||
ctx.achievements
|
||||
.as_deref()
|
||||
.map_or(&[][..], |a| a.0.as_slice()),
|
||||
font_res,
|
||||
),
|
||||
YouTab::Replays => crate::stats_plugin::spawn_replays_body(
|
||||
card,
|
||||
ctx.replay_history
|
||||
.as_deref()
|
||||
.map_or(&[][..], |h| h.0.replays.as_slice()),
|
||||
ctx.selected_replay.as_deref().map_or(0, |s| s.0),
|
||||
font_res,
|
||||
),
|
||||
}
|
||||
|
||||
spawn_modal_actions(card, |actions| {
|
||||
spawn_modal_button(
|
||||
actions,
|
||||
YouHubCloseButton,
|
||||
"Done",
|
||||
None,
|
||||
ButtonVariant::Primary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
let mut scrim_commands = commands.entity(scrim);
|
||||
scrim_commands.insert(ScrimDismissible);
|
||||
// Legacy markers: pre-hub code and tests query these.
|
||||
match tab {
|
||||
YouTab::Profile => {
|
||||
scrim_commands.insert(ProfileScreen);
|
||||
}
|
||||
YouTab::Stats | YouTab::Replays => {
|
||||
scrim_commands.insert(StatsScreen);
|
||||
}
|
||||
YouTab::Achievements => {
|
||||
scrim_commands.insert(AchievementsScreen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(YouHubPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn hub_count(app: &mut App) -> usize {
|
||||
app.world_mut()
|
||||
.query::<&YouHubScreen>()
|
||||
.iter(app.world())
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Each Toggle*RequestEvent opens the hub pre-selected to its tab,
|
||||
/// stamped with the tab's legacy screen marker.
|
||||
#[test]
|
||||
fn toggle_events_open_hub_on_their_tab() {
|
||||
use bevy::ecs::message::Messages;
|
||||
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ToggleStatsRequestEvent>>()
|
||||
.write(ToggleStatsRequestEvent);
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
assert_eq!(hub_count(&mut app), 1, "stats request must open the hub");
|
||||
assert_eq!(app.world().resource::<ActiveYouTab>().0, YouTab::Stats);
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&StatsScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1,
|
||||
"legacy StatsScreen marker must ride the hub scrim"
|
||||
);
|
||||
|
||||
// A different tab's request switches in place — still one hub.
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ToggleAchievementsRequestEvent>>()
|
||||
.write(ToggleAchievementsRequestEvent);
|
||||
app.update();
|
||||
app.update();
|
||||
assert_eq!(hub_count(&mut app), 1, "tab switch must not stack hubs");
|
||||
assert_eq!(
|
||||
app.world().resource::<ActiveYouTab>().0,
|
||||
YouTab::Achievements
|
||||
);
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&AchievementsScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
// Same-tab request toggles the hub closed.
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ToggleAchievementsRequestEvent>>()
|
||||
.write(ToggleAchievementsRequestEvent);
|
||||
app.update();
|
||||
app.update();
|
||||
assert_eq!(hub_count(&mut app), 0, "same-tab request must close");
|
||||
}
|
||||
|
||||
/// Chip-driven tab switches rebuild the single hub with the new
|
||||
/// tab's body (Profile scrollable swaps for the Stats one).
|
||||
#[test]
|
||||
fn tab_switch_swaps_bodies_without_stacking() {
|
||||
use crate::profile_plugin::ProfileScrollable;
|
||||
use crate::stats_plugin::StatsScrollable;
|
||||
use bevy::ecs::message::Messages;
|
||||
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<ToggleProfileRequestEvent>>()
|
||||
.write(ToggleProfileRequestEvent);
|
||||
app.update();
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&ProfileScrollable>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1,
|
||||
"profile body must spawn on the Profile tab"
|
||||
);
|
||||
|
||||
app.world_mut().resource_mut::<ActiveYouTab>().0 = YouTab::Stats;
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
assert_eq!(hub_count(&mut app), 1, "rebuild must not stack scrims");
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&ProfileScrollable>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0,
|
||||
"profile body must despawn when leaving the tab"
|
||||
);
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&StatsScrollable>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1,
|
||||
"stats body must spawn on the Stats tab"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,15 @@ tower-http = { version = "0.6", features = ["fs"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
# Theme store: scans a directory of theme .zip archives at startup,
|
||||
# reading each archive's theme.ron manifest and hashing the bytes.
|
||||
ron = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod leaderboard;
|
||||
pub mod middleware;
|
||||
pub mod replays;
|
||||
pub mod sync;
|
||||
pub mod theme_store;
|
||||
|
||||
pub use auth::reset_password;
|
||||
|
||||
@@ -86,6 +87,10 @@ pub struct AppState {
|
||||
pub pool: SqlitePool,
|
||||
/// HS256 signing secret for JWT access and refresh tokens.
|
||||
pub jwt_secret: String,
|
||||
/// Theme-store catalog scanned once at startup.
|
||||
/// [`theme_store::ThemeStore::empty`] when the server runs without
|
||||
/// a store directory.
|
||||
pub theme_store: Arc<theme_store::ThemeStore>,
|
||||
}
|
||||
|
||||
/// Construct the full Axum [`Router`].
|
||||
@@ -125,9 +130,20 @@ pub async fn build_test_pool() -> SqlitePool {
|
||||
/// integration tests do not need to set `JWT_SECRET` in the environment.
|
||||
#[doc(hidden)]
|
||||
pub fn build_test_router(pool: SqlitePool) -> Router {
|
||||
build_test_router_with_theme_store(pool, theme_store::ThemeStore::empty())
|
||||
}
|
||||
|
||||
/// [`build_test_router`] with a caller-supplied theme store, for
|
||||
/// integration tests exercising the theme endpoints.
|
||||
#[doc(hidden)]
|
||||
pub fn build_test_router_with_theme_store(
|
||||
pool: SqlitePool,
|
||||
theme_store: theme_store::ThemeStore,
|
||||
) -> Router {
|
||||
let state = AppState {
|
||||
pool,
|
||||
jwt_secret: "test_secret_32_chars_minimum_ok!".to_string(),
|
||||
theme_store: Arc::new(theme_store),
|
||||
};
|
||||
build_router_inner(state, false)
|
||||
}
|
||||
@@ -195,6 +211,9 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
||||
.route("/api/daily-challenge", get(challenge::daily_challenge))
|
||||
.route("/api/replays/recent", get(replays::recent))
|
||||
.route("/api/replays/{id}", get(replays::get_by_id))
|
||||
.route("/api/themes", get(theme_store::list))
|
||||
.route("/api/themes/{id}/download", get(theme_store::download))
|
||||
.route("/api/themes/{id}/preview", get(theme_store::preview))
|
||||
.route("/health", get(health))
|
||||
.nest_service("/avatars", ServeDir::new("avatars"));
|
||||
|
||||
|
||||
@@ -31,12 +31,13 @@
|
||||
//! echo "new_password" | ./solitaire_server --reset-password alice
|
||||
//! ```
|
||||
|
||||
use solitaire_server::{AppState, build_router};
|
||||
use solitaire_server::{AppState, build_router, theme_store};
|
||||
use sqlx::{SqlitePool, sqlite::SqliteConnectOptions};
|
||||
use std::{
|
||||
io::{self, BufRead},
|
||||
net::SocketAddr,
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
const JWT_SECRET_MIN_BYTES: usize = 32;
|
||||
@@ -142,7 +143,19 @@ async fn run_server() {
|
||||
|
||||
tracing::info!("database ready at {db_url}");
|
||||
|
||||
let state = AppState { pool, jwt_secret };
|
||||
// Theme store: optional; a missing directory just means an empty
|
||||
// catalog. Scanned once — add themes, then restart to publish.
|
||||
let theme_store_dir = std::env::var(theme_store::THEME_STORE_DIR_ENV)
|
||||
.unwrap_or_else(|_| theme_store::DEFAULT_THEME_STORE_DIR.into());
|
||||
let theme_store = Arc::new(theme_store::ThemeStore::scan(std::path::Path::new(
|
||||
&theme_store_dir,
|
||||
)));
|
||||
|
||||
let state = AppState {
|
||||
pool,
|
||||
jwt_secret,
|
||||
theme_store,
|
||||
};
|
||||
let app = build_router(state);
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1614,6 +1614,7 @@ async fn auth_rate_limit_returns_429_on_11th_request() {
|
||||
let state = solitaire_server::AppState {
|
||||
pool: test_pool().await,
|
||||
jwt_secret: TEST_SECRET.to_string(),
|
||||
theme_store: std::sync::Arc::new(solitaire_server::theme_store::ThemeStore::empty()),
|
||||
};
|
||||
let app = solitaire_server::build_router(state);
|
||||
|
||||
@@ -1675,6 +1676,7 @@ async fn sync_push_rate_limit_returns_429_on_11th_request() {
|
||||
let state = solitaire_server::AppState {
|
||||
pool: test_pool().await,
|
||||
jwt_secret: TEST_SECRET.to_string(),
|
||||
theme_store: std::sync::Arc::new(solitaire_server::theme_store::ThemeStore::empty()),
|
||||
};
|
||||
let app = solitaire_server::build_router(state);
|
||||
|
||||
@@ -1879,3 +1881,125 @@ async fn replay_upload_malformed_body_returns_400() {
|
||||
let resp = post_authed(app, "/api/replays", &token, bad).await;
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Writes a minimal theme zip (manifest only — the catalog scan reads
|
||||
/// just the `meta` block) into `dir` and returns its path.
|
||||
fn write_store_zip(
|
||||
dir: &std::path::Path,
|
||||
file_name: &str,
|
||||
id: &str,
|
||||
name: &str,
|
||||
) -> std::path::PathBuf {
|
||||
use std::io::Write as _;
|
||||
let manifest = format!(
|
||||
r#"(
|
||||
meta: (
|
||||
id: "{id}",
|
||||
name: "{name}",
|
||||
author: "Test Author",
|
||||
version: "1.0.0",
|
||||
card_aspect: (2, 3),
|
||||
),
|
||||
faces: {{}},
|
||||
back: "back.svg",
|
||||
)"#
|
||||
);
|
||||
let path = dir.join(file_name);
|
||||
let file = std::fs::File::create(&path).expect("create zip");
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
writer.start_file("theme.ron", options).expect("start_file");
|
||||
writer
|
||||
.write_all(manifest.as_bytes())
|
||||
.expect("write manifest");
|
||||
writer.finish().expect("finish zip");
|
||||
path
|
||||
}
|
||||
|
||||
/// `GET /api/themes` returns the scanned catalog as JSON, no auth needed.
|
||||
#[tokio::test]
|
||||
async fn theme_catalog_lists_scanned_themes() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
|
||||
let store = solitaire_server::theme_store::ThemeStore::scan(dir.path());
|
||||
let app = solitaire_server::build_test_router_with_theme_store(test_pool().await, store);
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/api/themes")
|
||||
.body(Body::empty())
|
||||
.expect("build request");
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read body");
|
||||
let catalog: solitaire_sync::ThemeCatalogResponse =
|
||||
serde_json::from_slice(&body).expect("parse catalog");
|
||||
assert_eq!(catalog.themes.len(), 1);
|
||||
let entry = &catalog.themes[0];
|
||||
assert_eq!(entry.id, "neon");
|
||||
assert_eq!(entry.name, "Neon");
|
||||
assert_eq!(entry.download_url, "/api/themes/neon/download");
|
||||
assert_eq!(entry.preview_url, None);
|
||||
let zip_bytes = std::fs::read(&zip_path).expect("read zip");
|
||||
assert_eq!(entry.size_bytes, zip_bytes.len() as u64);
|
||||
}
|
||||
|
||||
/// The download endpoint streams back exactly the bytes on disk, so a
|
||||
/// client hashing the response gets the catalog's sha256.
|
||||
#[tokio::test]
|
||||
async fn theme_download_returns_archive_bytes() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
|
||||
let store = solitaire_server::theme_store::ThemeStore::scan(dir.path());
|
||||
let app = solitaire_server::build_test_router_with_theme_store(test_pool().await, store);
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/api/themes/neon/download")
|
||||
.body(Body::empty())
|
||||
.expect("build request");
|
||||
let resp = app.oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("application/zip")
|
||||
);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read body");
|
||||
assert_eq!(&body[..], &std::fs::read(&zip_path).expect("read zip")[..]);
|
||||
}
|
||||
|
||||
/// Unknown ids 404 on both file endpoints; a theme without preview art
|
||||
/// 404s on `/preview` while still serving its download.
|
||||
#[tokio::test]
|
||||
async fn theme_unknown_id_and_missing_preview_return_404() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
|
||||
let store = solitaire_server::theme_store::ThemeStore::scan(dir.path());
|
||||
let app = solitaire_server::build_test_router_with_theme_store(test_pool().await, store);
|
||||
|
||||
for uri in [
|
||||
"/api/themes/nope/download",
|
||||
"/api/themes/nope/preview",
|
||||
"/api/themes/neon/preview",
|
||||
] {
|
||||
let req = Request::builder()
|
||||
.uri(uri)
|
||||
.body(Body::empty())
|
||||
.expect("build request");
|
||||
let resp = app.clone().oneshot(req).await.expect("oneshot");
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"expected 404 for {uri}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1649,62 +1649,62 @@ function __wbg_get_imports() {
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 61868, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 62028, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7314, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7312, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7472, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7313, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7473, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c);
|
||||
return ret;
|
||||
},
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -10,5 +10,8 @@ uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -10,11 +10,13 @@ pub mod achievements;
|
||||
pub mod merge;
|
||||
pub mod progress;
|
||||
pub mod stats;
|
||||
pub mod theme_store;
|
||||
|
||||
pub use achievements::AchievementRecord;
|
||||
pub use merge::{merge, merge_at};
|
||||
pub use progress::{PlayerProgress, level_for_xp};
|
||||
pub use stats::StatsSnapshot;
|
||||
pub use theme_store::{ThemeCatalogEntry, ThemeCatalogResponse};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -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
@@ -19,11 +19,14 @@
|
||||
//! is the contract.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::{KlondikeInstruction, KlondikePile};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||
use solitaire_core::error::MoveError;
|
||||
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||
use solitaire_core::{
|
||||
DrawStockConfig,
|
||||
game_state::{GameMode, GameState},
|
||||
};
|
||||
use solitaire_core::{KlondikeInstruction, KlondikePile};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Mirrors `solitaire_data::Replay` v3.
|
||||
@@ -145,8 +148,8 @@ impl ReplayPlayer {
|
||||
let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
|
||||
self.game.pile(t).iter().map(CardSnapshot::from).collect()
|
||||
};
|
||||
let foundations: [Vec<CardSnapshot>; 4] = solitaire_core::FOUNDATIONS
|
||||
.map(|f| pile_cards(KlondikePile::Foundation(f)));
|
||||
let foundations: [Vec<CardSnapshot>; 4] =
|
||||
solitaire_core::FOUNDATIONS.map(|f| pile_cards(KlondikePile::Foundation(f)));
|
||||
let tableaus: [Vec<CardSnapshot>; 7] =
|
||||
solitaire_core::TABLEAUS.map(|t| pile_cards(KlondikePile::Tableau(t)));
|
||||
StateSnapshot {
|
||||
@@ -342,8 +345,7 @@ fn legal_moves_for_game(game: &GameState) -> Vec<DebugMove> {
|
||||
fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport {
|
||||
let stock = game.stock_cards();
|
||||
let waste = game.waste_cards();
|
||||
let foundations =
|
||||
solitaire_core::FOUNDATIONS.map(|f| game.pile(KlondikePile::Foundation(f)));
|
||||
let foundations = solitaire_core::FOUNDATIONS.map(|f| game.pile(KlondikePile::Foundation(f)));
|
||||
let tableaus = solitaire_core::TABLEAUS.map(|t| game.pile(KlondikePile::Tableau(t)));
|
||||
|
||||
let mut seen: std::collections::HashSet<Card> = std::collections::HashSet::new();
|
||||
@@ -403,7 +405,8 @@ fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> Deb
|
||||
false
|
||||
});
|
||||
|
||||
let soft_lock = !game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty();
|
||||
let soft_lock =
|
||||
!game.is_won() && stock.is_empty() && waste.is_empty() && legal_moves.is_empty();
|
||||
|
||||
let state_ok = duplicate_cards.is_empty()
|
||||
&& missing_cards.is_empty()
|
||||
@@ -465,8 +468,7 @@ impl SolitaireGame {
|
||||
.iter()
|
||||
.map(CardSnapshot::from)
|
||||
.collect(),
|
||||
foundations: solitaire_core::FOUNDATIONS
|
||||
.map(|f| cards(KlondikePile::Foundation(f))),
|
||||
foundations: solitaire_core::FOUNDATIONS.map(|f| cards(KlondikePile::Foundation(f))),
|
||||
tableaus: solitaire_core::TABLEAUS.map(|t| cards(KlondikePile::Tableau(t))),
|
||||
}
|
||||
}
|
||||
@@ -888,7 +890,9 @@ mod tests {
|
||||
}
|
||||
let idx = pick_move_index(&legal_moves).unwrap_or_default();
|
||||
if let Err(e) = game.apply_legal_move_native(idx) {
|
||||
panic!("failed to advance game before replay export (seed={seed}, step={step}, idx={idx}): {e}");
|
||||
panic!(
|
||||
"failed to advance game before replay export (seed={seed}, step={step}, idx={idx}): {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1073,9 +1077,14 @@ mod tests {
|
||||
let stock_before = game.game.stock_cards().len();
|
||||
let waste_before = game.game.waste_cards().len();
|
||||
|
||||
assert!(stock_before > 0, "seed {seed}: stock must be non-empty at start");
|
||||
assert!(
|
||||
stock_before > 0,
|
||||
"seed {seed}: stock must be non-empty at start"
|
||||
);
|
||||
|
||||
game.game.draw().expect("draw must succeed when stock is non-empty");
|
||||
game.game
|
||||
.draw()
|
||||
.expect("draw must succeed when stock is non-empty");
|
||||
|
||||
assert_eq!(
|
||||
game.game.stock_cards().len(),
|
||||
@@ -1104,7 +1113,9 @@ mod tests {
|
||||
"seed {seed}: stock must have at least 3 cards for this test"
|
||||
);
|
||||
|
||||
game.game.draw().expect("draw must succeed when stock has cards");
|
||||
game.game
|
||||
.draw()
|
||||
.expect("draw must succeed when stock has cards");
|
||||
|
||||
let expected_drawn = stock_before.min(3);
|
||||
assert_eq!(
|
||||
|
||||
@@ -41,8 +41,7 @@ pub fn start() {
|
||||
// texture-dimension limit is now taken from the adapter (see
|
||||
// the RenderPlugin below), so this is purely a quality/perf
|
||||
// choice, no longer a crash-avoidance hack.
|
||||
resolution: WindowResolution::default()
|
||||
.with_scale_factor_override(1.0),
|
||||
resolution: WindowResolution::default().with_scale_factor_override(1.0),
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
|
||||
Reference in New Issue
Block a user