Compare commits

...

8 Commits

Author SHA1 Message Date
funman300 7b59e70192 feat(engine): theme registry + discovery (Card theme phase 6)
CI / Test & Lint (push) Failing after 8s
CI / Release Build (push) Has been skipped
Implements Phase 6 of CARD_PLAN.md — discovers every available card
theme on startup so the future picker UI can list them.

solitaire_engine/src/theme/registry.rs
  ThemeEntry { id, display_name, manifest_url, meta }
  ThemeRegistry — Resource holding the entries; provides
    find(id), iter(), len(), is_empty().
  ThemeRegistryPlugin — Startup system that scans
    user_theme_dir() and populates the registry.
  build_registry(user_dir) — pure helper; takes the dir as a
    parameter so tests use tempfile::tempdir() without touching
    the global OnceLock-based user-theme path.
  refresh_registry(&mut, user_dir) — replaces in-place; called
    after a successful import_theme so a freshly-imported theme
    appears in the picker without an app restart.

The bundled default entry is always inserted (id "default", served
from DEFAULT_THEME_MANIFEST_URL) so the picker has at least one
option even when no user themes exist.

Discovery is best-effort: a directory whose theme.ron is missing,
malformed, or fails ThemeMeta::validate is silently skipped — broken
themes don't poison the registry. Only the meta block is parsed
(via a derive(Deserialize) struct that ignores other manifest
fields), which keeps startup quick even with dozens of themes
installed.

Wired into solitaire_app/main.rs after ThemePlugin so the asset
sources are registered before discovery scans for theme.ron files.

10 new tests covering: empty user dir, nonexistent user dir, valid
user theme registers, full-manifest tolerance via meta-only parser,
malformed theme.ron skipped, invalid-meta theme skipped, directory
without theme.ron ignored, find() returns None for unknown id,
refresh_registry replaces stale entries, default-entry URL matches
the embedded constant.

cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (960 passed, 0 failed, 9 ignored).
2026-05-01 06:04:34 +00:00
funman300 7f477b4ad8 feat(engine): ThemePlugin + ActiveTheme integration (Card theme phase 4)
Implements Phase 4 of CARD_PLAN.md — the runtime hook that loads the
default theme on startup and refreshes the card-rendering pipeline
whenever the active theme changes.

solitaire_engine/src/theme/plugin.rs
  ThemePlugin
    init_asset::<CardTheme>, register_asset_loader for SvgLoader and
    CardThemeLoader, Startup load_default_theme, and Update
    sync_card_image_set_with_active_theme.
  ActiveTheme(Handle<CardTheme>)
    Resource pointing at the currently-loaded theme.
  set_theme(commands, asset_server, theme_id)
    Public API for switching themes — formats the URL as
    `themes://<theme_id>/theme.ron` and updates the resource.

Integration approach: rather than refactor every `card_plugin.rs`
spawn site to read from `Assets<CardTheme>` directly, the sync system
writes the theme's face/back image handles into the existing
`CardImageSet` resource on `AssetEvent::LoadedWithDependencies` /
`Modified`, then fires `StateChangedEvent`. The existing
`sync_cards_on_change` pipeline rebuilds card sprites from the new
handles on the next tick — observable behaviour matches the plan's
intent (theme switches propagate immediately) while keeping
card_plugin's 1929-line surface area untouched.

Theme.back is mapped onto `CardImageSet.backs[0]` (the default-back
slot xCards previously occupied); `backs[1..=4]` are the
asset-generator patterns and remain user-selectable independent of
the active theme.

Added to solitaire_app/main.rs as `add_plugins(ThemePlugin)` after
`AssetSourcesPlugin` so the asset sources are registered before the
default-theme load is dispatched.

6 new tests covering suit/rank index mapping (matching the
`card_plugin` doc-commented `[suit][rank]` layout), empty-theme
no-panic, back-slot overwrite, and the URL format from `set_theme`.

cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (950 passed, 0 failed, 9 ignored).
2026-05-01 05:59:28 +00:00
funman300 ce38b26721 feat(engine): theme zip importer with safety validation (Card theme phase 7)
Implements Phase 7 of CARD_PLAN.md — the entry point that takes a
user-supplied theme zip archive, validates it end-to-end, and
atomically unpacks it into the per-platform user themes directory.

Public API:
  import_theme(zip_path) -> Result<ThemeId, ImportError>
    Resolves user_theme_dir() and unpacks into <user>/<id>/.
  import_theme_into(zip_path, target_root) -> Result<ThemeId, ImportError>
    Test-friendly variant that takes the destination explicitly so
    unit tests never touch the global OnceLock override.

Safety guarantees enforced:
- 20 MB hard cap on archive size (read from the central directory
  before any extraction).
- Zip-slip path traversal rejected via ZipFile::enclosed_name plus a
  Component::Normal-only belt-and-braces check.
- Manifest parsed via ron::de and validated via the existing
  ThemeManifest::validate (Phase 2) — surfaces named diagnostics for
  missing-of-52, unknown keys, duplicate keys, and meta errors.
- Every referenced face + back rasterised through rasterize_svg as a
  structural validity check before any bytes hit the destination.
- Atomic install: writes to <root>/.<id>.tmp/ then std::fs::rename
  into place, with a recursive copy + remove fallback for cross-
  device renames. Failed extraction wipes the staging dir; the user
  themes root is never touched on error.
- Id collision with an existing theme dir rejected up front.

7 new tests covering the happy path plus six failure modes (missing
manifest, missing face, oversized archive, zip-slip, missing-file,
id collision). Tests build zips in tempfile::TempDir so they never
touch the real user themes directory.

Workspace deps: zip 8.6 (default-features off + deflate only),
tempfile 3.27 (dev only).

cargo check --workspace --all-targets / clippy --workspace
--all-targets -- -D warnings clean. cargo test could not be run in
this turn because cc disappeared from the sandbox; tests compile
under cargo check --tests and will run on a normal toolchain.
2026-05-01 05:47:30 +00:00
funman300 172d7773f0 feat(engine): asset sources for embedded + user theme dirs (Card theme phase 3)
Implements Phase 3 of CARD_PLAN.md — the embedded:// + themes:// asset
sources the card-theme system loads from. The bundled default-theme
manifest ships in the binary via Bevy's EmbeddedAssetRegistry; user
themes load from user_theme_dir() through a FileAssetReader-backed
source registered as `themes://`.

Registration is split across:
  register_theme_asset_sources(&mut App)
    Called BEFORE DefaultPlugins. Registers `themes://` while
    AssetSourceBuilders is still mutable.
  AssetSourcesPlugin
    Added AFTER DefaultPlugins. Populates the EmbeddedAssetRegistry
    that AssetPlugin's build step would otherwise overwrite.

Constants exposed for downstream consumers:
  USER_THEMES                 = "themes"   (asset-source name)
  DEFAULT_THEME_MANIFEST_URL  = "embedded://solitaire_engine/assets/themes/default/theme.ron"

Includes a stub default theme.ron (52 face slots + back) so
`ThemeManifest::validate()` accepts it today; PROVENANCE.md documents
the plan to drop in real SVG art (hayeah/playing-cards-assets) in a
follow-up.

4 new tests covering source registration, embedded-registry
population, manifest validation against the embedded stub, and the
manifest-URL constant matching the embedded asset path.

cargo check --workspace --all-targets / clippy --workspace
--all-targets -- -D warnings clean. cargo test could not be run in
this turn because the C linker (cc) is unexpectedly absent from the
sandbox; the test bodies compile cleanly under cargo check --tests
and will run on a normal toolchain.
2026-05-01 05:47:13 +00:00
funman300 205ad6f646 feat(engine): per-platform user-theme directory (Card theme phase 5)
Implements Phase 5 of CARD_PLAN.md. Phase 3 (asset sources) and
Phase 7 (zip importer) both depend on this so it goes first.

solitaire_engine/src/assets/user_dir.rs
  user_theme_dir() -> PathBuf
    Desktop (Linux/macOS/Windows): joins dirs::data_dir() with
    "solitaire_quest/themes" — same parent as the rest of the
    project's per-user files (settings.json, stats.json, etc.)
    Mobile (Android/iOS): reads a process-wide OnceLock populated
    by set_user_theme_dir() at entry-point bootstrap. Panics with a
    targeted message if the override is missing — there is no
    platform default we can guess that won't be wrong inside iOS
    sandboxing or the Android storage model.
  set_user_theme_dir(PathBuf) -> Result<(), PathBuf>
    First-write-wins. Mobile entry points call this before App::run().

The plan suggested the `directories` crate; reused the existing `dirs`
workspace dep instead to keep the dependency surface minimal — both
crates share an author and the platform behaviour we need is identical.

3 new tests covering pure path composition (desktop nesting + empty
root) and a desktop-target-gated check that the detected data dir is
absolute. The OnceLock override is intentionally not unit-tested
because asserting its semantics would pollute global state for any
sibling test that calls `user_theme_dir()`.
2026-05-01 05:25:21 +00:00
funman300 936d035750 feat(engine): CardTheme asset + manifest loader (Card theme phase 2)
Implements Phase 2 of CARD_PLAN.md — the data types and `.theme.ron`
asset loader that build on Phase 1's SVG rasteriser.

solitaire_engine/src/theme/
  mod.rs        — CardKey { suit, rank } as the HashMap lookup key
                  (distinct from solitaire_core::Card which carries
                  per-deal id + face_up state); CardKey::all() yields
                  the 52 keys in suit-major / rank-ascending order;
                  manifest_name() and parse_manifest_name() round-trip
                  via the canonical "{suit}_{rank}" form.
                  ThemeMeta with structural validation (id non-empty,
                  no path separators, non-zero aspect components).
                  CardTheme #[derive(Asset, TypePath)] storing the
                  53 image handles + meta.
  manifest.rs   — ThemeManifest { meta, back, faces } with serde for
                  RON round-trip. validate() returns a strongly-typed
                  HashMap<CardKey, PathBuf>, surfacing precise errors
                  for unknown face keys, missing-of-52 entries, and
                  duplicate keys (RON silently keeps the last; brittle
                  for a release).
  loader.rs     — AssetLoader for .theme.ron. Validates manifest, then
                  composes sibling SVG paths via AssetPath::resolve so
                  the same loader works for both embedded:// and
                  themes:// asset sources (Phase 3 territory).
                  Schedules every face + back load through SvgLoader
                  with target_size derived from meta.card_aspect.

24 new tests covering: 52-key enumeration uniqueness, manifest-name
round trip, garbage-name rejection, complete/missing/unknown/duplicate
manifest validation, RON round-trip integrity, target-size aspect
math (2:3 → 512x768; non-standard; degenerate 1:10000 clamps to 1px).

Workspace deps added: ron 0.12.

cargo build / clippy --workspace --all-targets -- -D warnings / test
all green (937 passed total — +24 from Phase 2 vs the +7 from
Phase 1's b8fb3fb baseline).
2026-05-01 05:19:12 +00:00
funman300 13d1d013e9 chore: route rustc through sccache for cold-build wins
Adds .cargo/config.toml setting `rustc-wrapper = "sccache"` so cold
rebuilds (CI, fresh checkouts, post-`cargo clean`) replay previously-
compiled crates from disk instead of recompiling. Warm incremental
builds are unaffected — cargo's own target/ cache dominates there.

Cache lives at `.sccache-cache/` inside the project (gitignored). The
[env] entry uses `force = false` so a developer-set $SCCACHE_DIR in
their shell wins, matching whichever directory the sccache daemon
already adopted.

Requires sccache on PATH. Install: `pacman -S sccache`,
`brew install sccache`, or `cargo install sccache --locked`. Bypass
without editing: `RUSTC_WRAPPER= cargo build`.
2026-05-01 05:15:59 +00:00
funman300 b8fb3fbd6e feat(engine): SVG → Image asset loader (Card theme phase 1)
Implements the runtime SVG rasterisation pipeline that the card-theme
system (CARD_PLAN.md) is built on. Bevy 0.18 has no native SVG support;
this loader bridges usvg (parser) + resvg (renderer) + tiny-skia (CPU
pixmap) so the rest of the engine consumes themes as plain
Handle<Image>. Rasterisation happens once per (asset, settings) pair at
load time — Bevy's asset cache absorbs the cost.

solitaire_engine/src/assets/
  mod.rs           — module entrypoint
  svg_loader.rs    — SvgLoader (AssetLoader for .svg → Image)
                     SvgLoaderSettings { target_size: UVec2 } default 512×768
                     SvgLoaderError (Io / Parse / PixmapAlloc) via thiserror
                     rasterize_svg() helper exposed for non-asset-graph
                     callers (the future zip-importer validation step)

The rasteriser scales-to-fit while preserving aspect ratio, centring
the SVG inside the target box so a non-2:3 source doesn't pin to the
top-left corner.

7 new unit tests — default + custom target size, zero-dimension reject,
malformed-input reject, RGBA byte-count, extension advertisement, and
a compile-time guard that SvgLoaderSettings still satisfies the
AssetLoader::Settings trait bounds.

Workspace deps added: usvg 0.47, resvg 0.47, tiny-skia 0.12 (latest
minor versions; CARD_PLAN.md called out the placeholder numbers
needed verification).

cargo build / cargo clippy --workspace --all-targets -- -D warnings
/ cargo test --workspace all green (913 passed, 0 failed, 9 ignored —
+7 from the new loader tests).
2026-05-01 05:05:30 +00:00
19 changed files with 3172 additions and 20 deletions
+31
View File
@@ -0,0 +1,31 @@
# Project-wide cargo configuration.
#
# Routes every rustc invocation through `sccache` so cold rebuilds and
# fresh checkouts (CI, new dev box, after a `cargo clean`) replay
# previously-compiled crates from a local on-disk cache rather than
# recompiling them. Warm incremental builds still go through cargo's
# own `target/` cache, which dominates locally — sccache buys you the
# big wins on cold paths.
#
# Requires sccache on PATH. Install it once per machine:
#
# Arch : pacman -S sccache
# macOS : brew install sccache
# Cargo : cargo install sccache --locked
#
# Without sccache the build fails with "rustc-wrapper not found". To
# bypass this config without editing the file, prepend
# `RUSTC_WRAPPER= ` (empty value) to your cargo command:
#
# RUSTC_WRAPPER= cargo build
#
[build]
rustc-wrapper = "sccache"
# Project-local cache so the shared dev box (or a Docker volume) keeps
# the artefacts isolated per checkout instead of mixing them in
# `~/.cache/sccache`. Set with `force = false` so a developer-set
# `SCCACHE_DIR` in their shell wins — important because the sccache
# daemon, once started, sticks with whichever directory it saw first.
[env]
SCCACHE_DIR = { value = ".sccache-cache", relative = true, force = false }
+1
View File
@@ -1,4 +1,5 @@
/target
/.sccache-cache
*.db
*.db-shm
*.db-wal
Generated
+294 -14
View File
@@ -2429,6 +2429,12 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -2950,6 +2956,12 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "datasketches"
version = "0.2.0"
@@ -3477,8 +3489,15 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
name = "float-cmp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
[[package]]
name = "flume"
version = "0.11.1"
@@ -3532,7 +3551,7 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree",
"roxmltree 0.20.0",
]
[[package]]
@@ -3851,6 +3870,16 @@ dependencies = [
"polyval",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "gilrs"
version = "0.11.1"
@@ -4529,6 +4558,22 @@ dependencies = [
"png 0.18.1",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "imagesize"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -4856,6 +4901,17 @@ dependencies = [
"bitflags 2.11.1",
]
[[package]]
name = "kurbo"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
dependencies = [
"arrayvec",
"euclid",
"smallvec",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -6011,15 +6067,6 @@ dependencies = [
"libredox",
]
[[package]]
name = "ordered-float"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
dependencies = [
"num-traits",
]
[[package]]
name = "ordered-float"
version = "5.3.0"
@@ -6165,6 +6212,12 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "pin-project"
version = "1.1.11"
@@ -6428,6 +6481,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.39.2"
@@ -6798,6 +6857,23 @@ dependencies = [
"web-sys",
]
[[package]]
name = "resvg"
version = "0.47.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81"
dependencies = [
"gif",
"image-webp",
"log",
"pico-args",
"rgb",
"svgtypes",
"tiny-skia 0.12.0",
"usvg",
"zune-jpeg",
]
[[package]]
name = "rfc6979"
version = "0.4.0"
@@ -6808,6 +6884,15 @@ dependencies = [
"subtle",
]
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
dependencies = [
"bytemuck",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -6862,6 +6947,15 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "roxmltree"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]]
name = "rpassword"
version = "7.5.0"
@@ -7068,6 +7162,24 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rustybuzz"
version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"
dependencies = [
"bitflags 2.11.1",
"bytemuck",
"core_maths",
"log",
"smallvec",
"ttf-parser",
"unicode-bidi-mirroring",
"unicode-ccc",
"unicode-properties",
"unicode-script",
]
[[package]]
name = "ruzstd"
version = "0.8.2"
@@ -7123,7 +7235,7 @@ dependencies = [
"log",
"memmap2",
"smithay-client-toolkit",
"tiny-skia",
"tiny-skia 0.11.4",
]
[[package]]
@@ -7382,6 +7494,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "simplecss"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
dependencies = [
"log",
]
[[package]]
name = "simsimd"
version = "6.5.16"
@@ -7557,12 +7678,21 @@ dependencies = [
"async-trait",
"bevy",
"chrono",
"dirs",
"kira",
"resvg",
"ron",
"serde",
"solitaire_core",
"solitaire_data",
"solitaire_sync",
"tempfile",
"thiserror 2.0.18",
"tiny-skia 0.12.0",
"tokio",
"usvg",
"uuid",
"zip",
]
[[package]]
@@ -7860,6 +7990,9 @@ name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
dependencies = [
"float-cmp",
]
[[package]]
name = "stringprep"
@@ -7912,6 +8045,16 @@ version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb"
[[package]]
name = "svgtypes"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d"
dependencies = [
"kurbo",
"siphasher",
]
[[package]]
name = "swash"
version = "0.2.7"
@@ -8226,7 +8369,7 @@ checksum = "dfadb8526b6da90704feb293b0701a6aae62ea14983143344be2dc5ce30f1d82"
dependencies = [
"fnv",
"nom",
"ordered-float 5.3.0",
"ordered-float",
"serde",
"serde_json",
]
@@ -8383,7 +8526,22 @@ dependencies = [
"bytemuck",
"cfg-if",
"log",
"tiny-skia-path",
"tiny-skia-path 0.11.4",
]
[[package]]
name = "tiny-skia"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"png 0.18.1",
"tiny-skia-path 0.12.0",
]
[[package]]
@@ -8397,6 +8555,17 @@ dependencies = [
"strict-num",
]
[[package]]
name = "tiny-skia-path"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
name = "tinystr"
version = "0.8.3"
@@ -8947,6 +9116,12 @@ dependencies = [
"rand 0.9.4",
]
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typeid"
version = "1.0.3"
@@ -9015,6 +9190,18 @@ version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-bidi-mirroring"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
[[package]]
name = "unicode-ccc"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -9054,6 +9241,12 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-vo"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
[[package]]
name = "unicode-width"
version = "0.1.14"
@@ -9094,6 +9287,34 @@ dependencies = [
"serde",
]
[[package]]
name = "usvg"
version = "0.47.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de"
dependencies = [
"base64",
"data-url",
"flate2",
"fontdb",
"imagesize",
"kurbo",
"log",
"pico-args",
"roxmltree 0.21.1",
"rustybuzz",
"simplecss",
"siphasher",
"strict-num",
"svgtypes",
"tiny-skia-path 0.12.0",
"ttf-parser",
"unicode-bidi",
"unicode-script",
"unicode-vo",
"xmlwriter",
]
[[package]]
name = "utf8-ranges"
version = "1.0.5"
@@ -9453,6 +9674,12 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "wgpu"
version = "27.0.1"
@@ -9571,7 +9798,7 @@ dependencies = [
"ndk-sys 0.6.0+11769913",
"objc",
"once_cell",
"ordered-float 4.6.0",
"ordered-float",
"parking_lot",
"portable-atomic",
"portable-atomic-util",
@@ -10500,6 +10727,12 @@ version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]]
name = "xmlwriter"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "yazi"
version = "0.2.1"
@@ -10701,12 +10934,44 @@ dependencies = [
"syn",
]
[[package]]
name = "zip"
version = "8.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"typed-path",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
@@ -10735,6 +11000,21 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "5.10.1"
+24
View File
@@ -38,6 +38,30 @@ solitaire_engine = { path = "solitaire_engine" }
bevy = "0.18"
kira = "0.12"
# SVG rasterisation pipeline for the runtime card-theme system.
# usvg parses + simplifies; resvg renders to a tiny-skia Pixmap;
# tiny-skia provides the CPU rasteriser. All three are maintained
# together by the resvg-rs project and version in lockstep.
usvg = "0.47"
resvg = "0.47"
tiny-skia = "0.12"
# Theme manifest format. RON keeps the file human-editable while
# preserving Rust-style structures the importer can validate.
ron = "0.12"
# Importer-only: reads user-supplied theme zip archives, validates
# their contents, and unpacks them into the user themes directory.
# Default features are disabled to keep the dependency footprint small;
# only `deflate` is needed because the importer rejects other
# compression methods anyway (see Phase 7 spec).
zip = { version = "8.6", default-features = false, features = ["deflate"] }
# Importer-only test dependency: tests build zip archives in a
# scratch directory so they don't pollute the real user themes path
# on the developer's machine.
tempfile = "3.27"
axum = "0.8"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
jsonwebtoken = { version = "10", default-features = false, features = ["rust_crypto"] }
+20 -6
View File
@@ -6,11 +6,12 @@ use bevy::prelude::*;
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiFocusPlugin,
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin,
SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, UiFocusPlugin,
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
};
@@ -54,7 +55,17 @@ fn main() {
),
};
App::new()
let mut app = App::new();
// The card-theme system's `themes://` asset source must be
// registered *before* `DefaultPlugins` builds `AssetPlugin`,
// because that plugin freezes the asset-source list at build
// time. The matching `AssetSourcesPlugin` (added below) finishes
// the wiring after `DefaultPlugins` by populating the embedded
// default theme into Bevy's `EmbeddedAssetRegistry`.
register_theme_asset_sources(&mut app);
app
.add_plugins(
DefaultPlugins
.set(WindowPlugin {
@@ -91,6 +102,9 @@ fn main() {
..default()
}),
)
.add_plugins(AssetSourcesPlugin)
.add_plugins(ThemePlugin)
.add_plugins(ThemeRegistryPlugin)
.add_plugins(FontPlugin)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
+9
View File
@@ -13,6 +13,15 @@ solitaire_sync = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
usvg = { workspace = true }
resvg = { workspace = true }
tiny-skia = { workspace = true }
ron = { workspace = true }
dirs = { workspace = true }
zip = { workspace = true }
[dev-dependencies]
async-trait = { workspace = true }
tempfile = { workspace = true }
@@ -0,0 +1,43 @@
# Default theme — provenance
This directory is the bundled-default card theme that ships embedded in
the binary via Bevy's `embedded_asset!` macro (see
`solitaire_engine/src/assets/sources.rs`). At runtime its files are
addressable as `embedded://solitaire_engine/assets/themes/default/...`.
## Current state (Phase 3)
The `theme.ron` manifest in this directory lists all 52 face slots plus
a back slot, but **the referenced SVG files do not yet exist**. The
manifest is intentionally a stub so that:
1. `embedded_asset!` has a real file to bundle (the manifest itself).
2. `ThemeManifest::validate` accepts the manifest (it requires all 52
faces to be listed by name).
3. The `embedded://` asset source can be source-registered and queried
without runtime errors during Phase 3.
The actual SVG art will be added when the project swaps in the
`hayeah/playing-cards-assets` artwork — see the implementation plan in
`/CARD_PLAN.md`. At that point, every `.svg` filename listed in
`theme.ron`'s `faces` map (and `back.svg`) must be added here, and each
new file needs a corresponding `embedded_asset!(app, ...)` call in
`solitaire_engine/src/assets/sources.rs::register_default_theme`.
## How to add files to the bundled default theme
For each new file you drop into this directory:
1. Drop the file under `solitaire_engine/assets/themes/default/`.
2. Add one line to `register_default_theme` in
`solitaire_engine/src/assets/sources.rs` of the form:
```rust
embedded_asset!(app, "../../assets/themes/default/<filename>");
```
(The path is relative to `sources.rs`, which lives in
`solitaire_engine/src/assets/`.)
3. Update this file with the licence and origin of the new asset.
## Licence
To be filled in once real artwork lands.
@@ -0,0 +1,77 @@
// Default card theme manifest — Phase 3 stub.
//
// The 53 SVG paths below are deliberate placeholders so the manifest
// validates against `ThemeManifest::validate` (which requires all 52
// faces plus a back). The actual SVG art lands in a later phase when
// the swap to hayeah/playing-cards-assets is complete; until then this
// file exists so the `embedded://` asset source has something to
// register, and so source-registration tests have a real RON file to
// parse.
//
// Suit / rank tokens follow `CardKey::manifest_name`:
// suits: clubs, diamonds, hearts, spades
// ranks: ace, 2, 3, 4, 5, 6, 7, 8, 9, 10, jack, queen, king
(
meta: (
id: "default",
name: "Default",
author: "Solitaire Quest",
version: "0.1.0",
card_aspect: (2, 3),
),
back: "back.svg",
faces: {
"clubs_ace": "clubs_ace.svg",
"clubs_2": "clubs_2.svg",
"clubs_3": "clubs_3.svg",
"clubs_4": "clubs_4.svg",
"clubs_5": "clubs_5.svg",
"clubs_6": "clubs_6.svg",
"clubs_7": "clubs_7.svg",
"clubs_8": "clubs_8.svg",
"clubs_9": "clubs_9.svg",
"clubs_10": "clubs_10.svg",
"clubs_jack": "clubs_jack.svg",
"clubs_queen": "clubs_queen.svg",
"clubs_king": "clubs_king.svg",
"diamonds_ace": "diamonds_ace.svg",
"diamonds_2": "diamonds_2.svg",
"diamonds_3": "diamonds_3.svg",
"diamonds_4": "diamonds_4.svg",
"diamonds_5": "diamonds_5.svg",
"diamonds_6": "diamonds_6.svg",
"diamonds_7": "diamonds_7.svg",
"diamonds_8": "diamonds_8.svg",
"diamonds_9": "diamonds_9.svg",
"diamonds_10": "diamonds_10.svg",
"diamonds_jack": "diamonds_jack.svg",
"diamonds_queen": "diamonds_queen.svg",
"diamonds_king": "diamonds_king.svg",
"hearts_ace": "hearts_ace.svg",
"hearts_2": "hearts_2.svg",
"hearts_3": "hearts_3.svg",
"hearts_4": "hearts_4.svg",
"hearts_5": "hearts_5.svg",
"hearts_6": "hearts_6.svg",
"hearts_7": "hearts_7.svg",
"hearts_8": "hearts_8.svg",
"hearts_9": "hearts_9.svg",
"hearts_10": "hearts_10.svg",
"hearts_jack": "hearts_jack.svg",
"hearts_queen": "hearts_queen.svg",
"hearts_king": "hearts_king.svg",
"spades_ace": "spades_ace.svg",
"spades_2": "spades_2.svg",
"spades_3": "spades_3.svg",
"spades_4": "spades_4.svg",
"spades_5": "spades_5.svg",
"spades_6": "spades_6.svg",
"spades_7": "spades_7.svg",
"spades_8": "spades_8.svg",
"spades_9": "spades_9.svg",
"spades_10": "spades_10.svg",
"spades_jack": "spades_jack.svg",
"spades_queen": "spades_queen.svg",
"spades_king": "spades_king.svg",
},
)
+18
View File
@@ -0,0 +1,18 @@
//! Asset-loading infrastructure for runtime SVG rasterisation and the
//! per-platform user-themes directory.
//!
//! See `CARD_PLAN.md` for the full multi-phase implementation plan.
//! This module is the entry point for Phases 1 (SVG → `Image`) and 5
//! (user-themes directory). Phase 3 will extend it further with custom
//! `AssetSource` implementations for `embedded://` and `themes://`.
pub mod sources;
pub mod svg_loader;
pub mod user_dir;
pub use sources::{
populate_embedded_default_theme, register_theme_asset_sources, AssetSourcesPlugin,
DEFAULT_THEME_MANIFEST_URL, USER_THEMES,
};
pub use svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings};
pub use user_dir::{set_user_theme_dir, user_theme_dir};
+221
View File
@@ -0,0 +1,221 @@
//! Custom Bevy asset sources for the card-theme system.
//!
//! Two sources are wired up here:
//!
//! - **`embedded://`** — the bundled default theme. The default theme
//! manifest (and, in later phases, every default-theme SVG) is
//! compiled into the binary via `include_bytes!` and inserted into
//! Bevy's [`EmbeddedAssetRegistry`] under a stable, pretty path.
//! The default theme manifest is reachable as
//! `embedded://solitaire_engine/assets/themes/default/theme.ron`.
//!
//! - **`themes://`** — user-supplied themes living in
//! [`crate::assets::user_dir::user_theme_dir`]. Reads delegate to
//! `FileAssetReader` rooted at that absolute path. If the directory
//! doesn't exist yet (the common case on first run), the source
//! still registers cleanly — individual reads simply return
//! `NotFound` until the player drops a theme there, which is the
//! correct behaviour for an empty user-themes directory.
//!
//! # Why two registration paths?
//!
//! Bevy treats the two sources differently:
//!
//! - **`themes://`** is a *new* source the engine doesn't know about.
//! It must be registered with [`App::register_asset_source`] *before*
//! `AssetPlugin` runs, because that plugin freezes the source list
//! when it builds the `AssetServer`.
//!
//! - **`embedded://`** is already registered by `AssetPlugin` itself
//! (via `EmbeddedAssetRegistry::register_source`). What we have to
//! do is *populate* the registry with our default-theme files —
//! exactly what Bevy's own `embedded_asset!` macro does. That
//! population must happen *after* `AssetPlugin` runs, because
//! `AssetPlugin::build` overwrites the `EmbeddedAssetRegistry`
//! resource with a fresh empty one as part of its own setup.
//!
//! These two timing constraints can't be satisfied by a single
//! `Plugin::build` call, so the public API splits the work into:
//!
//! 1. [`register_theme_asset_sources`] — call *before* `DefaultPlugins`,
//! typically immediately after `App::new()`. Registers `themes://`.
//! 2. [`AssetSourcesPlugin`] — add *after* `DefaultPlugins`. Populates
//! the embedded default-theme files into Bevy's already-built
//! `EmbeddedAssetRegistry`.
//!
//! Both must run for the card-theme system to function. The doc
//! comments on each call out the pairing so a future reader doesn't
//! accidentally drop one half.
use bevy::asset::io::embedded::EmbeddedAssetRegistry;
use bevy::asset::io::file::FileAssetReader;
use bevy::asset::io::AssetSourceBuilder;
use bevy::asset::AssetApp;
use bevy::prelude::*;
use crate::assets::user_dir::user_theme_dir;
/// `AssetSourceId` of the user-themes asset source. Use it as
/// `themes://<theme_id>/theme.ron` from any code that wants to load
/// from the user-themes directory.
pub const USER_THEMES: &str = "themes";
/// Stable embedded asset URL of the bundled default theme manifest.
///
/// Code that wants to load the embedded default — including the future
/// Phase 4 `ActiveTheme` initialisation — should use exactly this
/// constant rather than re-typing the URL inline. Changing where the
/// default theme lives in the asset graph then becomes a single-line
/// change in this file.
pub const DEFAULT_THEME_MANIFEST_URL: &str =
"embedded://solitaire_engine/assets/themes/default/theme.ron";
/// Path the embedded default-theme manifest registers under, relative
/// to the `embedded://` source root. Kept in lockstep with
/// [`DEFAULT_THEME_MANIFEST_URL`] by the unit test
/// `default_theme_url_constant_matches_embedded_path`.
const DEFAULT_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/default/theme.ron";
/// Bytes of the bundled default theme manifest. Embedded at compile
/// time via `include_bytes!` so the binary is self-contained even if
/// the workspace's `solitaire_engine/assets/` directory is absent at
/// runtime (e.g. when shipped to a player).
const DEFAULT_THEME_MANIFEST_BYTES: &[u8] =
include_bytes!("../../assets/themes/default/theme.ron");
/// Registers asset sources that must be in place *before*
/// `AssetPlugin` is built.
///
/// In practice that means just `themes://`: `embedded://` is owned by
/// Bevy itself and is always registered by `AssetPlugin`. To finish
/// wiring up the embedded default theme, also add
/// [`AssetSourcesPlugin`] *after* `DefaultPlugins`.
///
/// Returns the `&mut App` so the call can be chained from the binary
/// entry point.
pub fn register_theme_asset_sources(app: &mut App) -> &mut App {
let root = user_theme_dir();
app.register_asset_source(
USER_THEMES,
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))),
);
app
}
/// Bevy `Plugin` that pushes the bundled default theme files into
/// [`EmbeddedAssetRegistry`].
///
/// Add this *after* `DefaultPlugins`. It pairs with
/// [`register_theme_asset_sources`] (which has to run *before*
/// `DefaultPlugins`); both are required for the card-theme system to
/// function. See the module-level doc comment for why the work has to
/// be split across two calls.
///
/// To bundle additional default-theme files (the SVG art slated to
/// land in a later phase), edit [`populate_embedded_default_theme`].
#[derive(Debug, Default)]
pub struct AssetSourcesPlugin;
impl Plugin for AssetSourcesPlugin {
fn build(&self, app: &mut App) {
populate_embedded_default_theme(app);
}
}
/// Pushes every bundled default-theme file into the
/// [`EmbeddedAssetRegistry`] under its stable URL. Keeping this in a
/// free function (and not inside the `Plugin::build` body) means the
/// unit test below can exercise it without spinning up a full Bevy
/// `App` with `AssetPlugin`.
///
/// **Adding files to the bundled default theme** is a single edit
/// per file: add an `include_bytes!` constant that points at the file
/// under `solitaire_engine/assets/themes/default/`, then add a
/// matching `registry.insert_asset(...)` call here. Keep the
/// `asset_path` argument exactly the relative path that the manifest
/// references (e.g. `solitaire_engine/assets/themes/default/back.svg`).
pub fn populate_embedded_default_theme(app: &mut App) {
let registry = app
.world_mut()
.get_resource_or_insert_with(EmbeddedAssetRegistry::default);
// `full_path` is only consulted by the optional
// `embedded_watcher` cargo feature (which we don't enable). Use
// the manifest's logical workspace path so a future debugger
// session sees a sensible source-of-truth string.
registry.insert_asset(
std::path::PathBuf::from(DEFAULT_THEME_MANIFEST_PATH),
std::path::Path::new(DEFAULT_THEME_MANIFEST_PATH),
DEFAULT_THEME_MANIFEST_BYTES,
);
}
#[cfg(test)]
mod tests {
use super::*;
/// `register_theme_asset_sources` must register `themes://`
/// without panicking, even when `user_theme_dir()` resolves to a
/// directory that doesn't exist on disk — Bevy's
/// `FileAssetReader` constructs lazily, so a missing root is
/// fine.
#[test]
fn register_theme_asset_sources_inserts_themes_source() {
let mut app = App::new();
register_theme_asset_sources(&mut app);
let mut sources = app
.world_mut()
.get_resource_or_init::<bevy::asset::io::AssetSourceBuilders>();
assert!(
sources.get_mut(USER_THEMES).is_some(),
"themes:// source not registered"
);
}
/// `populate_embedded_default_theme` must work as a drop-in step
/// regardless of whether `EmbeddedAssetRegistry` already exists,
/// so it can be called both from `AssetSourcesPlugin::build`
/// (after `AssetPlugin` initialised it) and from this test (which
/// uses the resource's `get_resource_or_insert_with` fallback).
#[test]
fn populate_embedded_default_theme_runs_without_asset_plugin() {
let mut app = App::new();
populate_embedded_default_theme(&mut app);
// Resource exists and has been inserted into.
assert!(app
.world()
.get_resource::<EmbeddedAssetRegistry>()
.is_some());
}
/// The bundled default theme stub must satisfy
/// `ThemeManifest::validate` — otherwise the embedded source
/// would register a manifest the loader will then reject at
/// runtime.
#[test]
fn embedded_default_theme_manifest_validates() {
use crate::theme::ThemeManifest;
let manifest: ThemeManifest = ron::de::from_bytes(DEFAULT_THEME_MANIFEST_BYTES)
.expect("default manifest must parse as RON");
let faces = manifest
.validate()
.expect("default manifest must list all 52 faces");
assert_eq!(faces.len(), 52);
}
/// Belt-and-braces: if anyone edits `DEFAULT_THEME_MANIFEST_PATH`
/// without updating `DEFAULT_THEME_MANIFEST_URL` (or vice versa)
/// the asset would register at one path and be loaded from
/// another. Pin them together in the test suite so any drift
/// fails CI.
#[test]
fn default_theme_url_constant_matches_embedded_path() {
let url_tail = DEFAULT_THEME_MANIFEST_URL
.strip_prefix("embedded://")
.expect("default theme URL must use embedded:// scheme");
assert_eq!(url_tail, DEFAULT_THEME_MANIFEST_PATH);
}
}
+207
View File
@@ -0,0 +1,207 @@
//! Bevy `AssetLoader` that rasterises an SVG into `bevy::image::Image`.
//!
//! The card-theme system (see `CARD_PLAN.md`) ships SVG sources both as
//! the embedded default theme and as user-supplied themes. Bevy 0.18 has
//! no built-in SVG support, so this loader bridges `usvg` (parser) +
//! `resvg` (renderer) + `tiny-skia` (CPU pixmap) to produce textures
//! that the rest of the engine consumes as plain `Handle<Image>` — no
//! awareness of vector graphics leaks past this boundary.
//!
//! Rasterisation happens once per (asset, settings) pair at load time.
//! Bevy's asset system caches the resulting `Image`, so the cost is paid
//! exactly once per theme switch, not per frame.
//!
//! # Settings
//!
//! Each `Handle<Image>` produced via this loader carries
//! [`SvgLoaderSettings`]. The most important field is `target_size` —
//! callers should specify the rasterisation resolution explicitly when
//! loading via `load_with_settings(...)`. The default of 512×768 is a
//! safe fallback that fits a typical 2:3 playing card.
use bevy::asset::io::Reader;
use bevy::asset::{AssetLoader, LoadContext, RenderAssetUsages};
use bevy::image::Image;
use bevy::math::UVec2;
use bevy::reflect::TypePath;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Per-asset settings consumed by [`SvgLoader::load`].
///
/// `target_size` controls the rasterisation resolution. SVG content is
/// scaled uniformly to fit this box while preserving aspect ratio.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct SvgLoaderSettings {
/// Output texture dimensions in pixels.
pub target_size: UVec2,
}
impl Default for SvgLoaderSettings {
fn default() -> Self {
// 512×768 is a 2:3 aspect at a resolution that stays sharp on
// typical desktop windows where individual cards never exceed
// ~250 px wide. Callers that need higher fidelity should
// override via `load_with_settings`.
Self {
target_size: UVec2::new(512, 768),
}
}
}
/// Errors surfaced by [`SvgLoader::load`].
#[derive(Debug, Error)]
pub enum SvgLoaderError {
/// The asset reader failed before the SVG bytes were consumed.
#[error("io: {0}")]
Io(#[from] std::io::Error),
/// `usvg` rejected the input as malformed or unsupported.
#[error("svg parse: {0}")]
Parse(#[from] usvg::Error),
/// `tiny_skia::Pixmap::new` returned `None` — typically because the
/// requested target_size is zero or absurdly large.
#[error("could not allocate pixmap of size {0}x{1}")]
PixmapAlloc(u32, u32),
}
/// `AssetLoader` registered for the `.svg` extension.
///
/// Stateless; safe to construct via `Default` and register once at
/// startup with `app.register_asset_loader(SvgLoader)`.
#[derive(Debug, Default, TypePath)]
pub struct SvgLoader;
impl AssetLoader for SvgLoader {
type Asset = Image;
type Settings = SvgLoaderSettings;
type Error = SvgLoaderError;
async fn load(
&self,
reader: &mut dyn Reader,
settings: &Self::Settings,
_load_context: &mut LoadContext<'_>,
) -> Result<Image, Self::Error> {
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes).await?;
rasterize_svg(&bytes, settings.target_size)
}
fn extensions(&self) -> &[&str] {
&["svg"]
}
}
/// Rasterises an SVG byte buffer into an `Image` of exactly
/// `target.x × target.y` pixels. Content is scaled uniformly to fit
/// while preserving aspect ratio; unused area is left transparent.
///
/// Exposed separately from the `AssetLoader` impl so callers (tests,
/// the Phase 7 zip importer's "is this a valid SVG?" check, future
/// thumbnail generators) can rasterise without going through the
/// asset graph.
pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoaderError> {
let opt = usvg::Options::default();
let tree = usvg::Tree::from_data(svg_bytes, &opt)?;
let svg_size = tree.size();
let svg_w = svg_size.width();
let svg_h = svg_size.height();
let target_w = target.x as f32;
let target_h = target.y as f32;
// Scale-to-fit while preserving aspect — the smaller axis ratio wins
// so the entire SVG is visible inside the target box.
let scale = (target_w / svg_w).min(target_h / svg_h);
let mut pixmap = tiny_skia::Pixmap::new(target.x, target.y)
.ok_or(SvgLoaderError::PixmapAlloc(target.x, target.y))?;
// Centre the scaled SVG inside the target box so any aspect-ratio
// mismatch is balanced rather than pinned to the top-left corner.
let dx = (target_w - svg_w * scale) * 0.5;
let dy = (target_h - svg_h * scale) * 0.5;
let transform = tiny_skia::Transform::from_scale(scale, scale).post_translate(dx, dy);
resvg::render(&tree, transform, &mut pixmap.as_mut());
Ok(Image::new(
Extent3d {
width: target.x,
height: target.y,
depth_or_array_layers: 1,
},
TextureDimension::D2,
pixmap.take(),
TextureFormat::Rgba8UnormSrgb,
RenderAssetUsages::default(),
))
}
#[cfg(test)]
mod tests {
use super::*;
/// Minimal but non-trivial SVG: yellow rectangle + dark circle.
/// Embedded inline so tests have no filesystem dependencies. The
/// `##` raw-string delimiter lets us inline `#`-prefixed hex colours.
const TEST_SVG: &[u8] = br##"<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300" width="200" height="300">
<rect x="0" y="0" width="200" height="300" fill="#FFD23F"/>
<circle cx="100" cy="150" r="80" fill="#1A0F2E"/>
</svg>"##;
#[test]
fn rasterizes_at_default_size() {
let settings = SvgLoaderSettings::default();
let image = rasterize_svg(TEST_SVG, settings.target_size).expect("rasterisation");
assert_eq!(image.size().x, 512);
assert_eq!(image.size().y, 768);
}
#[test]
fn rasterizes_at_custom_size() {
let image = rasterize_svg(TEST_SVG, UVec2::new(64, 96)).expect("rasterisation");
assert_eq!(image.size().x, 64);
assert_eq!(image.size().y, 96);
}
#[test]
fn rejects_zero_dimension() {
let err = rasterize_svg(TEST_SVG, UVec2::new(0, 100)).unwrap_err();
assert!(matches!(err, SvgLoaderError::PixmapAlloc(0, 100)));
}
#[test]
fn rejects_malformed_svg() {
let err = rasterize_svg(b"not actually svg", UVec2::new(64, 96)).unwrap_err();
assert!(matches!(err, SvgLoaderError::Parse(_)));
}
#[test]
fn pixmap_data_is_rgba_with_target_byte_count() {
let image =
rasterize_svg(TEST_SVG, UVec2::new(32, 48)).expect("rasterisation");
let pixels = image.data.as_ref().expect("rasterised image carries pixel data");
// 32 × 48 × 4 (RGBA bytes) = 6144 bytes
assert_eq!(pixels.len(), 32 * 48 * 4);
}
#[test]
fn loader_advertises_svg_extension() {
let loader = SvgLoader;
assert_eq!(loader.extensions(), &["svg"]);
}
/// Compile-time guard that `SvgLoaderSettings` satisfies the trait
/// bounds Bevy expects on `AssetLoader::Settings` — keeps the
/// loader's `#[derive]` set honest if the upstream signature ever
/// tightens.
#[test]
fn settings_satisfies_loader_bounds() {
fn assert_loader_settings<T: Default + serde::Serialize + serde::de::DeserializeOwned>() {}
assert_loader_settings::<SvgLoaderSettings>();
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Per-platform resolution of the user-themes directory.
//!
//! The path is determined exactly once and exposed via
//! [`user_theme_dir`]. On desktop platforms it is derived from
//! `dirs::data_dir()` (matching the rest of the project's
//! per-app-storage convention); on mobile it must be supplied by the
//! platform entry point via [`set_user_theme_dir`] before any code
//! that needs the path executes — there is deliberately no silent
//! fallback because mobile sandboxing makes any guess we'd hard-code
//! wrong.
//!
//! # Why panic instead of returning Result?
//!
//! User-theme resolution is bootstrap-time configuration, not game
//! logic, so per CLAUDE.md panics are acceptable here. Returning
//! `Result` would force every caller (the registry, the asset source,
//! the importer) to plumb an error through systems that have no
//! recovery path: there is no useful state to display if we can't
//! find the user themes directory at all.
use std::path::PathBuf;
use std::sync::OnceLock;
/// Override slot populated by mobile entry points (Android's
/// `android_main`, iOS's launch handler) before the Bevy `App` starts.
/// Desktop platforms ignore the override and fall through to
/// [`desktop_theme_dir`].
static USER_THEME_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
/// Sub-folder under `dirs::data_dir()` where the project keeps every
/// per-user file. Matches the existing convention used by
/// `solitaire_data` for `settings.json`, `stats.json`, etc.
const APP_DIR_NAME: &str = "solitaire_quest";
/// Sub-folder under [`APP_DIR_NAME`] dedicated to user themes.
const THEME_DIR_NAME: &str = "themes";
/// Sets the user-themes directory at runtime — mobile-only API.
///
/// Returns `Err` containing the rejected path if the override has
/// already been set. The first caller wins and subsequent calls are
/// silently a no-op-with-feedback so a mis-configured embedder can't
/// flip the path mid-session.
///
/// On desktop platforms this is functional but unnecessary —
/// [`user_theme_dir`] derives the path from `dirs::data_dir` directly
/// and ignores the override. Setting it on desktop is harmless but
/// nearly always a sign of confusion.
pub fn set_user_theme_dir(path: PathBuf) -> Result<(), PathBuf> {
USER_THEME_DIR_OVERRIDE.set(path)
}
/// Returns the absolute path of the user-themes directory on the
/// current platform.
///
/// # Panics
///
/// Panics on:
///
/// - Desktop, if `dirs::data_dir()` returns `None` (rare; usually
/// indicates a broken `$HOME` or `$XDG_*` configuration).
/// - Mobile, if no entry point has called [`set_user_theme_dir`] yet.
/// - Any other target, where the embedder is required to supply the
/// path manually.
///
/// The panic message names the missing piece so the failure is
/// immediately actionable.
pub fn user_theme_dir() -> PathBuf {
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
return p.clone();
}
user_theme_dir_for(detected_platform_data_dir())
}
/// Composition helper that takes the platform data dir as input so the
/// pure path-joining behaviour is unit-testable without depending on
/// the user's actual `$HOME`.
fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
data_dir.join(APP_DIR_NAME).join(THEME_DIR_NAME)
}
/// Per-target-os resolution of the platform's data dir. Split out so
/// mobile branches can grow without disturbing desktop behaviour.
fn detected_platform_data_dir() -> PathBuf {
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
{
dirs::data_dir().unwrap_or_else(|| {
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()."
)
})
}
#[cfg(any(target_os = "android", target_os = "ios"))]
{
panic!(
"user_theme_dir(): mobile entry point must call \
solitaire_engine::assets::user_dir::set_user_theme_dir() \
before App::run() — there is no platform default."
)
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "android",
target_os = "ios"
)))]
{
panic!(
"user_theme_dir(): unsupported platform; call \
solitaire_engine::assets::user_dir::set_user_theme_dir() \
from your entry point before App::run()."
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_theme_dir_for_appends_solitaire_quest_themes() {
let dir = user_theme_dir_for(PathBuf::from("/tmp/data"));
assert_eq!(
dir,
PathBuf::from("/tmp/data/solitaire_quest/themes"),
"user dir must nest under solitaire_quest/themes"
);
}
#[test]
fn user_theme_dir_for_handles_empty_root() {
let dir = user_theme_dir_for(PathBuf::new());
assert_eq!(dir, PathBuf::from("solitaire_quest/themes"));
}
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
#[test]
fn detected_data_dir_yields_a_path_with_a_parent() {
// On every supported desktop platform the OS reports a
// user-writable data directory; the test machine already has
// one for `dirs::data_dir()` to discover. We don't pin the
// exact value because it depends on the user's $HOME, but it
// must at least be a non-empty path with a parent component.
let dir = detected_platform_data_dir();
assert!(dir.parent().is_some(), "data dir {dir:?} should be absolute");
}
// The OnceLock-based override is intentionally NOT covered here:
// setting it once would pollute every subsequent test in the
// process that called `user_theme_dir()`. The override's
// first-write-wins semantics come from `std::sync::OnceLock` which
// is already well-tested upstream; the behaviour we add on top is
// a trivial early-return that's covered by code review.
}
+10
View File
@@ -1,5 +1,6 @@
//! Bevy integration layer for Solitaire Quest.
pub mod assets;
pub mod card_animation;
pub mod achievement_plugin;
pub mod animation_plugin;
@@ -30,6 +31,7 @@ pub mod splash_plugin;
pub mod stats_plugin;
pub mod sync_plugin;
pub mod table_plugin;
pub mod theme;
pub mod time_attack_plugin;
pub mod ui_focus;
pub mod ui_modal;
@@ -38,6 +40,14 @@ pub mod ui_tooltip;
pub mod weekly_goals_plugin;
pub mod win_summary_plugin;
pub use assets::{
populate_embedded_default_theme, register_theme_asset_sources, AssetSourcesPlugin,
DEFAULT_THEME_MANIFEST_URL, USER_THEMES,
};
pub use theme::{
set_theme, ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry,
ThemeRegistryPlugin,
};
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
pub use challenge_plugin::{
challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL,
+752
View File
@@ -0,0 +1,752 @@
//! Theme zip-archive importer.
//!
//! Phase 7 of the card-theme system (see `CARD_PLAN.md`). Players ship
//! and install third-party themes as a single `.zip` containing a
//! `theme.ron` manifest at the archive root plus the 52 face SVGs and
//! one back SVG referenced by that manifest. [`import_theme`] is the
//! one-shot entry point: it opens the zip, validates structurally,
//! then atomically unpacks into [`crate::assets::user_theme_dir`] —
//! never touching the user's themes directory unless every check
//! passes.
//!
//! # Safety guarantees
//!
//! - **Hard size cap.** The total declared archive size must not
//! exceed 20 MB. The cap is checked from the central directory
//! (i.e. before any extraction) so a zip-bomb cannot run us out of
//! memory or disk just by pretending to be small.
//! - **Zip-slip immune.** Every entry path is normalised and rejected
//! if it contains `..`, an absolute prefix, or any non-`Normal`
//! component. We never trust the OS to clamp `..` for us.
//! - **Manifest-driven.** Only paths the manifest declares are
//! extracted; every face/back path is also rasterised through
//! [`crate::assets::rasterize_svg`] as a structural validity check
//! so corrupt SVGs surface here, not in the asset graph.
//! - **Atomic install.** Extraction goes to a sibling temp directory
//! that's renamed into place only after every byte has been
//! written. A failed import leaves the user themes dir untouched.
//! - **No id collision.** If the manifest's `meta.id` already names a
//! directory in the user themes root, we abort before writing.
//! Replacing an existing theme is a deliberate user action handled
//! by Phase 6's registry, not a side effect of importing.
//!
//! # Testing hook
//!
//! Tests target [`import_theme_into`] directly so they can pass a
//! `tempfile::TempDir` as the destination root. The public
//! [`import_theme`] is a thin wrapper that resolves the destination
//! via [`crate::assets::user_theme_dir`].
use std::fs::{self, File};
use std::io::{self, Read};
use std::path::{Component, Path, PathBuf};
use thiserror::Error;
use bevy::math::UVec2;
use crate::assets::{rasterize_svg, user_theme_dir, SvgLoaderError};
use super::manifest::{ManifestError, ThemeManifest};
use super::ThemeMetaError;
/// Hard cap on the *uncompressed* total of all archive entries. Set
/// generously high relative to a realistic 53-SVG theme (~12 MB at
/// most for vector content) but firmly below anything we'd risk
/// extracting blind.
pub const MAX_ARCHIVE_BYTES: u64 = 20 * 1024 * 1024;
/// Tiny rasterisation target used purely to validate SVG structure.
/// The actual asset loader picks a real size at load time; we just
/// need `usvg` + `resvg` to accept the bytes here.
const SVG_VALIDATION_SIZE: UVec2 = UVec2::new(64, 96);
/// Filename of the manifest at the archive root. Must match what
/// `CardThemeLoader::extensions()` advertises so the same artefact
/// works for both import and load.
const MANIFEST_NAME: &str = "theme.ron";
/// Strongly-typed wrapper around a successfully imported theme's
/// manifest id. Consumers (notably the Phase 6 registry refresh)
/// receive this so they can route directly to the new theme without
/// re-parsing the manifest off disk.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThemeId(pub String);
impl ThemeId {
/// Borrow the underlying id as `&str` for path joining and lookups.
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Errors surfaced by [`import_theme`] / [`import_theme_into`].
///
/// Each variant pinpoints exactly which check rejected the archive
/// — the importer never lumps unrelated failures into a single
/// generic error so the caller can render a precise message in the UI.
#[derive(Debug, Error)]
pub enum ImportError {
/// The zip file could not be opened or its central directory was
/// unreadable. `zip::result::ZipError` covers both I/O failures
/// and structurally corrupt archives.
#[error("could not open zip archive: {0}")]
OpenArchive(#[from] zip::result::ZipError),
/// Filesystem failure outside of zip parsing (creating the temp
/// dir, writing extracted bytes, the final rename, etc).
#[error("io error: {0}")]
Io(#[from] io::Error),
/// The archive's declared total uncompressed size exceeds
/// [`MAX_ARCHIVE_BYTES`]. Checked *before* extraction.
#[error(
"archive declares {total} uncompressed bytes, exceeds the {limit}-byte limit"
)]
Oversized { total: u64, limit: u64 },
/// No `theme.ron` at the archive root.
#[error("archive does not contain `theme.ron` at the root")]
MissingManifest,
/// `theme.ron` present but couldn't be parsed as RON.
#[error("manifest parse (RON): {0}")]
ManifestParse(#[from] ron::error::SpannedError),
/// Manifest parsed but failed structural validation. Wraps the
/// 52-faces / unknown-key / duplicate-key diagnostics from
/// [`super::manifest`].
#[error("manifest validation: {0}")]
Validation(#[from] ManifestError),
/// Manifest's `meta` block failed validation in isolation. Kept
/// distinct from the above so callers can branch on metadata-only
/// problems (id shape, aspect zero, etc.) when wanted.
#[error("manifest meta: {0}")]
Meta(#[from] ThemeMetaError),
/// A face or back path declared in the manifest is not present in
/// the zip's entry list.
#[error("manifest references file not present in archive: {missing}")]
MissingFile { missing: String },
/// One of the referenced SVGs failed to rasterise — `usvg`
/// rejected it, the bytes were truncated, etc.
#[error("invalid SVG content for {path}: {source}")]
InvalidSvg {
path: String,
#[source]
source: SvgLoaderError,
},
/// A zip entry's normalised path escapes the archive root (zip
/// slip): contains `..`, is absolute, or names something other
/// than a normal file component.
#[error("zip-slip path traversal attempt: {path}")]
ZipSlip { path: String },
/// The manifest's `meta.id` already names a directory under the
/// user themes root.
#[error("a theme with id {id:?} is already installed")]
IdCollision { id: String },
}
/// Imports a theme zip into the per-platform user themes directory
/// resolved by [`crate::assets::user_theme_dir`].
///
/// Returns the imported theme's manifest id on success. The Phase 6
/// registry is responsible for refreshing its in-memory list — this
/// function only writes to disk.
///
/// See the module-level docs for the full safety contract.
pub fn import_theme(zip_path: &Path) -> Result<ThemeId, ImportError> {
import_theme_into(zip_path, &user_theme_dir())
}
/// Same as [`import_theme`] but takes the destination root explicitly.
///
/// Tests use this directly with a `tempfile::TempDir` so they can
/// exercise the full extraction path without touching the global
/// [`crate::assets::user_dir::set_user_theme_dir`] override.
pub fn import_theme_into(
zip_path: &Path,
target_root: &Path,
) -> Result<ThemeId, ImportError> {
let file = File::open(zip_path)?;
let mut archive = zip::ZipArchive::new(file)?;
enforce_archive_size_limit(&mut archive)?;
enforce_zip_slip_safe(&mut archive)?;
// Parse + validate the manifest in-memory. Anything below this
// line works against a known-good `ThemeManifest`.
let manifest = read_manifest(&mut archive)?;
let face_paths = manifest.validate()?;
// Confirm every referenced SVG is in the archive AND structurally
// valid before we commit to writing anything to disk.
let mut required: Vec<PathBuf> = face_paths.values().cloned().collect();
required.push(manifest.back.clone());
for path in &required {
let bytes = read_archive_entry(&mut archive, path)?;
rasterize_svg(&bytes, SVG_VALIDATION_SIZE).map_err(|source| {
ImportError::InvalidSvg {
path: path.to_string_lossy().into_owned(),
source,
}
})?;
}
let id = manifest.meta.id.clone();
let final_dir = target_root.join(&id);
if final_dir.exists() {
return Err(ImportError::IdCollision { id });
}
// Stage the extraction in a sibling temp dir so a partial write
// never reaches `final_dir`. We rename on success; on failure
// we wipe the staging dir without ever touching `final_dir`.
fs::create_dir_all(target_root)?;
let staging = target_root.join(format!(".{id}.tmp"));
if staging.exists() {
// Leftover from a previous crashed import; safe to remove
// because it never made the rename.
fs::remove_dir_all(&staging)?;
}
fs::create_dir_all(&staging)?;
let extract_result = (|| -> Result<(), ImportError> {
// Always extract the manifest under its canonical name.
write_archive_entry(&mut archive, MANIFEST_NAME, &staging)?;
for path in &required {
write_archive_entry_pathbuf(&mut archive, path, &staging)?;
}
Ok(())
})();
match extract_result {
Ok(()) => match install_atomic(&staging, &final_dir) {
Ok(()) => Ok(ThemeId(id)),
Err(e) => {
// Best-effort cleanup; preserve the original error.
let _ = fs::remove_dir_all(&staging);
Err(e)
}
},
Err(e) => {
let _ = fs::remove_dir_all(&staging);
Err(e)
}
}
}
/// Sums every entry's declared uncompressed size and rejects archives
/// that overflow [`MAX_ARCHIVE_BYTES`]. Iterates the central
/// directory only — does not actually decompress anything.
fn enforce_archive_size_limit<R: io::Read + io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Result<(), ImportError> {
let mut total: u64 = 0;
for i in 0..archive.len() {
let entry = archive.by_index_raw(i)?;
total = total.saturating_add(entry.size());
if total > MAX_ARCHIVE_BYTES {
return Err(ImportError::Oversized {
total,
limit: MAX_ARCHIVE_BYTES,
});
}
}
Ok(())
}
/// Walks every entry name and rejects the archive if any path
/// (after normalisation) escapes its root. Catches `..`, absolute
/// paths, drive prefixes on Windows, and the awkward case where
/// `enclosed_name` returns `None` because the entry is suspicious.
fn enforce_zip_slip_safe<R: io::Read + io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Result<(), ImportError> {
for i in 0..archive.len() {
let entry = archive.by_index_raw(i)?;
let name = entry.name().to_owned();
let normalised = entry
.enclosed_name()
.ok_or_else(|| ImportError::ZipSlip { path: name.clone() })?;
if !is_safe_relative_path(&normalised) {
return Err(ImportError::ZipSlip { path: name });
}
}
Ok(())
}
/// True iff `p` is a relative path consisting only of `Normal`
/// components — no root, no prefix, no `.` or `..`. The zip crate's
/// `enclosed_name` already strips most attacks; this is belt and
/// braces.
fn is_safe_relative_path(p: &Path) -> bool {
if p.is_absolute() {
return false;
}
p.components()
.all(|c| matches!(c, Component::Normal(_)))
}
/// Reads `theme.ron` from the archive root and parses it.
///
/// Errors:
/// - [`ImportError::MissingManifest`] when the archive has no
/// `theme.ron` entry at its root.
/// - [`ImportError::ManifestParse`] when the bytes don't form valid
/// RON for `ThemeManifest`.
fn read_manifest<R: io::Read + io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Result<ThemeManifest, ImportError> {
// We can't use `?` directly across `by_name` because a missing
// file is a domain-level error here, not a generic ZipError.
let bytes = match archive.by_name(MANIFEST_NAME) {
Ok(mut entry) => {
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf)?;
buf
}
Err(zip::result::ZipError::FileNotFound) => {
return Err(ImportError::MissingManifest);
}
Err(e) => return Err(ImportError::OpenArchive(e)),
};
let manifest: ThemeManifest = ron::de::from_bytes(&bytes)?;
Ok(manifest)
}
/// Reads a manifest-declared entry's bytes by `Path`, normalising the
/// separators so a manifest authored on Windows still resolves on
/// Unix and vice versa.
///
/// Returns [`ImportError::MissingFile`] when the archive has no entry
/// matching the path.
fn read_archive_entry<R: io::Read + io::Seek>(
archive: &mut zip::ZipArchive<R>,
path: &Path,
) -> Result<Vec<u8>, ImportError> {
let key = archive_key(path);
let mut entry = match archive.by_name(&key) {
Ok(e) => e,
Err(zip::result::ZipError::FileNotFound) => {
return Err(ImportError::MissingFile { missing: key });
}
Err(e) => return Err(ImportError::OpenArchive(e)),
};
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf)?;
Ok(buf)
}
/// Joins manifest-declared sub-paths with the archive's root using
/// forward slashes — zip uses `/` on every platform.
fn archive_key(path: &Path) -> String {
path.components()
.filter_map(|c| match c {
Component::Normal(s) => s.to_str(),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
/// Extracts a single entry under the staging directory, creating any
/// parent directories as needed. The destination path is rebuilt from
/// the safe components we already vetted in
/// [`enforce_zip_slip_safe`], not from the raw entry name.
fn write_archive_entry<R: io::Read + io::Seek>(
archive: &mut zip::ZipArchive<R>,
name: &str,
staging: &Path,
) -> Result<(), ImportError> {
let mut entry = match archive.by_name(name) {
Ok(e) => e,
Err(zip::result::ZipError::FileNotFound) => {
return Err(ImportError::MissingFile {
missing: name.to_owned(),
});
}
Err(e) => return Err(ImportError::OpenArchive(e)),
};
let safe = entry
.enclosed_name()
.ok_or_else(|| ImportError::ZipSlip {
path: name.to_owned(),
})?;
if !is_safe_relative_path(&safe) {
return Err(ImportError::ZipSlip {
path: name.to_owned(),
});
}
let dest = staging.join(&safe);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
let mut out = File::create(&dest)?;
io::copy(&mut entry, &mut out)?;
Ok(())
}
/// Variant of [`write_archive_entry`] keyed by `Path` for the
/// manifest-declared face/back paths.
fn write_archive_entry_pathbuf<R: io::Read + io::Seek>(
archive: &mut zip::ZipArchive<R>,
path: &Path,
staging: &Path,
) -> Result<(), ImportError> {
let key = archive_key(path);
write_archive_entry(archive, &key, staging)
}
/// Promotes the staging directory to its final location.
///
/// `fs::rename` is atomic when both paths share a filesystem; if not
/// (e.g. `/tmp` on a tmpfs vs. the user data dir on disk) we fall
/// back to a recursive copy + remove so the import still completes.
fn install_atomic(staging: &Path, final_dir: &Path) -> Result<(), ImportError> {
match fs::rename(staging, final_dir) {
Ok(()) => Ok(()),
Err(rename_err) => {
// EXDEV (cross-device link) is the canonical case for
// falling back; other rename errors are surfaced after
// the fallback so the user sees the original cause if
// copy also fails.
copy_dir_recursive(staging, final_dir).map_err(|copy_err| {
ImportError::Io(io::Error::new(
copy_err.kind(),
format!(
"cross-device install fallback failed: rename={rename_err}, copy={copy_err}"
),
))
})?;
// Best-effort: if removing the staging dir fails the
// import is still a success — the user has the theme.
let _ = fs::remove_dir_all(staging);
Ok(())
}
}
}
/// Plain recursive copy used by [`install_atomic`] when `rename`
/// can't cross filesystem boundaries.
fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let from = entry.path();
let to = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_recursive(&from, &to)?;
} else {
fs::copy(&from, &to)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::io::Write;
use tempfile::TempDir;
use zip::write::SimpleFileOptions;
use zip::CompressionMethod;
use crate::theme::manifest::ThemeManifest;
use crate::theme::{CardKey, ThemeMeta};
/// Smallest non-trivial SVG that round-trips through `usvg` /
/// `resvg`. Matches the fixture used by `svg_loader::tests`.
const TEST_SVG: &[u8] = br##"<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300" width="200" height="300">
<rect x="0" y="0" width="200" height="300" fill="#FFD23F"/>
<circle cx="100" cy="150" r="80" fill="#1A0F2E"/>
</svg>"##;
fn meta(id: &str) -> ThemeMeta {
ThemeMeta {
id: id.to_owned(),
name: "Test Theme".into(),
author: "Tester".into(),
version: "1.0.0".into(),
card_aspect: (2, 3),
}
}
/// Returns a manifest that maps every card and the back to a
/// per-card SVG path inside the archive root.
fn full_manifest(id: &str) -> ThemeManifest {
let faces: HashMap<String, PathBuf> = CardKey::all()
.map(|k| {
let name = k.manifest_name();
(name.clone(), PathBuf::from(format!("faces/{name}.svg")))
})
.collect();
ThemeManifest {
meta: meta(id),
back: PathBuf::from("back.svg"),
faces,
}
}
/// Writes a zip archive at `zip_path` containing every entry in
/// `entries` (path → bytes). Uses `Stored` so tests don't pull
/// the deflate codepath into the assertion semantics.
fn write_zip(zip_path: &Path, entries: &[(&str, Vec<u8>)]) {
let file = File::create(zip_path).expect("create zip");
let mut writer = zip::ZipWriter::new(file);
let opts: SimpleFileOptions =
SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
for (name, bytes) in entries {
writer.start_file(*name, opts).expect("start file");
writer.write_all(bytes).expect("write entry");
}
writer.finish().expect("finish zip");
}
/// Builds a complete, valid theme zip at `zip_path` with the
/// given manifest id.
fn write_valid_zip(zip_path: &Path, id: &str) {
let manifest = full_manifest(id);
let manifest_ron = ron::ser::to_string_pretty(
&manifest,
ron::ser::PrettyConfig::default(),
)
.expect("ron serialise");
let mut entries: Vec<(String, Vec<u8>)> = Vec::with_capacity(54);
entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes()));
entries.push(("back.svg".to_owned(), TEST_SVG.to_vec()));
for k in CardKey::all() {
entries.push((
format!("faces/{}.svg", k.manifest_name()),
TEST_SVG.to_vec(),
));
}
let entries_ref: Vec<(&str, Vec<u8>)> =
entries.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
write_zip(zip_path, &entries_ref);
}
#[test]
fn valid_zip_imports_and_extracts_files() {
let scratch = TempDir::new().expect("scratch");
let zip_path = scratch.path().join("good.zip");
write_valid_zip(&zip_path, "fancy");
let target = TempDir::new().expect("target");
let id = import_theme_into(&zip_path, target.path()).expect("import succeeds");
assert_eq!(id.as_str(), "fancy");
let installed = target.path().join("fancy");
assert!(installed.is_dir(), "theme dir should exist");
assert!(installed.join("theme.ron").is_file(), "manifest extracted");
assert!(installed.join("back.svg").is_file(), "back extracted");
assert!(
installed.join("faces/hearts_ace.svg").is_file(),
"face extracted"
);
}
#[test]
fn missing_manifest_is_rejected() {
let scratch = TempDir::new().expect("scratch");
let zip_path = scratch.path().join("no_manifest.zip");
// No `theme.ron` at root, but plenty of other content.
write_zip(
&zip_path,
&[
("back.svg", TEST_SVG.to_vec()),
("faces/hearts_ace.svg", TEST_SVG.to_vec()),
],
);
let target = TempDir::new().expect("target");
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
assert!(
matches!(err, ImportError::MissingManifest),
"got {err:?}"
);
assert!(
target.path().read_dir().unwrap().next().is_none(),
"target untouched"
);
}
#[test]
fn manifest_with_only_51_faces_is_rejected() {
let scratch = TempDir::new().expect("scratch");
let zip_path = scratch.path().join("missing_face.zip");
let mut manifest = full_manifest("incomplete");
// Drop one face so validation surfaces MissingFaces.
manifest.faces.remove("hearts_ace");
let manifest_ron = ron::ser::to_string_pretty(
&manifest,
ron::ser::PrettyConfig::default(),
)
.expect("ron serialise");
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes()));
entries.push(("back.svg".to_owned(), TEST_SVG.to_vec()));
for k in CardKey::all() {
entries.push((
format!("faces/{}.svg", k.manifest_name()),
TEST_SVG.to_vec(),
));
}
let entries_ref: Vec<(&str, Vec<u8>)> =
entries.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
write_zip(&zip_path, &entries_ref);
let target = TempDir::new().expect("target");
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
match err {
ImportError::Validation(ManifestError::MissingFaces { missing }) => {
assert!(missing.iter().any(|s| s == "hearts_ace"));
}
other => panic!("expected MissingFaces, got {other:?}"),
}
assert!(
target.path().read_dir().unwrap().next().is_none(),
"target untouched"
);
}
#[test]
fn oversized_archive_is_rejected() {
let scratch = TempDir::new().expect("scratch");
let zip_path = scratch.path().join("huge.zip");
// Compose a single entry whose declared uncompressed size
// exceeds the cap. We use a real Vec here (compressed via
// Stored) because zip's central directory mirrors the
// payload size we actually wrote.
let huge = vec![0u8; (MAX_ARCHIVE_BYTES + 1) as usize];
write_zip(
&zip_path,
&[
(MANIFEST_NAME, b"".to_vec()),
("filler.bin", huge),
],
);
let target = TempDir::new().expect("target");
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
assert!(
matches!(err, ImportError::Oversized { .. }),
"got {err:?}"
);
assert!(
target.path().read_dir().unwrap().next().is_none(),
"target untouched"
);
}
#[test]
fn zip_slip_path_is_rejected() {
let scratch = TempDir::new().expect("scratch");
let zip_path = scratch.path().join("slip.zip");
// Build the zip with a deliberately path-traversing entry
// name. We bypass `write_zip`'s normal flow because the
// SimpleFileOptions API will gladly accept any string.
let file = File::create(&zip_path).expect("create zip");
let mut writer = zip::ZipWriter::new(file);
let opts: SimpleFileOptions =
SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
writer
.start_file("../etc/passwd", opts)
.expect("start file");
writer.write_all(b"not really").expect("write entry");
writer.finish().expect("finish zip");
let target = TempDir::new().expect("target");
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
assert!(matches!(err, ImportError::ZipSlip { .. }), "got {err:?}");
assert!(
target.path().read_dir().unwrap().next().is_none(),
"target untouched"
);
}
#[test]
fn manifest_referenced_face_missing_from_archive_is_rejected() {
let scratch = TempDir::new().expect("scratch");
let zip_path = scratch.path().join("missing_file.zip");
// Manifest is well-formed and validates, but we omit one of
// the SVGs from the archive to trigger the MissingFile path.
let manifest = full_manifest("missing_file_theme");
let manifest_ron = ron::ser::to_string_pretty(
&manifest,
ron::ser::PrettyConfig::default(),
)
.expect("ron serialise");
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes()));
entries.push(("back.svg".to_owned(), TEST_SVG.to_vec()));
for k in CardKey::all() {
// Skip hearts_ace.svg so its manifest entry has no
// matching archive payload.
if k.manifest_name() == "hearts_ace" {
continue;
}
entries.push((
format!("faces/{}.svg", k.manifest_name()),
TEST_SVG.to_vec(),
));
}
let entries_ref: Vec<(&str, Vec<u8>)> =
entries.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
write_zip(&zip_path, &entries_ref);
let target = TempDir::new().expect("target");
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
match err {
ImportError::MissingFile { missing } => {
assert!(
missing.contains("hearts_ace"),
"missing path should mention hearts_ace, got {missing}"
);
}
other => panic!("expected MissingFile, got {other:?}"),
}
assert!(
!target.path().join("missing_file_theme").exists(),
"target untouched"
);
}
#[test]
fn id_collision_with_existing_dir_is_rejected() {
let scratch = TempDir::new().expect("scratch");
let zip_path = scratch.path().join("collide.zip");
write_valid_zip(&zip_path, "duplicate");
let target = TempDir::new().expect("target");
// Pre-populate the destination so the import path runs
// straight into the IdCollision check.
fs::create_dir_all(target.path().join("duplicate")).unwrap();
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
match err {
ImportError::IdCollision { id } => assert_eq!(id, "duplicate"),
other => panic!("expected IdCollision, got {other:?}"),
}
// Existing dir must still be there, but no extracted files
// should have been copied into it.
let existing = target.path().join("duplicate");
assert!(existing.is_dir());
assert!(!existing.join("theme.ron").exists());
}
}
+160
View File
@@ -0,0 +1,160 @@
//! `AssetLoader` for `.theme.ron` manifests.
//!
//! Reads the manifest, validates structurally (52 faces, sane meta),
//! then schedules each referenced SVG via [`crate::assets::SvgLoader`]
//! at the resolution implied by `meta.card_aspect`. The resulting
//! `Handle<Image>`s are stored on the [`super::CardTheme`] asset, so
//! Bevy's asset dependency graph keeps each face alive for as long as
//! the theme is alive.
use std::collections::HashMap;
use bevy::asset::io::Reader;
use bevy::asset::{AssetLoader, AssetPath, LoadContext, ParseAssetPathError};
use bevy::reflect::TypePath;
use thiserror::Error;
use crate::assets::SvgLoaderSettings;
use super::manifest::{ManifestError, ThemeManifest};
use super::{CardKey, CardTheme};
/// Default rasterisation height when the manifest's `card_aspect`
/// implies a 2:3 card. 768 px tall × 512 px wide stays sharp on
/// any reasonable desktop window. Mobile viewports may want larger;
/// the per-load settings hook in `SvgLoader` stays available for
/// future overrides.
const DEFAULT_CARD_HEIGHT_PX: u32 = 768;
/// Errors raised by [`CardThemeLoader::load`].
#[derive(Debug, Error)]
pub enum CardThemeLoaderError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("manifest parse (RON): {0}")]
Parse(#[from] ron::error::SpannedError),
#[error("manifest validation: {0}")]
Validation(#[from] ManifestError),
/// `AssetPath::resolve` rejected a manifest-relative path. Almost
/// always means the manifest contains an absolute path or a
/// surface that includes a custom asset source the manifest
/// shouldn't be reaching across.
#[error("could not resolve asset path: {0}")]
PathResolve(#[from] ParseAssetPathError),
}
/// `AssetLoader` registered for the `.theme.ron` extension.
#[derive(Debug, Default, TypePath)]
pub struct CardThemeLoader;
impl AssetLoader for CardThemeLoader {
type Asset = CardTheme;
type Settings = ();
type Error = CardThemeLoaderError;
async fn load(
&self,
reader: &mut dyn Reader,
_settings: &Self::Settings,
load_context: &mut LoadContext<'_>,
) -> Result<CardTheme, Self::Error> {
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes).await?;
let manifest: ThemeManifest = ron::de::from_bytes(&bytes)?;
// Surfaces metadata + face-completeness errors with named
// diagnostics before we touch the asset graph.
let face_paths = manifest.validate()?;
let target = target_size_from_aspect(manifest.meta.card_aspect);
// Clone the manifest's own asset path so we can compose
// sibling paths via `AssetPath::resolve` without holding an
// immutable borrow of `load_context` while we mutably borrow
// it via `.loader()`.
let manifest_path: AssetPath<'static> = load_context.path().clone();
let back_path = manifest_path.resolve(&path_to_str(&manifest.back))?;
let face_full: Vec<(CardKey, AssetPath<'static>)> = face_paths
.iter()
.map(|(k, p)| {
manifest_path
.resolve(&path_to_str(p))
.map(|ap| (*k, ap))
})
.collect::<Result<_, _>>()?;
let mut faces = HashMap::with_capacity(face_full.len());
for (key, full_path) in face_full {
let handle = load_context
.loader()
.with_settings(move |s: &mut SvgLoaderSettings| s.target_size = target)
.load(full_path);
faces.insert(key, handle);
}
let back = load_context
.loader()
.with_settings(move |s: &mut SvgLoaderSettings| s.target_size = target)
.load(back_path);
Ok(CardTheme {
meta: manifest.meta,
faces,
back,
})
}
fn extensions(&self) -> &[&str] {
&["theme.ron"]
}
}
/// `AssetPath::resolve` takes `&str`; manifest paths are `PathBuf`.
/// Lossy is acceptable here because manifest paths must be plain ASCII
/// for cross-platform asset resolution to behave consistently.
fn path_to_str(p: &std::path::Path) -> String {
p.to_string_lossy().into_owned()
}
/// Translates `card_aspect` into the SVG rasteriser's target pixel
/// size. Height is held constant at [`DEFAULT_CARD_HEIGHT_PX`]; width
/// is derived to preserve the aspect, with a minimum of 1 px so a
/// degenerate-but-validated aspect doesn't produce a 0-width pixmap.
fn target_size_from_aspect(aspect: (u32, u32)) -> bevy::math::UVec2 {
let (num, denom) = aspect;
let width = ((DEFAULT_CARD_HEIGHT_PX as u64 * num as u64) / denom as u64).max(1) as u32;
bevy::math::UVec2::new(width, DEFAULT_CARD_HEIGHT_PX)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_size_2_to_3_yields_512_by_768() {
assert_eq!(
target_size_from_aspect((2, 3)),
bevy::math::UVec2::new(512, 768)
);
}
#[test]
fn target_size_handles_non_standard_aspect() {
// 3:4 → wider card.
let v = target_size_from_aspect((3, 4));
assert_eq!(v.y, DEFAULT_CARD_HEIGHT_PX);
assert_eq!(v.x, 576);
}
#[test]
fn target_size_clamps_to_at_least_1px_wide() {
// 1:10000 would otherwise round to zero.
let v = target_size_from_aspect((1, 10_000));
assert!(v.x >= 1);
}
#[test]
fn loader_advertises_theme_ron_extension() {
let loader = CardThemeLoader;
assert_eq!(loader.extensions(), &["theme.ron"]);
}
}
+180
View File
@@ -0,0 +1,180 @@
//! On-disk theme manifest schema (`.theme.ron`).
//!
//! A manifest is a single RON file that lists, for one card theme, the
//! display metadata plus the 52 face SVG paths and one back SVG path.
//! Paths are interpreted relative to the manifest file's directory so
//! the same manifest works whether the theme is bundled via
//! `embedded://`, dropped under `themes://`, or unpacked into a temp
//! dir during import validation.
use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::{CardKey, ThemeMeta, ThemeMetaError};
/// Raw deserialised manifest. Keys in `faces` use the canonical
/// [`CardKey::manifest_name`] string form (e.g. `"hearts_ace"`); the
/// loader converts to `HashMap<CardKey, _>` after validating that all
/// 52 entries are present.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThemeManifest {
pub meta: ThemeMeta,
pub back: PathBuf,
pub faces: HashMap<String, PathBuf>,
}
/// Errors raised by [`ThemeManifest::validate`].
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("theme metadata invalid: {0}")]
Meta(#[from] ThemeMetaError),
#[error("manifest face key {key:?} is not a valid card name")]
UnknownFaceKey { key: String },
#[error("manifest is missing face entries: {missing:?}")]
MissingFaces { missing: Vec<String> },
#[error("manifest declares {duplicate} twice with different paths")]
DuplicateFace { duplicate: String },
}
impl ThemeManifest {
/// Parses the manifest's face map into a strongly-typed
/// `HashMap<CardKey, PathBuf>`, surfacing precise errors for
/// (a) keys that don't name a real card, (b) any of the 52 cards
/// that the manifest forgot to list, and (c) duplicate keys (RON
/// silently keeps the last value, which is brittle behaviour for
/// a release). Also runs [`ThemeMeta::validate`] up front so
/// metadata-level errors surface before path validation.
pub fn validate(&self) -> Result<HashMap<CardKey, PathBuf>, ManifestError> {
self.meta.validate()?;
let mut faces: HashMap<CardKey, PathBuf> = HashMap::with_capacity(52);
for (key_str, path) in &self.faces {
let key = CardKey::parse_manifest_name(key_str).ok_or_else(|| {
ManifestError::UnknownFaceKey {
key: key_str.clone(),
}
})?;
if faces.insert(key, path.clone()).is_some() {
return Err(ManifestError::DuplicateFace {
duplicate: key_str.clone(),
});
}
}
let missing: Vec<String> = CardKey::all()
.filter(|k| !faces.contains_key(k))
.map(CardKey::manifest_name)
.collect();
if !missing.is_empty() {
return Err(ManifestError::MissingFaces { missing });
}
Ok(faces)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn meta() -> ThemeMeta {
ThemeMeta {
id: "default".into(),
name: "Default".into(),
author: "Solitaire Quest".into(),
version: "1.0.0".into(),
card_aspect: (2, 3),
}
}
fn full_face_map() -> HashMap<String, PathBuf> {
CardKey::all()
.map(|k| (k.manifest_name(), PathBuf::from(format!("{}.svg", k.manifest_name()))))
.collect()
}
#[test]
fn complete_manifest_validates() {
let m = ThemeManifest {
meta: meta(),
back: PathBuf::from("back.svg"),
faces: full_face_map(),
};
let parsed = m.validate().expect("valid manifest");
assert_eq!(parsed.len(), 52);
for k in CardKey::all() {
assert!(parsed.contains_key(&k), "{} missing", k.manifest_name());
}
}
#[test]
fn missing_face_is_rejected_with_a_named_list() {
let mut faces = full_face_map();
faces.remove("hearts_ace");
faces.remove("spades_king");
let m = ThemeManifest {
meta: meta(),
back: PathBuf::from("back.svg"),
faces,
};
match m.validate() {
Err(ManifestError::MissingFaces { missing }) => {
assert!(missing.iter().any(|s| s == "hearts_ace"));
assert!(missing.iter().any(|s| s == "spades_king"));
}
other => panic!("expected MissingFaces, got {other:?}"),
}
}
#[test]
fn unknown_face_key_is_rejected() {
let mut faces = full_face_map();
faces.insert("not_a_card".into(), PathBuf::from("nope.svg"));
let m = ThemeManifest {
meta: meta(),
back: PathBuf::from("back.svg"),
faces,
};
assert!(matches!(
m.validate(),
Err(ManifestError::UnknownFaceKey { key }) if key == "not_a_card"
));
}
#[test]
fn invalid_meta_propagates() {
let mut bad_meta = meta();
bad_meta.id = "../escape".into();
let m = ThemeManifest {
meta: bad_meta,
back: PathBuf::from("back.svg"),
faces: full_face_map(),
};
assert!(matches!(m.validate(), Err(ManifestError::Meta(_))));
}
#[test]
fn ron_round_trip_preserves_manifest() {
let m = ThemeManifest {
meta: meta(),
back: PathBuf::from("back.svg"),
faces: full_face_map(),
};
let serialised = ron::ser::to_string_pretty(
&m,
ron::ser::PrettyConfig::default(),
)
.expect("serde_ron");
let parsed: ThemeManifest = ron::from_str(&serialised).expect("ron parse");
assert_eq!(parsed.meta, m.meta);
assert_eq!(parsed.back, m.back);
assert_eq!(parsed.faces, m.faces);
}
}
+315
View File
@@ -0,0 +1,315 @@
//! Card-theme asset type.
//!
//! A `CardTheme` is a self-contained set of 52 face images plus one back
//! image, addressable by `CardKey`. Themes are loaded from RON manifests
//! (`.theme.ron`) by [`CardThemeLoader`]; the loader rasterises every
//! referenced SVG via [`crate::assets::SvgLoader`] and binds the
//! resulting `Handle<Image>` to its `CardKey`.
//!
//! The runtime card-rendering systems read the active theme through
//! [`crate::theme::ActiveTheme`] (added in Phase 4) and look up
//! `theme.faces.get(&card_key)` per render. They never store image
//! handles directly on card entities, so a theme switch propagates on
//! the next frame without re-spawning anything.
pub mod importer;
pub mod loader;
pub mod manifest;
pub mod plugin;
pub mod registry;
use std::collections::HashMap;
use bevy::asset::{Asset, Handle};
use bevy::image::Image;
use bevy::reflect::TypePath;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use solitaire_core::card::{Rank, Suit};
pub use importer::{import_theme, import_theme_into, ImportError, ThemeId};
pub use loader::{CardThemeLoader, CardThemeLoaderError};
pub use manifest::ThemeManifest;
pub use plugin::{set_theme, ActiveTheme, ThemePlugin};
pub use registry::{
build_registry, refresh_registry, ThemeEntry, ThemeRegistry, ThemeRegistryPlugin,
};
/// Hashable lookup key into [`CardTheme::faces`].
///
/// Distinct from `solitaire_core::Card`: the core type carries an `id`
/// and a `face_up` flag that vary per deal, neither of which is
/// relevant to image lookup. `CardKey` is just the (suit, rank) pair
/// that uniquely identifies which artwork to draw.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct CardKey {
pub suit: Suit,
pub rank: Rank,
}
impl CardKey {
/// Constructs a key from a `(suit, rank)` pair.
pub const fn new(suit: Suit, rank: Rank) -> Self {
Self { suit, rank }
}
/// Iterator over all 52 valid keys, in suit-major / rank-ascending order.
/// Used to enumerate the manifest's required entries.
pub fn all() -> impl Iterator<Item = CardKey> {
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
const RANKS: [Rank; 13] = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen,
Rank::King,
];
SUITS
.into_iter()
.flat_map(|s| RANKS.into_iter().map(move |r| CardKey::new(s, r)))
}
/// Canonical manifest-key string: `"{suit}_{rank}"` lowercase.
/// e.g. `"hearts_ace"`, `"spades_10"`, `"clubs_king"`.
pub fn manifest_name(self) -> String {
format!("{}_{}", suit_token(self.suit), rank_token(self.rank))
}
/// Inverse of [`CardKey::manifest_name`]. Accepts the canonical
/// `"{suit}_{rank}"` form. Returns `None` for any other shape so
/// the manifest loader surfaces a clear error message instead of
/// silently picking wrong defaults.
pub fn parse_manifest_name(s: &str) -> Option<CardKey> {
let (suit_part, rank_part) = s.split_once('_')?;
Some(CardKey::new(parse_suit(suit_part)?, parse_rank(rank_part)?))
}
}
fn suit_token(s: Suit) -> &'static str {
match s {
Suit::Clubs => "clubs",
Suit::Diamonds => "diamonds",
Suit::Hearts => "hearts",
Suit::Spades => "spades",
}
}
fn rank_token(r: Rank) -> &'static str {
match r {
Rank::Ace => "ace",
Rank::Two => "2",
Rank::Three => "3",
Rank::Four => "4",
Rank::Five => "5",
Rank::Six => "6",
Rank::Seven => "7",
Rank::Eight => "8",
Rank::Nine => "9",
Rank::Ten => "10",
Rank::Jack => "jack",
Rank::Queen => "queen",
Rank::King => "king",
}
}
fn parse_suit(s: &str) -> Option<Suit> {
match s {
"clubs" => Some(Suit::Clubs),
"diamonds" => Some(Suit::Diamonds),
"hearts" => Some(Suit::Hearts),
"spades" => Some(Suit::Spades),
_ => None,
}
}
fn parse_rank(s: &str) -> Option<Rank> {
match s {
"ace" => Some(Rank::Ace),
"2" => Some(Rank::Two),
"3" => Some(Rank::Three),
"4" => Some(Rank::Four),
"5" => Some(Rank::Five),
"6" => Some(Rank::Six),
"7" => Some(Rank::Seven),
"8" => Some(Rank::Eight),
"9" => Some(Rank::Nine),
"10" => Some(Rank::Ten),
"jack" => Some(Rank::Jack),
"queen" => Some(Rank::Queen),
"king" => Some(Rank::King),
_ => None,
}
}
/// Human-facing metadata stored in every theme manifest. Surfaces in
/// the future picker UI (Phase 6) and is preserved on disk so the
/// importer (Phase 7) can validate that two themes don't collide on
/// `id`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThemeMeta {
/// Unique opaque identifier — also the directory name on disk.
/// Must be filesystem-safe (no path separators); the importer
/// enforces this.
pub id: String,
/// Display name shown in the picker.
pub name: String,
/// Author attribution (free-form text).
pub author: String,
/// Version string (free-form, but conventionally semver).
pub version: String,
/// Card aspect ratio as `(numerator, denominator)`. The SVG
/// rasteriser uses this to choose a target size that preserves
/// the artwork's intended proportions when the player resizes the
/// window. Standard playing cards are 2:3.
pub card_aspect: (u32, u32),
}
/// Errors surfaced by [`ThemeMeta::validate`].
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ThemeMetaError {
#[error("theme id is empty")]
EmptyId,
#[error("theme id contains a path separator: {0:?}")]
PathSeparatorInId(String),
#[error("card_aspect denominator is zero")]
ZeroDenominator,
#[error("card_aspect numerator is zero")]
ZeroNumerator,
}
impl ThemeMeta {
/// Validates surface invariants. The importer (Phase 7) calls this
/// before unpacking a zip into the user-themes directory so it
/// can reject ill-formed manifests early without filesystem side
/// effects.
pub fn validate(&self) -> Result<(), ThemeMetaError> {
if self.id.is_empty() {
return Err(ThemeMetaError::EmptyId);
}
if self.id.contains('/') || self.id.contains('\\') {
return Err(ThemeMetaError::PathSeparatorInId(self.id.clone()));
}
if self.card_aspect.0 == 0 {
return Err(ThemeMetaError::ZeroNumerator);
}
if self.card_aspect.1 == 0 {
return Err(ThemeMetaError::ZeroDenominator);
}
Ok(())
}
}
/// A loaded card theme — 52 face images + 1 back image + metadata.
///
/// `faces` is keyed by [`CardKey`]; every key produced by
/// `CardKey::all()` is guaranteed to be present (the loader rejects
/// manifests that miss any of the 52 entries).
#[derive(Asset, TypePath, Debug)]
pub struct CardTheme {
pub meta: ThemeMeta,
pub faces: HashMap<CardKey, Handle<Image>>,
pub back: Handle<Image>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_yields_52_unique_keys() {
let keys: Vec<CardKey> = CardKey::all().collect();
assert_eq!(keys.len(), 52);
let unique: std::collections::HashSet<CardKey> = keys.iter().copied().collect();
assert_eq!(unique.len(), 52);
}
#[test]
fn manifest_name_round_trips_for_every_card() {
for key in CardKey::all() {
let name = key.manifest_name();
assert_eq!(
CardKey::parse_manifest_name(&name),
Some(key),
"round-trip failed for {name}"
);
}
}
#[test]
fn manifest_name_examples() {
assert_eq!(
CardKey::new(Suit::Hearts, Rank::Ace).manifest_name(),
"hearts_ace"
);
assert_eq!(
CardKey::new(Suit::Spades, Rank::Ten).manifest_name(),
"spades_10"
);
assert_eq!(
CardKey::new(Suit::Clubs, Rank::King).manifest_name(),
"clubs_king"
);
}
#[test]
fn parse_manifest_name_rejects_garbage() {
assert!(CardKey::parse_manifest_name("nope").is_none());
assert!(CardKey::parse_manifest_name("hearts").is_none());
assert!(CardKey::parse_manifest_name("hearts_").is_none());
assert!(CardKey::parse_manifest_name("_ace").is_none());
assert!(CardKey::parse_manifest_name("hearts_15").is_none());
assert!(CardKey::parse_manifest_name("HEARTS_ACE").is_none());
}
#[test]
fn theme_meta_validates_well_formed() {
let meta = ThemeMeta {
id: "default".into(),
name: "Default".into(),
author: "Solitaire Quest".into(),
version: "1.0.0".into(),
card_aspect: (2, 3),
};
assert_eq!(meta.validate(), Ok(()));
}
#[test]
fn theme_meta_rejects_empty_id() {
let meta = ThemeMeta {
id: String::new(),
name: "x".into(),
author: "x".into(),
version: "x".into(),
card_aspect: (2, 3),
};
assert_eq!(meta.validate(), Err(ThemeMetaError::EmptyId));
}
#[test]
fn theme_meta_rejects_path_separator_in_id() {
let meta = ThemeMeta {
id: "../etc/passwd".into(),
name: "x".into(),
author: "x".into(),
version: "x".into(),
card_aspect: (2, 3),
};
assert!(matches!(
meta.validate(),
Err(ThemeMetaError::PathSeparatorInId(_))
));
}
#[test]
fn theme_meta_rejects_zero_aspect_components() {
let mut meta = ThemeMeta {
id: "x".into(),
name: "x".into(),
author: "x".into(),
version: "x".into(),
card_aspect: (0, 3),
};
assert_eq!(meta.validate(), Err(ThemeMetaError::ZeroNumerator));
meta.card_aspect = (2, 0);
assert_eq!(meta.validate(), Err(ThemeMetaError::ZeroDenominator));
}
}
+285
View File
@@ -0,0 +1,285 @@
//! `ThemePlugin` — owns [`ActiveTheme`], registers the `CardTheme` /
//! SVG asset machinery, and keeps `card_plugin::CardImageSet` in sync
//! with the currently-loaded theme so existing card-rendering systems
//! pick up the new artwork on the next state-changed tick.
//!
//! Phase 4 of `CARD_PLAN.md`. The plugin's `set_theme` helper is the
//! public API that the future picker UI (Phase 6) calls; for now it's
//! exposed for tests and for any embedder that wants to load an
//! alternative theme manually.
use bevy::asset::AssetEvent;
use bevy::ecs::message::MessageReader;
use bevy::prelude::*;
use solitaire_core::card::{Rank, Suit};
use crate::assets::DEFAULT_THEME_MANIFEST_URL;
use crate::card_plugin::CardImageSet;
use crate::events::StateChangedEvent;
use super::loader::CardThemeLoader;
use super::{CardKey, CardTheme};
/// Resource pointing at the currently-active card theme. Populated on
/// startup with the bundled default theme and replaced by [`set_theme`]
/// when the player switches.
#[derive(Resource, Debug)]
pub struct ActiveTheme(pub Handle<CardTheme>);
/// Bevy plugin that loads the default theme and keeps `CardImageSet`
/// in sync with `Assets<CardTheme>`.
///
/// Order considerations:
///
/// - `init_asset::<CardTheme>` must happen before any system that
/// stores `Handle<CardTheme>` runs, so it goes in `build`.
/// - `register_asset_loader` for the SVG and theme loaders must
/// happen after `AssetPlugin` is built (DefaultPlugins). This
/// plugin therefore must be added after `DefaultPlugins`.
/// - The `Startup` system that loads the default theme runs after
/// the asset sources are registered (see
/// `crate::assets::register_theme_asset_sources` and
/// `crate::assets::AssetSourcesPlugin`).
pub struct ThemePlugin;
impl Plugin for ThemePlugin {
fn build(&self, app: &mut App) {
app.init_asset::<CardTheme>()
.register_asset_loader(crate::assets::SvgLoader)
.register_asset_loader(CardThemeLoader)
.add_systems(Startup, load_default_theme)
.add_systems(Update, sync_card_image_set_with_active_theme);
}
}
/// Kicks off the default-theme load and stashes the handle on
/// [`ActiveTheme`]. 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_default_theme(asset_server: Res<AssetServer>, mut commands: Commands) {
let handle: Handle<CardTheme> = asset_server.load(DEFAULT_THEME_MANIFEST_URL);
commands.insert_resource(ActiveTheme(handle));
}
/// Replaces every face slot and slot 0 of the back array on
/// `CardImageSet` whenever the active theme finishes loading or
/// changes. Fires `StateChangedEvent` afterwards so the existing
/// `card_plugin::sync_cards_on_change` pipeline re-renders every
/// on-screen card with the new artwork.
///
/// `CardImageSet` may be absent — tests using `MinimalPlugins` skip
/// `CardPlugin` entirely. In that case the system is a no-op and the
/// plugin still composes cleanly under headless setups.
fn sync_card_image_set_with_active_theme(
mut events: MessageReader<AssetEvent<CardTheme>>,
active: Option<Res<ActiveTheme>>,
themes: Res<Assets<CardTheme>>,
mut card_image_set: Option<ResMut<CardImageSet>>,
mut state_events: MessageWriter<StateChangedEvent>,
) {
let Some(active) = active else { return };
let active_id = active.0.id();
let mut should_sync = false;
for ev in events.read() {
let id = match ev {
AssetEvent::LoadedWithDependencies { id }
| AssetEvent::Modified { id } => *id,
_ => continue,
};
if id == active_id {
should_sync = true;
}
}
if !should_sync {
return;
}
let Some(theme) = themes.get(&active.0) else {
return;
};
let Some(card_image_set) = card_image_set.as_deref_mut() else {
return;
};
apply_theme_to_card_image_set(theme, card_image_set);
state_events.write(StateChangedEvent);
}
/// Pure helper that copies the theme's image handles into the
/// `[suit][rank]` face matrix and into back slot 0. Split out so it
/// can be unit-tested without spinning up a Bevy `App`.
fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet) {
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
for rank in [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
Rank::Jack, Rank::Queen, Rank::King,
] {
if let Some(handle) = theme.faces.get(&CardKey::new(suit, rank)) {
image_set.faces[suit_index(suit)][rank_index(rank)] = handle.clone();
}
}
}
image_set.backs[0] = theme.back.clone();
}
/// Index used by [`CardImageSet::faces`] for a given suit. Mirrors
/// the `card_plugin` doc comment: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
const fn suit_index(s: Suit) -> usize {
match s {
Suit::Clubs => 0,
Suit::Diamonds => 1,
Suit::Hearts => 2,
Suit::Spades => 3,
}
}
/// Index used by [`CardImageSet::faces`] for a given rank.
/// Ace=0, Two=1 … King=12.
const fn rank_index(r: Rank) -> usize {
match r {
Rank::Ace => 0,
Rank::Two => 1,
Rank::Three => 2,
Rank::Four => 3,
Rank::Five => 4,
Rank::Six => 5,
Rank::Seven => 6,
Rank::Eight => 7,
Rank::Nine => 8,
Rank::Ten => 9,
Rank::Jack => 10,
Rank::Queen => 11,
Rank::King => 12,
}
}
/// Switches the active theme to the one served at
/// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>`
/// so callers can poll `Assets<CardTheme>` if they want to wait for
/// the load before changing UI state.
///
/// The handle is also written to the [`ActiveTheme`] resource — the
/// per-frame sync system picks up the `LoadedWithDependencies` event
/// and refreshes `CardImageSet` automatically; callers don't need to
/// fire `StateChangedEvent` themselves.
pub fn set_theme(
commands: &mut Commands,
asset_server: &AssetServer,
theme_id: &str,
) -> Handle<CardTheme> {
let url = format!("themes://{theme_id}/theme.ron");
let handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle.clone()));
handle
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use crate::theme::ThemeMeta;
fn empty_theme() -> CardTheme {
CardTheme {
meta: ThemeMeta {
id: "test".into(),
name: "Test".into(),
author: "test".into(),
version: "0".into(),
card_aspect: (2, 3),
},
faces: HashMap::new(),
back: Handle::default(),
}
}
fn empty_card_image_set() -> CardImageSet {
// Every slot is the asset server's default-empty handle, the
// same shape `card_plugin::load_card_images` uses when the
// asset server is absent (tests under MinimalPlugins).
CardImageSet {
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
backs: std::array::from_fn(|_| Handle::default()),
}
}
#[test]
fn suit_index_ranges_match_card_plugin_layout() {
assert_eq!(suit_index(Suit::Clubs), 0);
assert_eq!(suit_index(Suit::Diamonds), 1);
assert_eq!(suit_index(Suit::Hearts), 2);
assert_eq!(suit_index(Suit::Spades), 3);
}
#[test]
fn rank_index_starts_at_ace_zero_and_ends_at_king_twelve() {
assert_eq!(rank_index(Rank::Ace), 0);
assert_eq!(rank_index(Rank::Two), 1);
assert_eq!(rank_index(Rank::Ten), 9);
assert_eq!(rank_index(Rank::Jack), 10);
assert_eq!(rank_index(Rank::Queen), 11);
assert_eq!(rank_index(Rank::King), 12);
}
#[test]
fn applying_empty_theme_does_not_panic() {
// A theme whose faces map is empty should leave existing
// image-set face slots untouched (the .get() returns None,
// we skip). The back is always copied since theme.back is
// a single handle.
let mut image_set = empty_card_image_set();
let theme = empty_theme();
apply_theme_to_card_image_set(&theme, &mut image_set);
}
#[test]
fn applying_theme_overwrites_back_slot_zero() {
// Build a theme whose back handle is a freshly-allocated weak
// handle — its id will differ from the default-handle id we
// started with, proving the back slot was overwritten.
let mut image_set = empty_card_image_set();
let theme = empty_theme();
let original_back_id = image_set.backs[0].id();
apply_theme_to_card_image_set(&theme, &mut image_set);
// Both default handles compare equal to themselves; the test
// asserts via id() that whichever handle is in slot 0 came
// from the theme — even if both happen to be Handle::default,
// the id swap is still observable via the value-equality of
// theme.back's id.
assert_eq!(image_set.backs[0].id(), theme.back.id());
// No assertion about original_back_id — both sides may be the
// same default handle id when neither is loaded; the contract
// we're checking is "slot 0 now matches theme.back".
let _ = original_back_id;
}
#[test]
fn theme_plugin_builds_under_minimal_plugins() {
// Smoke test: the plugin's build hooks (init_asset,
// register_asset_loader, system registration) run cleanly
// under MinimalPlugins. Loading the default theme is async
// and won't complete in a single tick, but the build step
// is what we're guarding against regression here.
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<Assets<CardTheme>>();
// The full ThemePlugin requires AssetServer (not present
// under MinimalPlugins). The pieces we can test in isolation
// are the asset registration and the sync helper, which the
// earlier tests cover. This test is a placeholder reminding
// future work to add an integration test once Phase 6 lands
// a richer test harness.
}
#[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", "default");
assert_eq!(url, "themes://default/theme.ron");
let url2 = format!("themes://{}/theme.ron", "user_uploaded");
assert_eq!(url2, "themes://user_uploaded/theme.ron");
}
}
+364
View File
@@ -0,0 +1,364 @@
//! Discovery and listing of available card themes.
//!
//! On startup the registry collects:
//!
//! - The bundled default theme — always present, served from
//! `embedded://`.
//! - Every valid user-supplied theme found under
//! [`crate::assets::user_theme_dir`] — one entry per immediate
//! subdirectory whose `theme.ron` parses cleanly.
//!
//! The picker UI (Phase 6 acceptance: "dropping a valid theme folder
//! into the user themes dir makes it appear on next app start") reads
//! [`ThemeRegistry`] to populate its list of options.
//!
//! Per the plan, this only parses the `meta` block of each manifest —
//! we don't validate face/back paths here because (a) that work
//! already lives in [`super::manifest::ThemeManifest::validate`] and
//! [`super::loader::CardThemeLoader`], and (b) the registry should
//! surface entries quickly enough for a startup scan to feel free,
//! even with dozens of user themes installed.
use std::path::Path;
use bevy::prelude::{App, Plugin, Resource, Startup};
use serde::Deserialize;
use super::ThemeMeta;
use crate::assets::{user_theme_dir, DEFAULT_THEME_MANIFEST_URL};
/// One entry in the [`ThemeRegistry`] — the data the picker UI needs
/// to render a row and load the theme on selection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThemeEntry {
/// Stable identifier; matches `meta.id` from the manifest. For
/// user themes this is also the directory name on disk; for the
/// bundled default it is the literal string `"default"`.
pub id: String,
/// Human-readable label for the picker.
pub display_name: String,
/// Asset URL the picker passes to
/// [`super::set_theme`] / `AssetServer::load`.
pub manifest_url: String,
/// The full meta block. Kept around so the picker can display
/// author + version without a second round-trip through disk.
pub meta: ThemeMeta,
}
/// Resource holding every theme available at app start.
///
/// The order is stable: default first, then user themes in the order
/// returned by [`std::fs::read_dir`] (filesystem-defined; usually
/// alphabetical on tested filesystems but not guaranteed by the OS).
#[derive(Resource, Debug, Default)]
pub struct ThemeRegistry {
pub entries: Vec<ThemeEntry>,
}
impl ThemeRegistry {
/// Returns the entry whose `id` matches, if any.
pub fn find(&self, id: &str) -> Option<&ThemeEntry> {
self.entries.iter().find(|e| e.id == id)
}
/// Iterator over every registered theme.
pub fn iter(&self) -> impl Iterator<Item = &ThemeEntry> {
self.entries.iter()
}
/// Number of registered themes (always ≥ 1 because the default
/// entry is always inserted, even if user-theme discovery fails).
pub fn len(&self) -> usize {
self.entries.len()
}
/// True only when the default entry is missing — should never
/// happen at runtime; provided for API completeness.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
/// Bevy plugin that builds [`ThemeRegistry`] on startup.
pub struct ThemeRegistryPlugin;
impl Plugin for ThemeRegistryPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ThemeRegistry>()
.add_systems(Startup, build_registry_on_startup);
}
}
/// Reads `user_theme_dir()` and replaces the registry's contents with
/// the bundled default plus every valid user theme.
fn build_registry_on_startup(mut registry: bevy::ecs::system::ResMut<ThemeRegistry>) {
*registry = build_registry(&user_theme_dir());
}
/// Pure helper: builds a registry given an explicit user-themes
/// directory. Tests pass a temp dir; production uses
/// [`user_theme_dir`].
pub fn build_registry(user_dir: &Path) -> ThemeRegistry {
let mut entries = Vec::new();
entries.push(default_entry());
entries.extend(discover_user_themes(user_dir));
ThemeRegistry { entries }
}
/// The bundled default theme entry — inserted unconditionally so the
/// picker always has at least one option.
fn default_entry() -> ThemeEntry {
ThemeEntry {
id: "default".to_string(),
display_name: "Default".to_string(),
manifest_url: DEFAULT_THEME_MANIFEST_URL.to_string(),
meta: ThemeMeta {
id: "default".to_string(),
name: "Default".to_string(),
author: "Solitaire Quest".to_string(),
version: "1.0".to_string(),
card_aspect: (2, 3),
},
}
}
/// Walks `user_dir`, treating every immediate subdirectory as a
/// candidate theme. A subdirectory contributes one entry if and only
/// if it contains a `theme.ron` whose `meta` block parses cleanly and
/// passes `ThemeMeta::validate`. Failed candidates are silently
/// skipped — broken themes don't poison discovery.
fn discover_user_themes(user_dir: &Path) -> Vec<ThemeEntry> {
let mut out = Vec::new();
let Ok(read) = std::fs::read_dir(user_dir) else {
// Missing or unreadable user directory is the common case
// before any theme is imported; treat it as "no themes" and
// move on.
return out;
};
for entry in read.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join("theme.ron");
if !manifest_path.is_file() {
continue;
}
let Some(theme_entry) = read_meta_only(&manifest_path) else {
continue;
};
out.push(theme_entry);
}
out
}
/// Partial deserialiser that only extracts `meta` from a theme
/// manifest. RON / serde silently skip unknown fields by default, so
/// this works against the full [`ThemeManifest`] schema without
/// having to load the 52 face paths or the back path.
#[derive(Deserialize)]
struct ManifestMetaOnly {
meta: ThemeMeta,
}
/// Reads a single `theme.ron` and turns its `meta` block into a
/// [`ThemeEntry`]. Returns `None` for any I/O / parse / validation
/// failure — discovery is best-effort.
fn read_meta_only(manifest_path: &Path) -> Option<ThemeEntry> {
let bytes = std::fs::read(manifest_path).ok()?;
let parsed: ManifestMetaOnly = ron::de::from_bytes(&bytes).ok()?;
parsed.meta.validate().ok()?;
let id = parsed.meta.id.clone();
let display_name = parsed.meta.name.clone();
let manifest_url = format!("themes://{id}/theme.ron");
Some(ThemeEntry {
id,
display_name,
manifest_url,
meta: parsed.meta,
})
}
/// Refreshes [`ThemeRegistry`] in place — call after a successful
/// [`super::import_theme`] so the new theme is visible in the picker
/// without restarting the app.
pub fn refresh_registry(registry: &mut ThemeRegistry, user_dir: &Path) {
*registry = build_registry(user_dir);
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
fn write_manifest(dir: &Path, id: &str, name: &str) {
let manifest = format!(
r#"(
meta: (
id: "{id}",
name: "{name}",
author: "tester",
version: "1.0.0",
card_aspect: (2, 3),
),
back: "back.svg",
faces: {{}},
)"#
);
fs::write(dir.join("theme.ron"), manifest).unwrap();
}
fn write_full_manifest(dir: &Path, id: &str, name: &str) {
// A complete manifest with the 52 face entries and back.
// Only used when a test specifically wants the full schema;
// most discovery tests use the meta-only stub via
// write_manifest above because the meta-only deserialiser
// ignores the rest of the file anyway.
let mut faces = String::new();
for key in crate::theme::CardKey::all() {
let mn = key.manifest_name();
faces.push_str(&format!(" \"{mn}\": \"{mn}.svg\",\n"));
}
let manifest = format!(
r#"(
meta: (
id: "{id}",
name: "{name}",
author: "tester",
version: "1.0.0",
card_aspect: (2, 3),
),
back: "back.svg",
faces: {{
{faces} }},
)"#
);
fs::write(dir.join("theme.ron"), manifest).unwrap();
}
#[test]
fn empty_user_dir_yields_only_the_default_entry() {
let tmp = tempfile::tempdir().unwrap();
let registry = build_registry(tmp.path());
assert_eq!(registry.len(), 1);
assert_eq!(registry.entries[0].id, "default");
}
#[test]
fn nonexistent_user_dir_still_yields_default() {
let registry = build_registry(Path::new(
"/definitely/not/a/real/path/should/not/panic",
));
assert_eq!(registry.len(), 1);
assert_eq!(registry.entries[0].id, "default");
}
#[test]
fn user_theme_with_valid_manifest_appears_in_registry() {
let tmp = tempfile::tempdir().unwrap();
let theme_dir = tmp.path().join("midnight");
fs::create_dir_all(&theme_dir).unwrap();
write_manifest(&theme_dir, "midnight", "Midnight");
let registry = build_registry(tmp.path());
assert_eq!(registry.len(), 2);
let entry = registry.find("midnight").expect("midnight registered");
assert_eq!(entry.display_name, "Midnight");
assert_eq!(entry.manifest_url, "themes://midnight/theme.ron");
}
#[test]
fn full_manifest_also_works_via_meta_only_parser() {
// The meta-only deserialiser must tolerate the full ThemeManifest
// schema without complaining about unknown fields.
let tmp = tempfile::tempdir().unwrap();
let theme_dir = tmp.path().join("full");
fs::create_dir_all(&theme_dir).unwrap();
write_full_manifest(&theme_dir, "full", "Full");
let registry = build_registry(tmp.path());
assert!(registry.find("full").is_some());
}
#[test]
fn malformed_manifest_is_skipped() {
let tmp = tempfile::tempdir().unwrap();
let theme_dir = tmp.path().join("broken");
fs::create_dir_all(&theme_dir).unwrap();
fs::write(theme_dir.join("theme.ron"), "this is not valid ron").unwrap();
// Plus a valid theme so we can confirm one bad apple doesn't
// poison discovery.
let good_dir = tmp.path().join("good");
fs::create_dir_all(&good_dir).unwrap();
write_manifest(&good_dir, "good", "Good Theme");
let registry = build_registry(tmp.path());
assert!(registry.find("broken").is_none());
assert!(registry.find("good").is_some());
}
#[test]
fn manifest_with_invalid_meta_is_skipped() {
// id with a path separator violates ThemeMeta::validate.
let tmp = tempfile::tempdir().unwrap();
let theme_dir = tmp.path().join("escape");
fs::create_dir_all(&theme_dir).unwrap();
write_manifest(&theme_dir, "../etc/passwd", "Evil");
let registry = build_registry(tmp.path());
assert_eq!(registry.len(), 1, "escape attempt must not register");
assert_eq!(registry.entries[0].id, "default");
}
#[test]
fn directory_without_theme_ron_is_ignored() {
let tmp = tempfile::tempdir().unwrap();
let lonely = tmp.path().join("no-manifest-here");
fs::create_dir_all(&lonely).unwrap();
fs::write(lonely.join("readme.md"), "wrong filename").unwrap();
let registry = build_registry(tmp.path());
assert_eq!(registry.len(), 1);
}
#[test]
fn find_returns_none_for_unknown_id() {
let registry = build_registry(Path::new("/nonexistent"));
assert!(registry.find("definitely-not-a-theme").is_none());
}
#[test]
fn refresh_replaces_existing_entries() {
let tmp = tempfile::tempdir().unwrap();
let mut registry = ThemeRegistry::default();
registry.entries.push(ThemeEntry {
id: "stale".into(),
display_name: "Stale".into(),
manifest_url: "themes://stale/theme.ron".into(),
meta: ThemeMeta {
id: "stale".into(),
name: "Stale".into(),
author: "x".into(),
version: "x".into(),
card_aspect: (2, 3),
},
});
refresh_registry(&mut registry, tmp.path());
assert_eq!(registry.len(), 1);
assert_eq!(registry.entries[0].id, "default");
assert!(registry.find("stale").is_none());
}
#[test]
fn default_entry_url_matches_embedded_constant() {
// Ensures the picker always gets a URL it can hand to the
// asset server for the bundled theme.
let entry = default_entry();
assert_eq!(entry.manifest_url, DEFAULT_THEME_MANIFEST_URL);
}
}