diff --git a/Cargo.lock b/Cargo.lock index 4579882..a1925f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7679,6 +7679,7 @@ dependencies = [ "chrono", "kira", "resvg", + "ron", "serde", "solitaire_core", "solitaire_data", diff --git a/Cargo.toml b/Cargo.toml index e600d4f..0258cf2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,10 @@ 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" + axum = "0.8" sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] } jsonwebtoken = { version = "10", default-features = false, features = ["rust_crypto"] } diff --git a/solitaire_engine/Cargo.toml b/solitaire_engine/Cargo.toml index de3adbb..77b6bb7 100644 --- a/solitaire_engine/Cargo.toml +++ b/solitaire_engine/Cargo.toml @@ -18,6 +18,7 @@ thiserror = { workspace = true } usvg = { workspace = true } resvg = { workspace = true } tiny-skia = { workspace = true } +ron = { workspace = true } [dev-dependencies] async-trait = { workspace = true } diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 98e3c8f..3eb82e3 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -31,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; diff --git a/solitaire_engine/src/theme/loader.rs b/solitaire_engine/src/theme/loader.rs new file mode 100644 index 0000000..affc764 --- /dev/null +++ b/solitaire_engine/src/theme/loader.rs @@ -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`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 { + 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::>()?; + + 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"]); + } +} diff --git a/solitaire_engine/src/theme/manifest.rs b/solitaire_engine/src/theme/manifest.rs new file mode 100644 index 0000000..c607bd6 --- /dev/null +++ b/solitaire_engine/src/theme/manifest.rs @@ -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` 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, +} + +/// 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 }, + #[error("manifest declares {duplicate} twice with different paths")] + DuplicateFace { duplicate: String }, +} + +impl ThemeManifest { + /// Parses the manifest's face map into a strongly-typed + /// `HashMap`, 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, ManifestError> { + self.meta.validate()?; + + let mut faces: HashMap = 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 = 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 { + 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); + } +} diff --git a/solitaire_engine/src/theme/mod.rs b/solitaire_engine/src/theme/mod.rs new file mode 100644 index 0000000..0d2175c --- /dev/null +++ b/solitaire_engine/src/theme/mod.rs @@ -0,0 +1,307 @@ +//! 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` 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 loader; +pub mod manifest; + +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 loader::{CardThemeLoader, CardThemeLoaderError}; +pub use manifest::ThemeManifest; + +/// 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 { + 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 { + 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 { + 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 { + 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>, + pub back: Handle, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_yields_52_unique_keys() { + let keys: Vec = CardKey::all().collect(); + assert_eq!(keys.len(), 52); + let unique: std::collections::HashSet = 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)); + } +}