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
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:
+142
-50
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user