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
+55 -46
View File
@@ -76,8 +76,8 @@ use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_BASE, BORDER_SUBTLE, MOTION_SPLASH_FADE_SECS,
MOTION_SPLASH_TOTAL_SECS, STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING,
TEXT_DISABLED, TEXT_PRIMARY, TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_1, VAL_SPACE_2,
VAL_SPACE_3, VAL_SPACE_5, VAL_SPACE_6, VAL_SPACE_7, Z_SPLASH,
TEXT_DISABLED, TEXT_PRIMARY, TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
VAL_SPACE_5, VAL_SPACE_6, VAL_SPACE_7, Z_SPLASH,
};
// ---------------------------------------------------------------------------
@@ -99,12 +99,7 @@ impl Plugin for SplashPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, spawn_splash).add_systems(
Update,
(
dismiss_splash_on_input,
advance_splash,
pulse_splash_cursor,
)
.chain(),
(dismiss_splash_on_input, advance_splash, pulse_splash_cursor).chain(),
);
}
}
@@ -325,11 +320,7 @@ fn build_scanline_image() -> Image {
// because `TextureFormat::pixel_size()` returns a `Result` in this
// Bevy version and a `debug_assert_eq!` shouldn't carry the
// unwrap noise.
debug_assert_eq!(
pixels.len(),
16,
"scanline pixel buffer must be 2x2 RGBA8",
);
debug_assert_eq!(pixels.len(), 16, "scanline pixel buffer must be 2x2 RGBA8",);
Image::new(
Extent3d {
width: 2,
@@ -376,13 +367,17 @@ fn spawn_header_section(parent: &mut ChildSpawnerCommands, font_handle: &Handle<
})
.with_children(|hdr| {
hdr.spawn((
SplashFadable { base_color: ACCENT_PRIMARY },
SplashFadable {
base_color: ACCENT_PRIMARY,
},
Text::new("|"), // ASCII terminal cursor.
cursor_font,
TextColor(transparent(ACCENT_PRIMARY)),
));
hdr.spawn((
SplashFadable { base_color: TEXT_PRIMARY },
SplashFadable {
base_color: TEXT_PRIMARY,
},
Text::new("Ferrous Solitaire"),
title_font,
TextColor(transparent(TEXT_PRIMARY)),
@@ -390,7 +385,9 @@ fn spawn_header_section(parent: &mut ChildSpawnerCommands, font_handle: &Handle<
// Thin horizontal divider under the wordmark — same hue as
// every other 1px chrome line in the design system.
hdr.spawn((
SplashFadableBg { base_color: BORDER_SUBTLE },
SplashFadableBg {
base_color: BORDER_SUBTLE,
},
Node {
width: Val::Px(192.0),
height: Val::Px(1.0),
@@ -399,7 +396,9 @@ fn spawn_header_section(parent: &mut ChildSpawnerCommands, font_handle: &Handle<
BackgroundColor(transparent(BORDER_SUBTLE)),
));
hdr.spawn((
SplashFadable { base_color: TEXT_DISABLED },
SplashFadable {
base_color: TEXT_DISABLED,
},
Text::new("TERMINAL EDITION"),
subtitle_font,
TextColor(transparent(TEXT_DISABLED)),
@@ -469,13 +468,17 @@ fn spawn_check_row(parent: &mut ChildSpawnerCommands, line_font: &TextFont, labe
})
.with_children(|row| {
row.spawn((
SplashFadable { base_color: STATE_SUCCESS },
SplashFadable {
base_color: STATE_SUCCESS,
},
Text::new("\u{2713}"), // ✓
line_font.clone(),
TextColor(transparent(STATE_SUCCESS)),
));
row.spawn((
SplashFadable { base_color: TEXT_DISABLED },
SplashFadable {
base_color: TEXT_DISABLED,
},
Text::new(label.to_string()),
line_font.clone(),
TextColor(transparent(TEXT_DISABLED)),
@@ -502,7 +505,9 @@ fn spawn_ready_row(parent: &mut ChildSpawnerCommands, line_font: &TextFont) {
})
.with_children(|row| {
row.spawn((
SplashFadable { base_color: TEXT_PRIMARY },
SplashFadable {
base_color: TEXT_PRIMARY,
},
Text::new("| ready_"), // ASCII ready prompt.
line_font.clone(),
TextColor(transparent(TEXT_PRIMARY)),
@@ -513,7 +518,9 @@ fn spawn_ready_row(parent: &mut ChildSpawnerCommands, line_font: &TextFont) {
// 6×12 px spec literally. Pulse animation lives in
// `pulse_splash_cursor` for testability.
row.spawn((
SplashFadableBg { base_color: ACCENT_PRIMARY },
SplashFadableBg {
base_color: ACCENT_PRIMARY,
},
SplashCursorPulse,
Node {
width: Val::Px(6.0),
@@ -542,7 +549,9 @@ fn spawn_progress_bar(parent: &mut ChildSpawnerCommands, line_font: &TextFont) {
.with_children(|bar| {
// Track.
bar.spawn((
SplashFadableBg { base_color: BORDER_SUBTLE },
SplashFadableBg {
base_color: BORDER_SUBTLE,
},
Node {
width: Val::Percent(100.0),
height: Val::Px(1.0),
@@ -553,7 +562,9 @@ fn spawn_progress_bar(parent: &mut ChildSpawnerCommands, line_font: &TextFont) {
.with_children(|track| {
// Fill — 100 % of the track width = "complete".
track.spawn((
SplashFadableBg { base_color: ACCENT_PRIMARY },
SplashFadableBg {
base_color: ACCENT_PRIMARY,
},
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
@@ -570,7 +581,9 @@ fn spawn_progress_bar(parent: &mut ChildSpawnerCommands, line_font: &TextFont) {
})
.with_children(|caption| {
caption.spawn((
SplashFadable { base_color: TEXT_DISABLED },
SplashFadable {
base_color: TEXT_DISABLED,
},
Text::new("DONE \u{00B7} 247 ASSETS"), // DONE · 247 ASSETS
line_font.clone(),
TextColor(transparent(TEXT_DISABLED)),
@@ -598,14 +611,18 @@ fn spawn_footer_section(parent: &mut ChildSpawnerCommands, font_handle: &Handle<
})
.with_children(|footer| {
footer.spawn((
SplashFadable { base_color: TEXT_DISABLED },
SplashFadable {
base_color: TEXT_DISABLED,
},
Text::new("BASE16-EIGHTIES"),
footer_font.clone(),
TextColor(transparent(TEXT_DISABLED)),
));
spawn_palette_swatch_row(footer);
footer.spawn((
SplashFadable { base_color: TEXT_DISABLED },
SplashFadable {
base_color: TEXT_DISABLED,
},
Text::new(format!("v{}", env!("CARGO_PKG_VERSION"))),
footer_font.clone(),
TextColor(transparent(TEXT_DISABLED)),
@@ -838,9 +855,8 @@ fn dismiss_splash_on_input(
// Jump the age forward to the start of the fade-out so the
// overlay dissolves cleanly. Saturating arithmetic on Duration
// means an already-past-fade-out splash stays past fade-out.
let fade_out_start = Duration::from_secs_f32(
(MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS).max(0.0),
);
let fade_out_start =
Duration::from_secs_f32((MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS).max(0.0));
for mut age in &mut roots {
if age.0 < fade_out_start {
age.0 = fade_out_start;
@@ -879,9 +895,9 @@ mod tests {
/// Tells `TimePlugin` to advance the virtual clock by `secs` on the
/// next `app.update()`. Mirrors the helper in `ui_tooltip::tests`.
fn set_manual_time_step(app: &mut App, secs: f32) {
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(secs),
));
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
secs,
)));
}
/// `Time<Virtual>` clamps per-tick deltas to `max_delta` (default
@@ -1056,9 +1072,8 @@ mod tests {
"alpha mid-hold must be exactly 1.0"
);
// Inside fade-out.
let mid_fade_out = Duration::from_secs_f32(
MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS / 2.0,
);
let mid_fade_out =
Duration::from_secs_f32(MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS / 2.0);
let mid_out_alpha = splash_alpha(mid_fade_out).unwrap();
assert!(
mid_out_alpha < 0.6 && mid_out_alpha > 0.4,
@@ -1097,9 +1112,8 @@ mod tests {
.next()
.expect("splash should exist after one post-dismiss tick")
.0;
let fade_out_start = Duration::from_secs_f32(
MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS,
);
let fade_out_start =
Duration::from_secs_f32(MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS);
assert!(
age >= fade_out_start,
"after a keypress dismiss the splash must be in fade-out (age >= {fade_out_start:?}); got {age:?}"
@@ -1127,9 +1141,8 @@ mod tests {
.next()
.expect("splash should exist after one post-dismiss tick")
.0;
let fade_out_start = Duration::from_secs_f32(
MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS,
);
let fade_out_start =
Duration::from_secs_f32(MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS);
assert!(
age >= fade_out_start,
"after a left-click dismiss the splash must be in fade-out; got {age:?}"
@@ -1320,11 +1333,7 @@ mod tests {
);
// Trough — sin(TAU * 0.75) = -1 → normalised = 0 → factor = min.
let trough = cursor_pulse_factor(
Duration::from_secs_f32(period * 3.0 / 4.0),
period,
min,
);
let trough = cursor_pulse_factor(Duration::from_secs_f32(period * 3.0 / 4.0), period, min);
assert!(
(trough - min).abs() < 1e-5,
"trough should fall to min ({min}); got {trough}"