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