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).
This commit is contained in:
Generated
+1
@@ -7679,6 +7679,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"kira",
|
"kira",
|
||||||
"resvg",
|
"resvg",
|
||||||
|
"ron",
|
||||||
"serde",
|
"serde",
|
||||||
"solitaire_core",
|
"solitaire_core",
|
||||||
"solitaire_data",
|
"solitaire_data",
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ usvg = "0.47"
|
|||||||
resvg = "0.47"
|
resvg = "0.47"
|
||||||
tiny-skia = "0.12"
|
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"
|
axum = "0.8"
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
|
||||||
jsonwebtoken = { version = "10", default-features = false, features = ["rust_crypto"] }
|
jsonwebtoken = { version = "10", default-features = false, features = ["rust_crypto"] }
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ thiserror = { workspace = true }
|
|||||||
usvg = { workspace = true }
|
usvg = { workspace = true }
|
||||||
resvg = { workspace = true }
|
resvg = { workspace = true }
|
||||||
tiny-skia = { workspace = true }
|
tiny-skia = { workspace = true }
|
||||||
|
ron = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ pub mod splash_plugin;
|
|||||||
pub mod stats_plugin;
|
pub mod stats_plugin;
|
||||||
pub mod sync_plugin;
|
pub mod sync_plugin;
|
||||||
pub mod table_plugin;
|
pub mod table_plugin;
|
||||||
|
pub mod theme;
|
||||||
pub mod time_attack_plugin;
|
pub mod time_attack_plugin;
|
||||||
pub mod ui_focus;
|
pub mod ui_focus;
|
||||||
pub mod ui_modal;
|
pub mod ui_modal;
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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 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<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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user