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
+142 -50
View File
@@ -5,8 +5,8 @@
use chrono::{DateTime, NaiveDate, Utc};
use crate::progress::{DAILY_CHALLENGE_HISTORY_CAP, level_for_xp};
use crate::{AchievementRecord, ConflictReport, PlayerProgress, StatsSnapshot, SyncPayload};
use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP};
/// Merge two [`SyncPayload`]s into a single authoritative result.
///
@@ -43,7 +43,12 @@ pub fn merge_at(
let stats = merge_stats(&local.stats, &remote.stats, resolved_at, &mut conflicts);
let achievements = merge_achievements(&local.achievements, &remote.achievements);
let progress = merge_progress(&local.progress, &remote.progress, resolved_at, &mut conflicts);
let progress = merge_progress(
&local.progress,
&remote.progress,
resolved_at,
&mut conflicts,
);
let merged = SyncPayload {
user_id: local.user_id,
@@ -269,22 +274,26 @@ fn merge_progress(
let total_xp = local.total_xp.max(remote.total_xp);
// Union cosmetic unlocks.
let unlocked_card_backs = union_usize_vecs(&local.unlocked_card_backs, &remote.unlocked_card_backs);
let unlocked_card_backs =
union_usize_vecs(&local.unlocked_card_backs, &remote.unlocked_card_backs);
let unlocked_backgrounds =
union_usize_vecs(&local.unlocked_backgrounds, &remote.unlocked_backgrounds);
// Keep the most recently completed daily challenge date (latest).
let daily_challenge_last_completed =
match (local.daily_challenge_last_completed, remote.daily_challenge_last_completed) {
(Some(l), Some(r)) => Some(l.max(r)),
(Some(l), None) => Some(l),
(None, Some(r)) => Some(r),
(None, None) => None,
};
let daily_challenge_last_completed = match (
local.daily_challenge_last_completed,
remote.daily_challenge_last_completed,
) {
(Some(l), Some(r)) => Some(l.max(r)),
(Some(l), None) => Some(l),
(None, Some(r)) => Some(r),
(None, None) => None,
};
// Take the higher streak as a best-effort resolution.
let daily_challenge_streak =
local.daily_challenge_streak.max(remote.daily_challenge_streak);
let daily_challenge_streak = local
.daily_challenge_streak
.max(remote.daily_challenge_streak);
// weekly_goal_progress: use whichever side has the more recent ISO week key.
// When both sides share the same week, merge per-goal counts with max so
@@ -299,18 +308,22 @@ fn merge_progress(
}
(local.weekly_goal_week_iso.clone(), merged)
}
(Some(l), Some(r)) if r > l => {
(remote.weekly_goal_week_iso.clone(), remote.weekly_goal_progress.clone())
}
(Some(_), Some(_)) => {
(local.weekly_goal_week_iso.clone(), local.weekly_goal_progress.clone())
}
(Some(_), None) => {
(local.weekly_goal_week_iso.clone(), local.weekly_goal_progress.clone())
}
(None, Some(_)) => {
(remote.weekly_goal_week_iso.clone(), remote.weekly_goal_progress.clone())
}
(Some(l), Some(r)) if r > l => (
remote.weekly_goal_week_iso.clone(),
remote.weekly_goal_progress.clone(),
),
(Some(_), Some(_)) => (
local.weekly_goal_week_iso.clone(),
local.weekly_goal_progress.clone(),
),
(Some(_), None) => (
local.weekly_goal_week_iso.clone(),
local.weekly_goal_progress.clone(),
),
(None, Some(_)) => (
remote.weekly_goal_week_iso.clone(),
remote.weekly_goal_progress.clone(),
),
(None, None) => (None, Default::default()),
};
@@ -382,7 +395,11 @@ mod tests {
use crate::{AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload};
fn make_payload(stats: StatsSnapshot, achievements: Vec<AchievementRecord>, progress: PlayerProgress) -> SyncPayload {
fn make_payload(
stats: StatsSnapshot,
achievements: Vec<AchievementRecord>,
progress: PlayerProgress,
) -> SyncPayload {
SyncPayload {
user_id: Uuid::nil(),
stats,
@@ -536,8 +553,7 @@ mod tests {
assert_eq!(merged.stats.draw_one_wins, 20);
assert_eq!(merged.stats.draw_three_wins, 5);
assert!(
merged.stats.draw_one_wins + merged.stats.draw_three_wins
<= merged.stats.games_won,
merged.stats.draw_one_wins + merged.stats.draw_three_wins <= merged.stats.games_won,
"draw-mode win counts must not exceed total wins"
);
}
@@ -635,12 +651,19 @@ mod tests {
r.unlock(Utc::now());
r
};
let local = make_payload(StatsSnapshot::default(), vec![unlocked.clone()], PlayerProgress::default());
let local = make_payload(
StatsSnapshot::default(),
vec![unlocked.clone()],
PlayerProgress::default(),
);
let remote = make_payload(StatsSnapshot::default(), vec![], PlayerProgress::default());
let (merged, _) = merge(&local, &remote);
assert!(
merged.achievements.iter().any(|a| a.id == "first_win" && a.unlocked),
merged
.achievements
.iter()
.any(|a| a.id == "first_win" && a.unlocked),
"unlocked achievement must survive merge even if absent from remote"
);
}
@@ -651,11 +674,23 @@ mod tests {
let mut unlocked = AchievementRecord::locked("century");
unlocked.unlock(Utc::now());
let local = make_payload(StatsSnapshot::default(), vec![locked], PlayerProgress::default());
let remote = make_payload(StatsSnapshot::default(), vec![unlocked.clone()], PlayerProgress::default());
let local = make_payload(
StatsSnapshot::default(),
vec![locked],
PlayerProgress::default(),
);
let remote = make_payload(
StatsSnapshot::default(),
vec![unlocked.clone()],
PlayerProgress::default(),
);
let (merged, _) = merge(&local, &remote);
let ach = merged.achievements.iter().find(|a| a.id == "century").expect("must exist");
let ach = merged
.achievements
.iter()
.find(|a| a.id == "century")
.expect("must exist");
assert!(ach.unlocked);
assert_eq!(ach.unlock_date, unlocked.unlock_date);
}
@@ -670,11 +705,23 @@ mod tests {
let mut remote_rec = AchievementRecord::locked("speed_demon");
remote_rec.unlock(earlier);
let local = make_payload(StatsSnapshot::default(), vec![local_rec], PlayerProgress::default());
let remote = make_payload(StatsSnapshot::default(), vec![remote_rec], PlayerProgress::default());
let local = make_payload(
StatsSnapshot::default(),
vec![local_rec],
PlayerProgress::default(),
);
let remote = make_payload(
StatsSnapshot::default(),
vec![remote_rec],
PlayerProgress::default(),
);
let (merged, _) = merge(&local, &remote);
let ach = merged.achievements.iter().find(|a| a.id == "speed_demon").expect("must exist");
let ach = merged
.achievements
.iter()
.find(|a| a.id == "speed_demon")
.expect("must exist");
assert_eq!(ach.unlock_date, Some(earlier), "earlier date must win");
}
@@ -685,8 +732,16 @@ mod tests {
let mut a2 = AchievementRecord::locked("century");
a2.unlock(Utc::now());
let local = make_payload(StatsSnapshot::default(), vec![a1], PlayerProgress::default());
let remote = make_payload(StatsSnapshot::default(), vec![a2], PlayerProgress::default());
let local = make_payload(
StatsSnapshot::default(),
vec![a1],
PlayerProgress::default(),
);
let remote = make_payload(
StatsSnapshot::default(),
vec![a2],
PlayerProgress::default(),
);
let (merged, _) = merge(&local, &remote);
assert_eq!(merged.achievements.len(), 2);
@@ -744,7 +799,9 @@ mod tests {
let (_, conflicts) = merge(&local, &remote);
assert!(
conflicts.iter().any(|c| c.field == "daily_challenge_streak"),
conflicts
.iter()
.any(|c| c.field == "daily_challenge_streak"),
"expected conflict for daily_challenge_streak"
);
}
@@ -770,37 +827,70 @@ mod tests {
let week = "2026-W17".to_string();
let mut local = default_payload();
local.progress.weekly_goal_week_iso = Some(week.clone());
local.progress.weekly_goal_progress.insert("weekly_5_wins".to_string(), 3);
local.progress.weekly_goal_progress.insert("weekly_3_fast".to_string(), 1);
local
.progress
.weekly_goal_progress
.insert("weekly_5_wins".to_string(), 3);
local
.progress
.weekly_goal_progress
.insert("weekly_3_fast".to_string(), 1);
let mut remote = default_payload();
remote.progress.weekly_goal_week_iso = Some(week.clone());
remote.progress.weekly_goal_progress.insert("weekly_5_wins".to_string(), 2);
remote.progress.weekly_goal_progress.insert("weekly_3_no_undo".to_string(), 2);
remote
.progress
.weekly_goal_progress
.insert("weekly_5_wins".to_string(), 2);
remote
.progress
.weekly_goal_progress
.insert("weekly_3_no_undo".to_string(), 2);
let (merged, _) = merge(&local, &remote);
assert_eq!(merged.progress.weekly_goal_week_iso, Some(week));
// local had 3, remote had 2 — take max
assert_eq!(merged.progress.weekly_goal_progress.get("weekly_5_wins"), Some(&3));
assert_eq!(
merged.progress.weekly_goal_progress.get("weekly_5_wins"),
Some(&3)
);
// only in local
assert_eq!(merged.progress.weekly_goal_progress.get("weekly_3_fast"), Some(&1));
assert_eq!(
merged.progress.weekly_goal_progress.get("weekly_3_fast"),
Some(&1)
);
// only in remote
assert_eq!(merged.progress.weekly_goal_progress.get("weekly_3_no_undo"), Some(&2));
assert_eq!(
merged.progress.weekly_goal_progress.get("weekly_3_no_undo"),
Some(&2)
);
}
#[test]
fn weekly_goals_newer_remote_week_wins() {
let mut local = default_payload();
local.progress.weekly_goal_week_iso = Some("2026-W16".to_string());
local.progress.weekly_goal_progress.insert("weekly_5_wins".to_string(), 5);
local
.progress
.weekly_goal_progress
.insert("weekly_5_wins".to_string(), 5);
let mut remote = default_payload();
remote.progress.weekly_goal_week_iso = Some("2026-W17".to_string());
remote.progress.weekly_goal_progress.insert("weekly_5_wins".to_string(), 1);
remote
.progress
.weekly_goal_progress
.insert("weekly_5_wins".to_string(), 1);
let (merged, _) = merge(&local, &remote);
assert_eq!(merged.progress.weekly_goal_week_iso, Some("2026-W17".to_string()));
assert_eq!(merged.progress.weekly_goal_progress.get("weekly_5_wins"), Some(&1));
assert_eq!(
merged.progress.weekly_goal_week_iso,
Some("2026-W17".to_string())
);
assert_eq!(
merged.progress.weekly_goal_progress.get("weekly_5_wins"),
Some(&1)
);
}
// -----------------------------------------------------------------------
@@ -887,7 +977,9 @@ mod tests {
let (_, conflicts) = merge(&local, &remote);
assert!(
!conflicts.iter().any(|c| c.field == "daily_challenge_streak"),
!conflicts
.iter()
.any(|c| c.field == "daily_challenge_streak"),
"equal daily challenge streaks must produce no conflict"
);
}