Files
Ferrous-Solitaire/solitaire_app/src/main.rs
T
funman300 7b59e70192
CI / Test & Lint (push) Failing after 8s
CI / Release Build (push) Has been skipped
feat(engine): theme registry + discovery (Card theme phase 6)
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).
2026-05-01 06:04:34 +00:00

172 lines
7.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::fs::OpenOptions;
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin,
SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, UiFocusPlugin,
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
};
fn main() {
// Install a panic hook that writes a crash log next to the save files
// before re-running the default hook (so stderr still gets the message
// and any debugger attached still sees the panic).
install_crash_log_hook();
// Initialise the platform keyring store before any token operations.
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
// macOS it uses the Keychain; on Windows it uses the Credential store.
// If the platform has no OS keyring (e.g. a headless CI box), keyring
// operations will fail gracefully with TokenError::KeychainUnavailable.
if let Err(e) = keyring::use_native_store(true) {
eprintln!(
"warn: could not initialise OS keyring ({e}); \
server sync login will be unavailable"
);
}
// Load settings before building the app so we can construct the right
// sync provider. Falls back to defaults if no settings file exists yet.
let settings: Settings = settings_file_path()
.map(|p| load_settings_from(&p))
.unwrap_or_default();
let sync_provider = provider_for_backend(&settings.sync_backend);
// Restore the previous window geometry if the player has one saved.
// Otherwise open at the platform default (1280×800, centred on the
// primary monitor). The window_geometry field is None on first run
// and after upgrading from a build that didn't persist geometry.
let (window_resolution, window_position) = match settings.window_geometry {
Some(geom) => (
(geom.width, geom.height).into(),
WindowPosition::At(IVec2::new(geom.x, geom.y)),
),
None => (
(1280u32, 800u32).into(),
WindowPosition::Centered(MonitorSelection::Primary),
),
};
let mut app = App::new();
// The card-theme system's `themes://` asset source must be
// registered *before* `DefaultPlugins` builds `AssetPlugin`,
// because that plugin freezes the asset-source list at build
// time. The matching `AssetSourcesPlugin` (added below) finishes
// the wiring after `DefaultPlugins` by populating the embedded
// default theme into Bevy's `EmbeddedAssetRegistry`.
register_theme_asset_sources(&mut app);
app
.add_plugins(
DefaultPlugins
.set(WindowPlugin {
primary_window: Some(Window {
title: "Solitaire Quest".into(),
// X11/Wayland WM_CLASS so taskbar managers group
// multiple windows of this app correctly.
name: Some("solitaire-quest".into()),
resolution: window_resolution,
position: window_position,
// AutoNoVsync prefers Mailbox (triple-buffered) and
// falls back to Immediate, eliminating the vsync stall
// that AutoVsync produces during continuous window
// resize on X11 / Wayland. The game's frame budget is
// small enough that a few stray dropped frames from
// disabling vsync are imperceptible.
present_mode: PresentMode::AutoNoVsync,
resize_constraints: bevy::window::WindowResizeConstraints {
min_width: 800.0,
min_height: 600.0,
..default()
},
..default()
}),
..default()
})
// The `assets/` directory lives at the workspace root, but
// Bevy resolves `AssetPlugin::file_path` relative to the
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
// Point one level up so `cargo run -p solitaire_app` finds
// card faces, backs, backgrounds, and the UI font.
.set(bevy::asset::AssetPlugin {
file_path: "../assets".to_string(),
..default()
}),
)
.add_plugins(AssetSourcesPlugin)
.add_plugins(ThemePlugin)
.add_plugins(ThemeRegistryPlugin)
.add_plugins(FontPlugin)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(CardPlugin)
.add_plugins(CursorPlugin)
.add_plugins(InputPlugin)
.add_plugins(SelectionPlugin)
.add_plugins(AnimationPlugin)
.add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin)
.add_plugins(AutoCompletePlugin)
.add_plugins(StatsPlugin::default())
.add_plugins(ProgressPlugin::default())
.add_plugins(AchievementPlugin::default())
.add_plugins(DailyChallengePlugin)
.add_plugins(WeeklyGoalsPlugin)
.add_plugins(ChallengePlugin)
.add_plugins(TimeAttackPlugin)
.add_plugins(HudPlugin)
.add_plugins(HelpPlugin)
.add_plugins(HomePlugin)
.add_plugins(ProfilePlugin)
.add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default())
.add_plugins(AudioPlugin)
.add_plugins(OnboardingPlugin)
.add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(LeaderboardPlugin)
.add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(UiTooltipPlugin)
.add_plugins(SplashPlugin)
.run();
}
/// Wraps the default panic hook with one that also appends a crash log
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
/// still runs afterwards, so stderr output and debugger integration are
/// unchanged. If the data directory is unavailable, the wrapper silently
/// falls through — the default hook handles output either way.
fn install_crash_log_hook() {
let crash_log_path = settings_file_path().and_then(|p| {
p.parent()
.map(|parent| parent.join("crash.log"))
});
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if let Some(path) = crash_log_path.as_ref()
&& let Ok(mut file) = OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
// Plain unix-seconds timestamp keeps the format trivially
// parseable and avoids pulling in chrono just for this.
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let _ = writeln!(file, "----- t={secs} -----\n{info}\n");
}
default_hook(info);
}));
}