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
+24 -10
View File
@@ -3,13 +3,13 @@
//! `GET /api/leaderboard` — list all opted-in entries (requires auth).
//! `POST /api/leaderboard/opt-in` — opt in and set / update display name.
use axum::{extract::State, Json};
use axum::{Json, extract::State};
use chrono::Utc;
use serde::Deserialize;
use solitaire_sync::LeaderboardEntry;
use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
use crate::{AppState, error::AppError, middleware::AuthenticatedUser};
// ---------------------------------------------------------------------------
// Request shapes
@@ -118,7 +118,9 @@ pub async fn opt_in(
) -> Result<Json<serde_json::Value>, AppError> {
let display_name = body.display_name.trim();
if display_name.is_empty() {
return Err(AppError::BadRequest("display_name must not be empty".into()));
return Err(AppError::BadRequest(
"display_name must not be empty".into(),
));
}
if display_name.chars().count() > DISPLAY_NAME_MAX {
return Err(AppError::BadRequest(format!(
@@ -197,9 +199,9 @@ mod tests {
fn leaderboard_entries_sorted_by_score_descending() {
let mut entries = [
entry("Charlie", Some(1_200)),
entry("Alice", Some(8_000)),
entry("Bob", Some(3_500)),
entry("Dave", None), // no score — should rank last
entry("Alice", Some(8_000)),
entry("Bob", Some(3_500)),
entry("Dave", None), // no score — should rank last
];
// Mirrors the SQL sort:
@@ -214,10 +216,22 @@ mod tests {
});
// Scored entries first, in descending order.
assert_eq!(entries[0].display_name, "Alice", "highest scorer must be first");
assert_eq!(entries[1].display_name, "Bob", "second-highest scorer must be second");
assert_eq!(entries[2].display_name, "Charlie", "lowest scorer must be third");
assert_eq!(
entries[0].display_name, "Alice",
"highest scorer must be first"
);
assert_eq!(
entries[1].display_name, "Bob",
"second-highest scorer must be second"
);
assert_eq!(
entries[2].display_name, "Charlie",
"lowest scorer must be third"
);
// Null-score entry sinks to the bottom.
assert_eq!(entries[3].display_name, "Dave", "entry with no score must rank last");
assert_eq!(
entries[3].display_name, "Dave",
"entry with no score must rank last"
);
}
}