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
+16 -12
View File
@@ -9,7 +9,7 @@ use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION};
use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState};
use crate::stats::StatsSnapshot;
@@ -57,9 +57,8 @@ pub fn load_stats() -> StatsSnapshot {
/// Save stats to the platform default path. Returns an error if the platform
/// data dir is unavailable or the write fails.
pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
let path = stats_file_path().ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable")
})?;
let path = stats_file_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable"))?;
save_stats_to(&path, stats)
}
@@ -89,11 +88,7 @@ pub fn load_game_state_from(path: &Path) -> Option<GameState> {
if gs.schema_version != GAME_STATE_SCHEMA_VERSION {
return None;
}
if gs.is_won {
None
} else {
Some(gs)
}
if gs.is_won { None } else { Some(gs) }
}
/// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won`
@@ -180,7 +175,10 @@ pub struct TimeAttackSession {
/// Returns the platform-specific path to `time_attack_session.json`, or
/// `None` if `crate::data_dir()` is unavailable.
pub fn time_attack_session_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME))
crate::data_dir().map(|d| {
d.join(crate::APP_DIR_NAME)
.join(TIME_ATTACK_SESSION_FILE_NAME)
})
}
/// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s
@@ -422,7 +420,10 @@ mod tests {
let mut gs = GameState::new(99, DrawMode::DrawOne);
gs.is_won = true;
save_game_state_to(&path, &gs).expect("save should be no-op, not error");
assert!(!path.exists(), "should not have written a file for a won game");
assert!(
!path.exists(),
"should not have written a file for a won game"
);
}
#[test]
@@ -556,7 +557,10 @@ mod tests {
loaded.remaining_secs,
);
assert_eq!(loaded.wins, 3, "wins must round-trip");
assert_eq!(loaded.saved_at_unix_secs, saved_at, "timestamp must round-trip");
assert_eq!(
loaded.saved_at_unix_secs, saved_at,
"timestamp must round-trip"
);
let _ = fs::remove_file(&path);
}