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
+35 -17
View File
@@ -3,15 +3,15 @@
//! `GET /api/sync/pull` — return the server's stored payload for this user.
//! `POST /api/sync/push` — receive the client's payload, merge, store, return.
use axum::{extract::State, Json};
use axum::{Json, extract::State};
use chrono::Utc;
use sqlx::SqlitePool;
use solitaire_sync::{
merge, AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse,
AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse, merge,
};
use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
use crate::{AppState, error::AppError, middleware::AuthenticatedUser};
// ---------------------------------------------------------------------------
// Database row helpers
@@ -38,11 +38,17 @@ async fn load_sync_row(pool: &SqlitePool, user_id: &str) -> Result<Option<SyncRo
/// Deserialize a stored `SyncRow` into a `SyncPayload`.
fn row_to_payload(row: &SyncRow, user_id: &str) -> Result<SyncPayload, AppError> {
let stats_json = row.stats_json.as_deref()
let stats_json = row
.stats_json
.as_deref()
.ok_or_else(|| AppError::Internal("missing stats_json".into()))?;
let achievements_json = row.achievements_json.as_deref()
let achievements_json = row
.achievements_json
.as_deref()
.ok_or_else(|| AppError::Internal("missing achievements_json".into()))?;
let progress_json = row.progress_json.as_deref()
let progress_json = row
.progress_json
.as_deref()
.ok_or_else(|| AppError::Internal("missing progress_json".into()))?;
let stats: StatsSnapshot = serde_json::from_str(stats_json)?;
@@ -172,28 +178,29 @@ pub async fn push(
/// If the user is opted in to the leaderboard, update their row with the
/// better of the stored and incoming `best_single_score` / `fastest_win_seconds`.
///
/// Uses SQLite `MIN`/`MAX` in the UPDATE so the database never regresses
/// a score even if the client sends stale data.
/// The opt-in check and the update are performed atomically in a single
/// conditional UPDATE (WHERE EXISTS subquery) to avoid a TOCTOU race where
/// the user opts out between the check and the write.
async fn update_leaderboard_if_opted_in(
pool: &SqlitePool,
user_id: &str,
payload: &SyncPayload,
) -> Result<(), AppError> {
// Only update if the user has opted in (leaderboard row exists).
let opted_in: Option<i64> = sqlx::query_scalar!(
let opted_in = sqlx::query!(
"SELECT leaderboard_opt_in FROM users WHERE id = ?",
user_id
)
.fetch_optional(pool)
.await?;
.await?
.map(|r| r.leaderboard_opt_in)
.unwrap_or(0);
if opted_in != Some(1) {
if opted_in != 1 {
return Ok(());
}
let best_score = payload.stats.best_single_score as i64;
let fastest = if payload.stats.fastest_win_seconds == u64::MAX {
// Sentinel "never won" value — don't store.
None::<i64>
} else {
Some(payload.stats.fastest_win_seconds as i64)
@@ -211,7 +218,9 @@ async fn update_leaderboard_if_opted_in(
recorded_at = ?
WHERE user_id = ?"#,
best_score,
fastest, fastest, fastest,
fastest,
fastest,
fastest,
now,
user_id
)
@@ -329,9 +338,18 @@ mod tests {
assert_eq!(merged.stats.games_played, 42, "idempotent: games_played");
assert_eq!(merged.stats.games_won, 20, "idempotent: games_won");
assert_eq!(merged.stats.best_single_score, 5_500, "idempotent: best_single_score");
assert_eq!(merged.stats.fastest_win_seconds, 90, "idempotent: fastest_win_seconds");
assert_eq!(merged.stats.lifetime_score, 110_000, "idempotent: lifetime_score");
assert_eq!(
merged.stats.best_single_score, 5_500,
"idempotent: best_single_score"
);
assert_eq!(
merged.stats.fastest_win_seconds, 90,
"idempotent: fastest_win_seconds"
);
assert_eq!(
merged.stats.lifetime_score, 110_000,
"idempotent: lifetime_score"
);
assert_eq!(merged.progress.total_xp, 3_000, "idempotent: total_xp");
}
}