From 7b59e70192a15a1741be8a7454bcdae04ec6fdf9 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 1 May 2026 06:04:34 +0000 Subject: [PATCH] feat(engine): theme registry + discovery (Card theme phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- solitaire_app/src/main.rs | 5 +- solitaire_engine/src/lib.rs | 5 +- solitaire_engine/src/theme/mod.rs | 4 + solitaire_engine/src/theme/registry.rs | 364 +++++++++++++++++++++++++ 4 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 solitaire_engine/src/theme/registry.rs diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 430cf86..076c860 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -11,8 +11,8 @@ use solitaire_engine::{ CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, - SyncPlugin, TablePlugin, ThemePlugin, TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, - UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, + SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, UiFocusPlugin, + UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, }; fn main() { @@ -104,6 +104,7 @@ fn main() { ) .add_plugins(AssetSourcesPlugin) .add_plugins(ThemePlugin) + .add_plugins(ThemeRegistryPlugin) .add_plugins(FontPlugin) .add_plugins(GamePlugin) .add_plugins(TablePlugin) diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index ac2eef7..b26a4c2 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -44,7 +44,10 @@ 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, ThemePlugin}; +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, diff --git a/solitaire_engine/src/theme/mod.rs b/solitaire_engine/src/theme/mod.rs index 10fed7c..a2a9ec9 100644 --- a/solitaire_engine/src/theme/mod.rs +++ b/solitaire_engine/src/theme/mod.rs @@ -16,6 +16,7 @@ pub mod importer; pub mod loader; pub mod manifest; pub mod plugin; +pub mod registry; use std::collections::HashMap; @@ -31,6 +32,9 @@ 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`]. /// diff --git a/solitaire_engine/src/theme/registry.rs b/solitaire_engine/src/theme/registry.rs new file mode 100644 index 0000000..cf3266e --- /dev/null +++ b/solitaire_engine/src/theme/registry.rs @@ -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, +} + +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 { + 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::() + .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) { + *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 { + 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 { + 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); + } +}