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
+62 -30
View File
@@ -11,11 +11,11 @@ use solitaire_core::pile::PileType;
use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::hud_plugin::HudVisibility;
use crate::layout::{compute_layout, Layout, LayoutResource, LayoutSystem};
use crate::safe_area::SafeAreaInsets;
use crate::resources::GameStateResource;
#[cfg(test)]
use crate::layout::TABLE_COLOUR;
use crate::layout::{Layout, LayoutResource, LayoutSystem, compute_layout};
use crate::resources::GameStateResource;
use crate::safe_area::SafeAreaInsets;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::ui_theme::TEXT_PRIMARY;
#[cfg(test)]
@@ -101,7 +101,9 @@ fn load_background_images(asset_server: Option<Res<AssetServer>>, mut commands:
let Some(asset_server) = asset_server else {
// AssetServer absent (e.g. MinimalPlugins in tests) — insert an
// empty set so setup_table can proceed using a default handle.
commands.insert_resource(BackgroundImageSet { handles: Vec::new() });
commands.insert_resource(BackgroundImageSet {
handles: Vec::new(),
});
return;
};
let handles = (0..5)
@@ -118,8 +120,8 @@ fn load_background_images(asset_server: Option<Res<AssetServer>>, mut commands:
fn theme_colour(theme: &Theme) -> Color {
match theme {
Theme::Green => Color::srgb(TABLE_COLOUR[0], TABLE_COLOUR[1], TABLE_COLOUR[2]),
Theme::Blue => Color::srgb(0.059, 0.196, 0.322),
Theme::Dark => Color::srgb(0.08, 0.08, 0.10),
Theme::Blue => Color::srgb(0.059, 0.196, 0.322),
Theme::Dark => Color::srgb(0.08, 0.08, 0.10),
}
}
@@ -171,10 +173,12 @@ fn setup_table(
));
}
let (window_size, scale) = windows.iter().next().map_or(
(Vec2::new(1280.0, 800.0), 1.0f32),
|w| (default_window_size(w), w.scale_factor()),
);
let (window_size, scale) = windows
.iter()
.next()
.map_or((Vec2::new(1280.0, 800.0), 1.0f32), |w| {
(default_window_size(w), w.scale_factor())
});
// Safe-area insets arrive from JNI asynchronously; they are almost always
// 0 here (populated ~frame 2-3). on_safe_area_changed fires when they
// arrive and issues a synthetic WindowResized to re-snap all game objects.
@@ -249,10 +253,10 @@ fn apply_theme_on_settings_change(
/// Matches the same ASCII convention used by `CardPlugin` for card labels.
pub fn suit_symbol(suit: &Suit) -> &'static str {
match suit {
Suit::Spades => "S",
Suit::Hearts => "H",
Suit::Spades => "S",
Suit::Hearts => "H",
Suit::Diamonds => "D",
Suit::Clubs => "C",
Suit::Clubs => "C",
}
}
@@ -291,7 +295,10 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
entity.with_children(|b| {
b.spawn((
Text2d::new("K"),
TextFont { font_size, ..default() },
TextFont {
font_size,
..default()
},
TextColor(TEXT_PRIMARY.with_alpha(0.35)),
Transform::from_xyz(0.0, 0.0, 0.1),
));
@@ -301,7 +308,10 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
entity.with_children(|b| {
b.spawn((
Text2d::new("A"),
TextFont { font_size, ..default() },
TextFont {
font_size,
..default()
},
TextColor(TEXT_PRIMARY.with_alpha(0.35)),
Transform::from_xyz(0.0, 0.0, 0.1),
));
@@ -375,7 +385,9 @@ fn on_safe_area_changed(
windows: Query<(Entity, &Window)>,
mut resize_events: MessageWriter<WindowResized>,
) {
let Some(safe_area) = safe_area else { return; };
let Some(safe_area) = safe_area else {
return;
};
if !safe_area.is_changed() {
return;
}
@@ -597,18 +609,18 @@ mod tests {
#[test]
fn all_three_themes_produce_distinct_colours() {
let green = theme_colour(&Theme::Green);
let blue = theme_colour(&Theme::Blue);
let dark = theme_colour(&Theme::Dark);
let blue = theme_colour(&Theme::Blue);
let dark = theme_colour(&Theme::Dark);
assert_ne!(green, blue, "Green and Blue must differ");
assert_ne!(green, dark, "Green and Dark must differ");
assert_ne!(blue, dark, "Blue and Dark must differ");
assert_ne!(blue, dark, "Blue and Dark must differ");
}
#[test]
fn effective_background_index_0_matches_theme_colour() {
for theme in [Theme::Green, Theme::Blue, Theme::Dark] {
let expected = theme_colour(&theme);
let actual = effective_background_colour(&theme, 0);
let actual = effective_background_colour(&theme, 0);
assert_eq!(
expected, actual,
"index 0 must always return the theme colour for {:?}",
@@ -623,7 +635,10 @@ mod tests {
let theme_green = theme_colour(&Theme::Green);
for idx in 1..=3 {
let eff = effective_background_colour(&Theme::Green, idx);
assert_ne!(eff, theme_green, "index {idx} must override the theme colour");
assert_ne!(
eff, theme_green,
"index {idx} must override the theme colour"
);
}
}
@@ -643,10 +658,10 @@ mod tests {
#[test]
fn suit_symbol_returns_correct_letters() {
assert_eq!(suit_symbol(&Suit::Spades), "S");
assert_eq!(suit_symbol(&Suit::Hearts), "H");
assert_eq!(suit_symbol(&Suit::Spades), "S");
assert_eq!(suit_symbol(&Suit::Hearts), "H");
assert_eq!(suit_symbol(&Suit::Diamonds), "D");
assert_eq!(suit_symbol(&Suit::Clubs), "C");
assert_eq!(suit_symbol(&Suit::Clubs), "C");
}
// -----------------------------------------------------------------------
@@ -730,12 +745,29 @@ mod tests {
/// `hint_pile_highlight_rgb_tracks_state_warning_token`.
#[test]
fn hint_pile_highlight_colour_is_gold() {
let Srgba { red, green, blue, .. } = HINT_PILE_HIGHLIGHT_COLOUR.to_srgba();
assert!(red >= 0.7, "gold hint colour must have red ≥ 0.7, got {red}");
assert!(green >= 0.5, "gold hint colour must have green ≥ 0.5, got {green}");
assert!(blue <= 0.6, "gold hint colour must have blue ≤ 0.6, got {blue}");
assert!(red > blue, "gold hint colour must be warmer than cool, got r={red} b={blue}");
assert!(green > blue, "gold hint colour must be warmer than cool, got g={green} b={blue}");
let Srgba {
red, green, blue, ..
} = HINT_PILE_HIGHLIGHT_COLOUR.to_srgba();
assert!(
red >= 0.7,
"gold hint colour must have red ≥ 0.7, got {red}"
);
assert!(
green >= 0.5,
"gold hint colour must have green ≥ 0.5, got {green}"
);
assert!(
blue <= 0.6,
"gold hint colour must have blue ≤ 0.6, got {blue}"
);
assert!(
red > blue,
"gold hint colour must be warmer than cool, got r={red} b={blue}"
);
assert!(
green > blue,
"gold hint colour must be warmer than cool, got g={green} b={blue}"
);
}
#[test]