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
+5 -1
View File
@@ -76,6 +76,10 @@ mod tests {
let later = Utc::now();
r.unlock(early);
r.unlock(later); // should be a no-op
assert_eq!(r.unlock_date, Some(early), "earliest unlock date must be preserved");
assert_eq!(
r.unlock_date,
Some(early),
"earliest unlock date must be preserved"
);
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ pub mod stats;
pub use achievements::AchievementRecord;
pub use merge::{merge, merge_at};
pub use progress::{level_for_xp, PlayerProgress};
pub use progress::{PlayerProgress, level_for_xp};
pub use stats::StatsSnapshot;
use chrono::{DateTime, Utc};
+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"
);
}
+25 -12
View File
@@ -116,7 +116,10 @@ impl PlayerProgress {
/// Returns `true` if this call brought the counter from below `target`
/// to at-or-above `target` (i.e. just completed the goal).
pub fn record_weekly_progress(&mut self, goal_id: &str, target: u32) -> bool {
let entry = self.weekly_goal_progress.entry(goal_id.to_string()).or_insert(0);
let entry = self
.weekly_goal_progress
.entry(goal_id.to_string())
.or_insert(0);
if *entry >= target {
return false;
}
@@ -237,7 +240,10 @@ mod tests {
#[test]
fn add_xp_saturates_on_overflow() {
let mut p = PlayerProgress { total_xp: u64::MAX, ..Default::default() };
let mut p = PlayerProgress {
total_xp: u64::MAX,
..Default::default()
};
p.add_xp(1);
assert_eq!(p.total_xp, u64::MAX);
}
@@ -265,8 +271,12 @@ mod tests {
#[test]
fn roll_weekly_goals_clears_progress_for_new_week() {
let mut p = PlayerProgress { weekly_goal_week_iso: Some("2026-W16".to_string()), ..Default::default() };
p.weekly_goal_progress.insert("weekly_5_wins".to_string(), 3);
let mut p = PlayerProgress {
weekly_goal_week_iso: Some("2026-W16".to_string()),
..Default::default()
};
p.weekly_goal_progress
.insert("weekly_5_wins".to_string(), 3);
let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
assert!(rolled);
@@ -276,8 +286,12 @@ mod tests {
#[test]
fn roll_weekly_goals_is_noop_for_same_week() {
let mut p = PlayerProgress { weekly_goal_week_iso: Some("2026-W17".to_string()), ..Default::default() };
p.weekly_goal_progress.insert("weekly_5_wins".to_string(), 2);
let mut p = PlayerProgress {
weekly_goal_week_iso: Some("2026-W17".to_string()),
..Default::default()
};
p.weekly_goal_progress
.insert("weekly_5_wins".to_string(), 2);
let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
assert!(!rolled);
@@ -338,7 +352,10 @@ mod tests {
p.record_daily_completion(date(2026, 4, 20));
let recorded = p.record_daily_completion(date(2026, 4, 20));
assert!(!recorded);
assert_eq!(p.daily_challenge_streak, 1, "streak must not double-count same day");
assert_eq!(
p.daily_challenge_streak, 1,
"streak must not double-count same day"
);
}
#[test]
@@ -370,11 +387,7 @@ mod tests {
p.record_daily_completion(date(2026, 4, 22));
assert_eq!(
p.daily_challenge_history,
vec![
date(2026, 4, 20),
date(2026, 4, 21),
date(2026, 4, 22),
],
vec![date(2026, 4, 20), date(2026, 4, 21), date(2026, 4, 22),],
"history should hold all three completions in ascending order"
);
}
+17 -5
View File
@@ -54,7 +54,6 @@ pub struct StatsSnapshot {
// `Default::default()` — and `u64::default()` is 0. The merge logic
// and the UI must therefore treat 0 as "no win recorded yet".
// -----------------------------------------------------------------
/// Best single score achieved in Classic mode (Draw-One or Draw-Three).
/// 0 means "no Classic win yet".
#[serde(default)]
@@ -191,16 +190,29 @@ mod tests {
#[test]
fn record_abandoned_resets_win_streak() {
let mut s = StatsSnapshot { win_streak_current: 5, ..Default::default() };
let mut s = StatsSnapshot {
win_streak_current: 5,
..Default::default()
};
s.record_abandoned();
assert_eq!(s.win_streak_current, 0, "abandoned game must break the win streak");
assert_eq!(
s.win_streak_current, 0,
"abandoned game must break the win streak"
);
}
#[test]
fn record_abandoned_preserves_best_streak() {
let mut s = StatsSnapshot { win_streak_best: 7, win_streak_current: 7, ..Default::default() };
let mut s = StatsSnapshot {
win_streak_best: 7,
win_streak_current: 7,
..Default::default()
};
s.record_abandoned();
assert_eq!(s.win_streak_best, 7, "best streak must not be reduced on abandon");
assert_eq!(
s.win_streak_best, 7,
"best streak must not be reduced on abandon"
);
assert_eq!(s.win_streak_current, 0);
}