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
+42 -16
View File
@@ -22,17 +22,17 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::game_state::DrawMode;
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
use crate::events::{NewGameRequestEvent, StartPlayBySeedRequestEvent};
use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation;
use crate::settings_plugin::SettingsResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button, spawn_modal_header,
ButtonVariant, ScrimDismissible,
ButtonVariant, ScrimDismissible, spawn_modal, spawn_modal_actions, spawn_modal_body_text,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_PRESSED, BORDER_SUBTLE, HighContrastBorder, RADIUS_MD,
@@ -341,8 +341,7 @@ fn tick_debounce_and_spawn_solver_task(
.as_ref()
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode);
let cfg = SolverConfig::default();
let task = AsyncComputeTaskPool::get()
.spawn(async move { try_solve(seed, draw_mode, &cfg) });
let task = AsyncComputeTaskPool::get().spawn(async move { try_solve(seed, draw_mode, &cfg) });
pending.seed = Some(seed);
pending.handle = Some(task);
@@ -407,7 +406,9 @@ fn handle_confirm(
}
let Ok(buf) = buffers.single() else { return };
let Ok(seed) = buf.text.parse::<u64>() else { return };
let Ok(seed) = buf.text.parse::<u64>() else {
return;
};
new_game.write(NewGameRequestEvent {
seed: Some(seed),
@@ -470,8 +471,7 @@ mod tests {
}
fn open_dialog(app: &mut App) {
app.world_mut()
.write_message(StartPlayBySeedRequestEvent);
app.world_mut().write_message(StartPlayBySeedRequestEvent);
app.update();
}
@@ -547,7 +547,10 @@ mod tests {
let msgs = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = msgs.get_cursor();
assert!(cursor.read(msgs).next().is_none(), "no NewGameRequestEvent when buffer empty");
assert!(
cursor.read(msgs).next().is_none(),
"no NewGameRequestEvent when buffer empty"
);
// Dialog should still be open.
assert!(dialog_present(&mut app));
}
@@ -607,7 +610,10 @@ mod tests {
}
let pending = app.world().resource::<PendingVerification>();
assert!(pending.handle.is_some(), "solver task should have been spawned after debounce");
assert!(
pending.handle.is_some(),
"solver task should have been spawned after debounce"
);
assert_eq!(pending.seed, Some(42));
}
@@ -623,11 +629,21 @@ mod tests {
for _ in 0..DEBOUNCE_FRAMES {
app.update();
}
assert!(app.world().resource::<PendingVerification>().handle.is_some());
assert!(
app.world()
.resource::<PendingVerification>()
.handle
.is_some()
);
// New keypress should cancel the in-flight task.
press_key(&mut app, KeyCode::Digit3);
assert!(app.world().resource::<PendingVerification>().handle.is_none());
assert!(
app.world()
.resource::<PendingVerification>()
.handle
.is_none()
);
assert_eq!(app.world().resource::<PendingVerification>().seed, None);
}
@@ -649,7 +665,11 @@ mod tests {
// Poll until the solver task resolves (cap at 15 s wall-clock).
let deadline = Instant::now() + std::time::Duration::from_secs(15);
while app.world().resource::<PendingVerification>().handle.is_some()
while app
.world()
.resource::<PendingVerification>()
.handle
.is_some()
&& Instant::now() < deadline
{
app.update();
@@ -664,7 +684,13 @@ mod tests {
.next()
.map(|(t, _)| t.0.clone())
.unwrap_or_default();
assert_ne!(badge_text, "Verifying\u{2026}", "badge should have resolved to a verdict");
assert_ne!(badge_text, "Type a number", "badge should show verdict, not idle state");
assert_ne!(
badge_text, "Verifying\u{2026}",
"badge should have resolved to a verdict"
);
assert_ne!(
badge_text, "Type a number",
"badge should show verdict, not idle state"
);
}
}