fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
Build and Deploy / build-and-push (push) Successful in 3m54s

- #66: Clamp safe-area insets to 25% of window height with warn!() on excess
- #68: Move fire_flush outside per-event loop in analytics (batch flush once)
- #56: Persist progress before marking reward_granted to prevent XP loss on crash
- #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh
- #62: Add validate_header() in replay upload with mode/draw_mode allowlists
- #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original
       queries already in .sqlx cache; EXISTS variant would require sqlx prepare

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
funman300
2026-05-28 13:07:22 -07:00
parent 8cb4c9808e
commit 6e407a3ea7
104 changed files with 6356 additions and 3092 deletions
+33 -55
View File
@@ -45,10 +45,10 @@ use thiserror::Error;
use bevy::math::UVec2;
use crate::assets::{rasterize_svg, user_theme_dir, SvgLoaderError};
use crate::assets::{SvgLoaderError, rasterize_svg, user_theme_dir};
use super::manifest::{ManifestError, ThemeManifest};
use super::ThemeMetaError;
use super::manifest::{ManifestError, ThemeManifest};
/// Hard cap on the *uncompressed* total of all archive entries. Set
/// generously high relative to a realistic 53-SVG theme (~12 MB at
@@ -100,9 +100,7 @@ pub enum ImportError {
/// The archive's declared total uncompressed size exceeds
/// [`MAX_ARCHIVE_BYTES`]. Checked *before* extraction.
#[error(
"archive declares {total} uncompressed bytes, exceeds the {limit}-byte limit"
)]
#[error("archive declares {total} uncompressed bytes, exceeds the {limit}-byte limit")]
Oversized { total: u64, limit: u64 },
/// No `theme.ron` at the archive root.
@@ -168,10 +166,7 @@ pub fn import_theme(zip_path: &Path) -> Result<ThemeId, ImportError> {
/// Tests use this directly with a `tempfile::TempDir` so they can
/// exercise the full extraction path without touching the global
/// [`crate::assets::user_dir::set_user_theme_dir`] override.
pub fn import_theme_into(
zip_path: &Path,
target_root: &Path,
) -> Result<ThemeId, ImportError> {
pub fn import_theme_into(zip_path: &Path, target_root: &Path) -> Result<ThemeId, ImportError> {
let file = File::open(zip_path)?;
let mut archive = zip::ZipArchive::new(file)?;
@@ -189,11 +184,9 @@ pub fn import_theme_into(
required.push(manifest.back.clone());
for path in &required {
let bytes = read_archive_entry(&mut archive, path)?;
rasterize_svg(&bytes, SVG_VALIDATION_SIZE).map_err(|source| {
ImportError::InvalidSvg {
path: path.to_string_lossy().into_owned(),
source,
}
rasterize_svg(&bytes, SVG_VALIDATION_SIZE).map_err(|source| ImportError::InvalidSvg {
path: path.to_string_lossy().into_owned(),
source,
})?;
}
@@ -288,8 +281,7 @@ fn is_safe_relative_path(p: &Path) -> bool {
if p.is_absolute() {
return false;
}
p.components()
.all(|c| matches!(c, Component::Normal(_)))
p.components().all(|c| matches!(c, Component::Normal(_)))
}
/// Reads `theme.ron` from the archive root and parses it.
@@ -373,11 +365,9 @@ fn write_archive_entry<R: io::Read + io::Seek>(
}
Err(e) => return Err(ImportError::OpenArchive(e)),
};
let safe = entry
.enclosed_name()
.ok_or_else(|| ImportError::ZipSlip {
path: name.to_owned(),
})?;
let safe = entry.enclosed_name().ok_or_else(|| ImportError::ZipSlip {
path: name.to_owned(),
})?;
if !is_safe_relative_path(&safe) {
return Err(ImportError::ZipSlip {
path: name.to_owned(),
@@ -457,8 +447,8 @@ mod tests {
use std::io::Write;
use tempfile::TempDir;
use zip::write::SimpleFileOptions;
use zip::CompressionMethod;
use zip::write::SimpleFileOptions;
use crate::theme::manifest::ThemeManifest;
use crate::theme::{CardKey, ThemeMeta};
@@ -516,11 +506,8 @@ mod tests {
/// given manifest id.
fn write_valid_zip(zip_path: &Path, id: &str) {
let manifest = full_manifest(id);
let manifest_ron = ron::ser::to_string_pretty(
&manifest,
ron::ser::PrettyConfig::default(),
)
.expect("ron serialise");
let manifest_ron = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
.expect("ron serialise");
let mut entries: Vec<(String, Vec<u8>)> = Vec::with_capacity(54);
entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes()));
entries.push(("back.svg".to_owned(), TEST_SVG.to_vec()));
@@ -530,8 +517,10 @@ mod tests {
TEST_SVG.to_vec(),
));
}
let entries_ref: Vec<(&str, Vec<u8>)> =
entries.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
let entries_ref: Vec<(&str, Vec<u8>)> = entries
.iter()
.map(|(k, v)| (k.as_str(), v.clone()))
.collect();
write_zip(zip_path, &entries_ref);
}
@@ -570,10 +559,7 @@ mod tests {
let target = TempDir::new().expect("target");
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
assert!(
matches!(err, ImportError::MissingManifest),
"got {err:?}"
);
assert!(matches!(err, ImportError::MissingManifest), "got {err:?}");
assert!(
target.path().read_dir().unwrap().next().is_none(),
"target untouched"
@@ -588,11 +574,8 @@ mod tests {
let mut manifest = full_manifest("incomplete");
// Drop one face so validation surfaces MissingFaces.
manifest.faces.remove("hearts_ace");
let manifest_ron = ron::ser::to_string_pretty(
&manifest,
ron::ser::PrettyConfig::default(),
)
.expect("ron serialise");
let manifest_ron = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
.expect("ron serialise");
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes()));
@@ -603,8 +586,10 @@ mod tests {
TEST_SVG.to_vec(),
));
}
let entries_ref: Vec<(&str, Vec<u8>)> =
entries.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
let entries_ref: Vec<(&str, Vec<u8>)> = entries
.iter()
.map(|(k, v)| (k.as_str(), v.clone()))
.collect();
write_zip(&zip_path, &entries_ref);
let target = TempDir::new().expect("target");
@@ -633,18 +618,12 @@ mod tests {
let huge = vec![0u8; (MAX_ARCHIVE_BYTES + 1) as usize];
write_zip(
&zip_path,
&[
(MANIFEST_NAME, b"".to_vec()),
("filler.bin", huge),
],
&[(MANIFEST_NAME, b"".to_vec()), ("filler.bin", huge)],
);
let target = TempDir::new().expect("target");
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
assert!(
matches!(err, ImportError::Oversized { .. }),
"got {err:?}"
);
assert!(matches!(err, ImportError::Oversized { .. }), "got {err:?}");
assert!(
target.path().read_dir().unwrap().next().is_none(),
"target untouched"
@@ -686,11 +665,8 @@ mod tests {
// Manifest is well-formed and validates, but we omit one of
// the SVGs from the archive to trigger the MissingFile path.
let manifest = full_manifest("missing_file_theme");
let manifest_ron = ron::ser::to_string_pretty(
&manifest,
ron::ser::PrettyConfig::default(),
)
.expect("ron serialise");
let manifest_ron = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
.expect("ron serialise");
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes()));
@@ -706,8 +682,10 @@ mod tests {
TEST_SVG.to_vec(),
));
}
let entries_ref: Vec<(&str, Vec<u8>)> =
entries.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
let entries_ref: Vec<(&str, Vec<u8>)> = entries
.iter()
.map(|(k, v)| (k.as_str(), v.clone()))
.collect();
write_zip(&zip_path, &entries_ref);
let target = TempDir::new().expect("target");
+8 -6
View File
@@ -92,7 +92,12 @@ mod tests {
fn full_face_map() -> HashMap<String, PathBuf> {
CardKey::all()
.map(|k| (k.manifest_name(), PathBuf::from(format!("{}.svg", k.manifest_name()))))
.map(|k| {
(
k.manifest_name(),
PathBuf::from(format!("{}.svg", k.manifest_name())),
)
})
.collect()
}
@@ -167,11 +172,8 @@ mod tests {
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 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);
+16 -6
View File
@@ -28,15 +28,15 @@ use thiserror::Error;
use solitaire_core::card::{Rank, Suit};
pub use importer::{import_theme, import_theme_into, ImportError, ThemeId};
pub use importer::{ImportError, ThemeId, import_theme, import_theme_into};
pub use loader::{CardThemeLoader, CardThemeLoaderError};
pub use manifest::ThemeManifest;
pub use plugin::{
ensure_theme_thumbnails, set_theme, ActiveTheme, ThemePlugin, ThemeThumbnailCache,
ThemeThumbnailPair, THEME_THUMBNAIL_HEIGHT_PX, THEME_THUMBNAIL_WIDTH_PX,
ActiveTheme, THEME_THUMBNAIL_HEIGHT_PX, THEME_THUMBNAIL_WIDTH_PX, ThemePlugin,
ThemeThumbnailCache, ThemeThumbnailPair, ensure_theme_thumbnails, set_theme,
};
pub use registry::{
build_registry, refresh_registry, ThemeEntry, ThemeRegistry, ThemeRegistryPlugin,
ThemeEntry, ThemeRegistry, ThemeRegistryPlugin, build_registry, refresh_registry,
};
/// Hashable lookup key into [`CardTheme::faces`].
@@ -62,8 +62,18 @@ impl CardKey {
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::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
+24 -15
View File
@@ -194,8 +194,7 @@ fn sync_card_image_set_with_active_theme(
// Consume asset events — covers the normal first-load path.
for ev in events.read() {
let id = match ev {
AssetEvent::LoadedWithDependencies { id }
| AssetEvent::Modified { id } => *id,
AssetEvent::LoadedWithDependencies { id } | AssetEvent::Modified { id } => *id,
_ => continue,
};
if id == active_id {
@@ -245,9 +244,19 @@ fn sync_card_image_set_with_active_theme(
fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet) {
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
for rank in [
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,
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,
] {
if let Some(handle) = theme.faces.get(&CardKey::new(suit, rank)) {
image_set.faces[suit_index(suit)][rank_index(rank)] = handle.clone();
@@ -348,10 +357,7 @@ fn read_theme_preview_svg_bytes(theme_id: &str, filename: &str) -> Option<Vec<u8
/// [`Handle::default`] if rasterisation fails (malformed SVG, etc.) so
/// the picker can render a placeholder for broken themes without
/// crashing.
fn rasterize_preview_to_handle(
svg_bytes: &[u8],
images: &mut Assets<Image>,
) -> Handle<Image> {
fn rasterize_preview_to_handle(svg_bytes: &[u8], images: &mut Assets<Image>) -> Handle<Image> {
let target = UVec2::new(THEME_THUMBNAIL_WIDTH_PX, THEME_THUMBNAIL_HEIGHT_PX);
match rasterize_svg(svg_bytes, target) {
Ok(image) => images.add(image),
@@ -365,10 +371,7 @@ fn rasterize_preview_to_handle(
/// Builds a [`ThemeThumbnailPair`] for a single theme. Either handle
/// is [`Handle::default`] when the matching SVG could not be located
/// or rasterised.
fn generate_thumbnail_pair_for(
theme_id: &str,
images: &mut Assets<Image>,
) -> ThemeThumbnailPair {
fn generate_thumbnail_pair_for(theme_id: &str, images: &mut Assets<Image>) -> ThemeThumbnailPair {
let ace = read_theme_preview_svg_bytes(theme_id, PREVIEW_FACE_FILENAME)
.map(|b| rasterize_preview_to_handle(&b, images))
.unwrap_or_default();
@@ -543,8 +546,14 @@ mod tests {
);
// And the underlying images must actually exist in the assets
// collection — the handles are real, not dangling.
assert!(images.get(&pair.ace).is_some(), "ace image must be inserted");
assert!(images.get(&pair.back).is_some(), "back image must be inserted");
assert!(
images.get(&pair.ace).is_some(),
"ace image must be inserted"
);
assert!(
images.get(&pair.back).is_some(),
"back image must be inserted"
);
}
/// Test 2: when a theme is registered but its preview SVGs are not
+6 -4
View File
@@ -291,9 +291,7 @@ mod tests {
#[test]
fn nonexistent_user_dir_still_yields_bundled_entries() {
let registry = build_registry(Path::new(
"/definitely/not/a/real/path/should/not/panic",
));
let registry = build_registry(Path::new("/definitely/not/a/real/path/should/not/panic"));
assert_eq!(registry.len(), BUNDLED_COUNT);
assert!(registry.find("classic").is_some());
assert!(registry.find("dark").is_some());
@@ -353,7 +351,11 @@ mod tests {
write_manifest(&theme_dir, "../etc/passwd", "Evil");
let registry = build_registry(tmp.path());
assert_eq!(registry.len(), BUNDLED_COUNT, "escape attempt must not register");
assert_eq!(
registry.len(),
BUNDLED_COUNT,
"escape attempt must not register"
);
assert!(registry.find("classic").is_some());
}