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).
This commit is contained in:
funman300
2026-05-01 06:04:34 +00:00
parent 7f477b4ad8
commit 7b59e70192
4 changed files with 375 additions and 3 deletions
+3 -2
View File
@@ -11,8 +11,8 @@ use solitaire_engine::{
CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin,
SyncPlugin, TablePlugin, ThemePlugin, TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, UiFocusPlugin,
UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
}; };
fn main() { fn main() {
@@ -104,6 +104,7 @@ fn main() {
) )
.add_plugins(AssetSourcesPlugin) .add_plugins(AssetSourcesPlugin)
.add_plugins(ThemePlugin) .add_plugins(ThemePlugin)
.add_plugins(ThemeRegistryPlugin)
.add_plugins(FontPlugin) .add_plugins(FontPlugin)
.add_plugins(GamePlugin) .add_plugins(GamePlugin)
.add_plugins(TablePlugin) .add_plugins(TablePlugin)
+4 -1
View File
@@ -44,7 +44,10 @@ pub use assets::{
populate_embedded_default_theme, register_theme_asset_sources, AssetSourcesPlugin, populate_embedded_default_theme, register_theme_asset_sources, AssetSourcesPlugin,
DEFAULT_THEME_MANIFEST_URL, USER_THEMES, 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 achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
pub use challenge_plugin::{ pub use challenge_plugin::{
challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL, challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL,
+4
View File
@@ -16,6 +16,7 @@ pub mod importer;
pub mod loader; pub mod loader;
pub mod manifest; pub mod manifest;
pub mod plugin; pub mod plugin;
pub mod registry;
use std::collections::HashMap; 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 loader::{CardThemeLoader, CardThemeLoaderError};
pub use manifest::ThemeManifest; pub use manifest::ThemeManifest;
pub use plugin::{set_theme, ActiveTheme, ThemePlugin}; pub use plugin::{set_theme, ActiveTheme, ThemePlugin};
pub use registry::{
build_registry, refresh_registry, ThemeEntry, ThemeRegistry, ThemeRegistryPlugin,
};
/// Hashable lookup key into [`CardTheme::faces`]. /// Hashable lookup key into [`CardTheme::faces`].
/// ///
+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);
}
}