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 -42
View File
@@ -14,15 +14,15 @@
use std::sync::Arc;
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use chrono::Utc;
use uuid::Uuid;
use solitaire_data::{
save_achievements_to, save_progress_to, save_replay_history_to, save_stats_to,
AchievementRecord, PlayerProgress, Replay, StatsSnapshot, SyncError, SyncProvider,
save_achievements_to, save_progress_to, save_replay_history_to, save_stats_to,
};
use solitaire_sync::{merge, SyncPayload, SyncResponse};
use solitaire_sync::{SyncPayload, SyncResponse, merge};
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
use crate::events::{
@@ -32,7 +32,9 @@ use crate::events::{
use crate::game_plugin::RecordingReplay;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource, TokioRuntimeResource};
use crate::stats_plugin::{LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath};
use crate::stats_plugin::{
LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath,
};
// ---------------------------------------------------------------------------
// Public resources
@@ -148,9 +150,7 @@ fn start_pull(
) {
let provider = provider.0.clone();
let rt = rt.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
rt.block_on(provider.pull())
});
let task = AsyncComputeTaskPool::get().spawn(async move { rt.block_on(provider.pull()) });
task_res.0 = Some(task);
status.0 = SyncStatus::Syncing;
}
@@ -173,9 +173,7 @@ fn handle_manual_sync_request(
}
let provider = provider.0.clone();
let rt = rt.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
rt.block_on(provider.pull())
});
let task = AsyncComputeTaskPool::get().spawn(async move { rt.block_on(provider.pull()) });
task_res.0 = Some(task);
status.0 = SyncStatus::Syncing;
}
@@ -219,17 +217,20 @@ fn poll_pull_result(
// Persist merged state atomically.
if let Some(p) = &stats_path.0
&& let Err(e) = save_stats_to(p, &merged.stats) {
warn!("sync: failed to persist stats: {e}");
}
&& let Err(e) = save_stats_to(p, &merged.stats)
{
warn!("sync: failed to persist stats: {e}");
}
if let Some(p) = &achievements_path.0
&& let Err(e) = save_achievements_to(p, &merged.achievements) {
warn!("sync: failed to persist achievements: {e}");
}
&& let Err(e) = save_achievements_to(p, &merged.achievements)
{
warn!("sync: failed to persist achievements: {e}");
}
if let Some(p) = &progress_path.0
&& let Err(e) = save_progress_to(p, &merged.progress) {
warn!("sync: failed to persist progress: {e}");
}
&& let Err(e) = save_progress_to(p, &merged.progress)
{
warn!("sync: failed to persist progress: {e}");
}
// Update in-world resources.
let now = Utc::now();
@@ -342,9 +343,8 @@ fn push_replay_on_win(
);
let provider = provider.0.clone();
let rt = rt.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
rt.block_on(provider.push_replay(&replay))
});
let task = AsyncComputeTaskPool::get()
.spawn(async move { rt.block_on(provider.push_replay(&replay)) });
// If a previous upload is still in flight, drop it — the most
// recent win is the one whose share link the player will care
// about. Bevy's `Task` Drop cancels cooperatively.
@@ -520,10 +520,7 @@ mod tests {
// Status is either Syncing (task still running) or LastSynced (resolved).
let status = &app.world().resource::<SyncStatusResource>().0;
assert!(
matches!(
status,
SyncStatus::Syncing | SyncStatus::LastSynced(_)
),
matches!(status, SyncStatus::Syncing | SyncStatus::LastSynced(_)),
"status should be Syncing or LastSynced, got {status:?}"
);
}
@@ -539,8 +536,7 @@ mod tests {
// mirrors the auto-save flake fix and turns this test from
// "pass on a fast machine" into "pass on any machine that
// makes meaningful progress".
let deadline =
std::time::Instant::now() + std::time::Duration::from_secs(5);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
app.update();
if matches!(
@@ -565,8 +561,7 @@ mod tests {
fn pull_failure_fires_warning_toast() {
use bevy::ecs::message::Messages;
let mut app = headless_app_with(FailingProvider);
let deadline =
std::time::Instant::now() + std::time::Duration::from_secs(5);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
app.update();
if matches!(
@@ -590,17 +585,16 @@ mod tests {
#[test]
fn build_payload_sets_nil_user_id() {
let payload = build_payload(
&StatsSnapshot::default(),
&[],
&PlayerProgress::default(),
);
let payload = build_payload(&StatsSnapshot::default(), &[], &PlayerProgress::default());
assert_eq!(payload.user_id, Uuid::nil());
}
#[test]
fn build_payload_clones_stats() {
let stats = StatsSnapshot { games_played: 42, ..Default::default() };
let stats = StatsSnapshot {
games_played: 42,
..Default::default()
};
let payload = build_payload(&stats, &[], &PlayerProgress::default());
assert_eq!(payload.stats.games_played, 42);
}
@@ -615,12 +609,11 @@ mod tests {
fn upload_result_writes_share_url_into_replay_and_persists() {
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_data::{
load_replay_history_from, save_replay_history_to, Replay, ReplayHistory,
Replay, ReplayHistory, load_replay_history_from, save_replay_history_to,
};
let mut app = headless_app_with(NoOpProvider);
let path = std::env::temp_dir()
.join("solitaire_test_replay_share_url_persist.json");
let path = std::env::temp_dir().join("solitaire_test_replay_share_url_persist.json");
let _ = std::fs::remove_file(&path);
// Seed the in-memory history with a single replay carrying no
@@ -649,9 +642,7 @@ mod tests {
let url = url.clone();
async move { Ok::<String, SyncError>(url) }
});
app.world_mut()
.resource_mut::<PendingReplayUpload>()
.0 = Some(task);
app.world_mut().resource_mut::<PendingReplayUpload>().0 = Some(task);
// Pump frames until the polling system observes the task as
// ready and clears `PendingReplayUpload`.