From 3cffbc2c51dc0ed13e73ee4a471227aceaedd905 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 14 May 2026 10:53:14 -0700 Subject: [PATCH] feat(engine): embed classic theme into binary like dark theme Classic SVGs and manifest are now compiled in via include_bytes!(), making the theme available on all platforms (desktop, Android) without requiring filesystem assets. Removes the now-redundant Dockerfile COPY of solitaire_engine/assets/themes/classic. Co-Authored-By: Claude Sonnet 4.6 --- solitaire_engine/src/assets/mod.rs | 5 +- solitaire_engine/src/assets/sources.rs | 165 ++++++++++++++++++++++++- solitaire_engine/src/theme/plugin.rs | 30 +++-- solitaire_server/Dockerfile | 2 +- 4 files changed, 188 insertions(+), 14 deletions(-) diff --git a/solitaire_engine/src/assets/mod.rs b/solitaire_engine/src/assets/mod.rs index c107205..0b7f92f 100644 --- a/solitaire_engine/src/assets/mod.rs +++ b/solitaire_engine/src/assets/mod.rs @@ -11,8 +11,9 @@ pub mod svg_loader; pub mod user_dir; pub use sources::{ - bundled_theme_url, dark_theme_svg_bytes, populate_embedded_dark_theme, - register_theme_asset_sources, AssetSourcesPlugin, DARK_THEME_MANIFEST_URL, USER_THEMES, + bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, + populate_embedded_classic_theme, populate_embedded_dark_theme, register_theme_asset_sources, + AssetSourcesPlugin, CLASSIC_THEME_MANIFEST_URL, DARK_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}; diff --git a/solitaire_engine/src/assets/sources.rs b/solitaire_engine/src/assets/sources.rs index 967f796..e296114 100644 --- a/solitaire_engine/src/assets/sources.rs +++ b/solitaire_engine/src/assets/sources.rs @@ -78,6 +78,20 @@ const DARK_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/dark/them const DARK_THEME_MANIFEST_BYTES: &[u8] = include_bytes!("../../assets/themes/dark/theme.ron"); +/// Stable embedded asset URL of the bundled Classic theme manifest. +pub const CLASSIC_THEME_MANIFEST_URL: &str = + "embedded://solitaire_engine/assets/themes/classic/theme.ron"; + +/// Path the embedded Classic-theme manifest registers under, relative +/// to the `embedded://` source root. Kept in lockstep with +/// [`CLASSIC_THEME_MANIFEST_URL`] by the unit test +/// `classic_theme_url_constant_matches_embedded_path`. +const CLASSIC_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/classic/theme.ron"; + +/// Bytes of the bundled Classic theme manifest, embedded at compile time. +const CLASSIC_THEME_MANIFEST_BYTES: &[u8] = + include_bytes!("../../assets/themes/classic/theme.ron"); + /// Generates a `(stable_path, bytes)` entry for one Dark-theme SVG. macro_rules! embed_dark_svg { ($name:literal) => { @@ -88,6 +102,16 @@ macro_rules! embed_dark_svg { }; } +/// Generates a `(stable_path, bytes)` entry for one Classic-theme SVG. +macro_rules! embed_classic_svg { + ($name:literal) => { + ( + concat!("solitaire_engine/assets/themes/classic/", $name), + include_bytes!(concat!("../../assets/themes/classic/", $name)) as &[u8], + ) + }; +} + /// Every Dark-theme SVG file bundled into the binary. const DARK_THEME_SVGS: &[(&str, &[u8])] = &[ embed_dark_svg!("back.svg"), @@ -145,6 +169,63 @@ const DARK_THEME_SVGS: &[(&str, &[u8])] = &[ embed_dark_svg!("spades_king.svg"), ]; +/// Every Classic-theme SVG file bundled into the binary. +const CLASSIC_THEME_SVGS: &[(&str, &[u8])] = &[ + embed_classic_svg!("back.svg"), + embed_classic_svg!("clubs_ace.svg"), + embed_classic_svg!("clubs_2.svg"), + embed_classic_svg!("clubs_3.svg"), + embed_classic_svg!("clubs_4.svg"), + embed_classic_svg!("clubs_5.svg"), + embed_classic_svg!("clubs_6.svg"), + embed_classic_svg!("clubs_7.svg"), + embed_classic_svg!("clubs_8.svg"), + embed_classic_svg!("clubs_9.svg"), + embed_classic_svg!("clubs_10.svg"), + embed_classic_svg!("clubs_jack.svg"), + embed_classic_svg!("clubs_queen.svg"), + embed_classic_svg!("clubs_king.svg"), + embed_classic_svg!("diamonds_ace.svg"), + embed_classic_svg!("diamonds_2.svg"), + embed_classic_svg!("diamonds_3.svg"), + embed_classic_svg!("diamonds_4.svg"), + embed_classic_svg!("diamonds_5.svg"), + embed_classic_svg!("diamonds_6.svg"), + embed_classic_svg!("diamonds_7.svg"), + embed_classic_svg!("diamonds_8.svg"), + embed_classic_svg!("diamonds_9.svg"), + embed_classic_svg!("diamonds_10.svg"), + embed_classic_svg!("diamonds_jack.svg"), + embed_classic_svg!("diamonds_queen.svg"), + embed_classic_svg!("diamonds_king.svg"), + embed_classic_svg!("hearts_ace.svg"), + embed_classic_svg!("hearts_2.svg"), + embed_classic_svg!("hearts_3.svg"), + embed_classic_svg!("hearts_4.svg"), + embed_classic_svg!("hearts_5.svg"), + embed_classic_svg!("hearts_6.svg"), + embed_classic_svg!("hearts_7.svg"), + embed_classic_svg!("hearts_8.svg"), + embed_classic_svg!("hearts_9.svg"), + embed_classic_svg!("hearts_10.svg"), + embed_classic_svg!("hearts_jack.svg"), + embed_classic_svg!("hearts_queen.svg"), + embed_classic_svg!("hearts_king.svg"), + embed_classic_svg!("spades_ace.svg"), + embed_classic_svg!("spades_2.svg"), + embed_classic_svg!("spades_3.svg"), + embed_classic_svg!("spades_4.svg"), + embed_classic_svg!("spades_5.svg"), + embed_classic_svg!("spades_6.svg"), + embed_classic_svg!("spades_7.svg"), + embed_classic_svg!("spades_8.svg"), + embed_classic_svg!("spades_9.svg"), + embed_classic_svg!("spades_10.svg"), + embed_classic_svg!("spades_jack.svg"), + embed_classic_svg!("spades_queen.svg"), + embed_classic_svg!("spades_king.svg"), +]; + /// Registers asset sources that must be in place *before* /// `AssetPlugin` is built. /// @@ -181,6 +262,7 @@ pub struct AssetSourcesPlugin; impl Plugin for AssetSourcesPlugin { fn build(&self, app: &mut App) { populate_embedded_dark_theme(app); + populate_embedded_classic_theme(app); } } @@ -208,11 +290,44 @@ pub fn dark_theme_svg_bytes(filename: &str) -> Option<&'static [u8]> { pub fn bundled_theme_url(id: &str) -> Option<&'static str> { match id { "dark" => Some(DARK_THEME_MANIFEST_URL), - "classic" => Some("themes/classic/theme.ron"), + "classic" => Some(CLASSIC_THEME_MANIFEST_URL), _ => None, } } +/// Returns the embedded SVG bytes for a single Classic-theme file +/// (e.g. `"back.svg"` or `"spades_ace.svg"`), or `None` when the +/// filename is not bundled. +pub fn classic_theme_svg_bytes(filename: &str) -> Option<&'static [u8]> { + let suffix = format!("/{filename}"); + CLASSIC_THEME_SVGS + .iter() + .find(|(path, _)| path.ends_with(&suffix)) + .map(|(_, bytes)| *bytes) +} + +/// Pushes every bundled Classic-theme file into the +/// [`EmbeddedAssetRegistry`] under its stable URL. +pub fn populate_embedded_classic_theme(app: &mut App) { + let registry = app + .world_mut() + .get_resource_or_insert_with(EmbeddedAssetRegistry::default); + + registry.insert_asset( + std::path::PathBuf::from(CLASSIC_THEME_MANIFEST_PATH), + std::path::Path::new(CLASSIC_THEME_MANIFEST_PATH), + CLASSIC_THEME_MANIFEST_BYTES, + ); + + for (path, bytes) in CLASSIC_THEME_SVGS { + registry.insert_asset( + std::path::PathBuf::from(*path), + std::path::Path::new(*path), + *bytes, + ); + } +} + /// Pushes every bundled Dark-theme file into the /// [`EmbeddedAssetRegistry`] under its stable URL. pub fn populate_embedded_dark_theme(app: &mut App) { @@ -305,4 +420,52 @@ mod tests { .expect("dark theme URL must use embedded:// scheme"); assert_eq!(url_tail, DARK_THEME_MANIFEST_PATH); } + + #[test] + fn populate_embedded_classic_theme_runs_without_asset_plugin() { + let mut app = App::new(); + populate_embedded_classic_theme(&mut app); + assert!(app + .world() + .get_resource::() + .is_some()); + } + + #[test] + fn embedded_classic_theme_manifest_validates() { + use crate::theme::ThemeManifest; + + let manifest: ThemeManifest = ron::de::from_bytes(CLASSIC_THEME_MANIFEST_BYTES) + .expect("classic manifest must parse as RON"); + let faces = manifest + .validate() + .expect("classic manifest must list all 52 faces"); + assert_eq!(faces.len(), 52); + } + + #[test] + fn classic_theme_svg_bytes_finds_back_and_ace_of_spades() { + assert!( + classic_theme_svg_bytes("back.svg").is_some(), + "classic theme must bundle a back.svg" + ); + assert!( + classic_theme_svg_bytes("spades_ace.svg").is_some(), + "classic theme must bundle a spades_ace.svg" + ); + } + + #[test] + fn classic_theme_svg_bytes_returns_none_for_unknown_file() { + assert!(classic_theme_svg_bytes("nope.svg").is_none()); + assert!(classic_theme_svg_bytes("").is_none()); + } + + #[test] + fn classic_theme_url_constant_matches_embedded_path() { + let url_tail = CLASSIC_THEME_MANIFEST_URL + .strip_prefix("embedded://") + .expect("classic theme URL must use embedded:// scheme"); + assert_eq!(url_tail, CLASSIC_THEME_MANIFEST_PATH); + } } diff --git a/solitaire_engine/src/theme/plugin.rs b/solitaire_engine/src/theme/plugin.rs index c86f580..eda9864 100644 --- a/solitaire_engine/src/theme/plugin.rs +++ b/solitaire_engine/src/theme/plugin.rs @@ -15,7 +15,7 @@ use bevy::prelude::*; use solitaire_core::card::{Rank, Suit}; use crate::assets::{ - bundled_theme_url, dark_theme_svg_bytes, rasterize_svg, user_theme_dir, + bundled_theme_url, classic_theme_svg_bytes, dark_theme_svg_bytes, rasterize_svg, user_theme_dir, }; use crate::card_plugin::CardImageSet; use crate::events::StateChangedEvent; @@ -306,22 +306,18 @@ const PREVIEW_BACK_FILENAME: &str = "back.svg"; /// /// - For the embedded `dark` theme, reads from the in-binary table via /// [`dark_theme_svg_bytes`]. No filesystem I/O. -/// - For bundled non-embedded themes (e.g. `classic`), reads from the -/// `assets/themes//` directory. +/// - For the embedded `classic` theme, reads from the in-binary table via +/// [`classic_theme_svg_bytes`]. No filesystem I/O. /// - For user themes, reads from `//`. /// Returns `None` for any I/O failure. fn read_theme_preview_svg_bytes(theme_id: &str, filename: &str) -> Option> { if theme_id == "dark" { return dark_theme_svg_bytes(filename).map(|b| b.to_vec()); } - // Bundled non-embedded themes live alongside the binary in assets/. - let bundled_path = std::path::Path::new("assets/themes") - .join(theme_id) - .join(filename); - if let Ok(bytes) = std::fs::read(&bundled_path) { - return Some(bytes); + if theme_id == "classic" { + return classic_theme_svg_bytes(filename).map(|b| b.to_vec()); } - // Fall back to user theme dir. + // User themes live in the user theme dir. let path = user_theme_dir().join(theme_id).join(filename); std::fs::read(&path).ok() } @@ -577,6 +573,20 @@ mod tests { ); } + /// `read_theme_preview_svg_bytes` for the classic theme always returns + /// embedded bytes for the canonical preview pair. + #[test] + fn read_classic_theme_preview_returns_some_for_canonical_files() { + assert!( + read_theme_preview_svg_bytes("classic", PREVIEW_BACK_FILENAME).is_some(), + "classic theme back.svg must be embedded" + ); + assert!( + read_theme_preview_svg_bytes("classic", PREVIEW_FACE_FILENAME).is_some(), + "classic theme spades_ace.svg must be embedded" + ); + } + /// `ensure_theme_thumbnails` is idempotent: calling it twice with /// the same registry must not regenerate or replace already-cached /// entries. This guards against the per-frame Update tick churning diff --git a/solitaire_server/Dockerfile b/solitaire_server/Dockerfile index 3079388..5388091 100644 --- a/solitaire_server/Dockerfile +++ b/solitaire_server/Dockerfile @@ -62,9 +62,9 @@ COPY --from=builder /build/target/release/solitaire_server ./server # Static web assets are served via ServeDir at runtime from these paths: # /app/solitaire_server/web → /web route # /app/assets → /assets route +# Card themes (dark + classic) are embedded in the binary; no theme files needed here. COPY solitaire_server/web ./solitaire_server/web COPY assets ./assets -COPY solitaire_engine/assets/themes/classic ./assets/themes/classic ENV SERVER_PORT=8080 EXPOSE 8080