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
+93 -55
View File
@@ -13,7 +13,7 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
use solitaire_data::{daily_seed_for, save_progress_to};
use solitaire_sync::ChallengeGoal;
@@ -89,6 +89,16 @@ struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
#[derive(Resource, Default, Debug)]
struct DailyExpiryWarningShown(Option<NaiveDate>);
/// Throttle timer so `check_date_rollover` does not call `Local::now()` every frame.
#[derive(Resource)]
struct DateRolloverTimer(Timer);
impl Default for DateRolloverTimer {
fn default() -> Self {
Self(Timer::from_seconds(60.0, TimerMode::Repeating))
}
}
/// Fetches today's daily challenge seed and goal from the sync server on startup and tracks completion.
/// Fires `DailyChallengeCompletedEvent` when the player wins a matching game.
pub struct DailyChallengePlugin;
@@ -98,6 +108,7 @@ impl Plugin for DailyChallengePlugin {
app.insert_resource(DailyChallengeResource::for_today())
.init_resource::<DailyChallengeTask>()
.init_resource::<DailyExpiryWarningShown>()
.init_resource::<DateRolloverTimer>()
.add_message::<DailyChallengeCompletedEvent>()
.add_message::<DailyGoalAnnouncementEvent>()
.add_message::<GameWonEvent>()
@@ -111,7 +122,8 @@ impl Plugin for DailyChallengePlugin {
// ProgressPlugin's add_xp on the same frame.
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
.add_systems(Update, handle_start_daily_request.before(GameMutation))
.add_systems(Update, check_daily_expiry_warning);
.add_systems(Update, check_daily_expiry_warning)
.add_systems(Update, check_date_rollover);
}
}
@@ -161,8 +173,7 @@ fn poll_server_challenge(
daily.max_time_secs = goal.max_time_secs;
info!(
"daily challenge seed updated from server: {old_seed} → {} ({})",
goal.seed,
goal.description
goal.seed, goal.description
);
}
}
@@ -184,28 +195,35 @@ fn handle_daily_completion(
}
// Enforce server-supplied goal constraints when present.
if let Some(target) = daily.target_score
&& ev.score < target {
continue; // score goal not met
}
&& ev.score < target
{
continue; // score goal not met
}
if let Some(max_secs) = daily.max_time_secs
&& ev.time_seconds > max_secs {
continue; // time limit exceeded
}
&& ev.time_seconds > max_secs
{
continue; // time limit exceeded
}
if !progress.0.record_daily_completion(daily.date) {
// Already counted today — no-op.
continue;
}
progress.0.add_xp(DAILY_BONUS_XP);
xp_awarded.write(XpAwardedEvent { amount: DAILY_BONUS_XP });
xp_awarded.write(XpAwardedEvent {
amount: DAILY_BONUS_XP,
});
if let Some(target) = &path.0
&& let Err(e) = save_progress_to(target, &progress.0) {
warn!("failed to save progress after daily completion: {e}");
}
&& let Err(e) = save_progress_to(target, &progress.0)
{
warn!("failed to save progress after daily completion: {e}");
}
completed.write(DailyChallengeCompletedEvent {
date: daily.date,
streak: progress.0.daily_challenge_streak,
});
toast.write(InfoToastEvent("Daily challenge complete! +100 XP".to_string()));
toast.write(InfoToastEvent(
"Daily challenge complete! +100 XP".to_string(),
));
}
}
@@ -298,12 +316,40 @@ fn check_daily_expiry_warning(
)));
}
/// Detects when the local calendar day changes while the app is running
/// (e.g. the app stays open past midnight) and refreshes the daily
/// challenge resource for the new day.
fn check_date_rollover(
time: Res<Time>,
mut timer: ResMut<DateRolloverTimer>,
mut daily: ResMut<DailyChallengeResource>,
mut shown: ResMut<DailyExpiryWarningShown>,
) {
timer.0.tick(time.delta());
if !timer.0.just_finished() {
return;
}
let today = Local::now().date_naive();
if today != daily.date {
info!(
"daily_challenge: date rolled over from {} to {}; refreshing challenge",
daily.date, today
);
*daily = DailyChallengeResource::for_today();
// Reset the expiry-warning state so the new day's warning can fire.
shown.0 = None;
}
}
#[cfg(test)]
#[allow(dead_code)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin;
#[allow(unused_imports)]
use solitaire_core::game_state::{DrawMode, GameState};
fn headless_app() -> App {
@@ -346,7 +392,9 @@ mod tests {
// +100 from the daily bonus
assert!(progress.total_xp >= DAILY_BONUS_XP);
let events = app.world().resource::<Messages<DailyChallengeCompletedEvent>>();
let events = app
.world()
.resource::<Messages<DailyChallengeCompletedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1);
@@ -370,7 +418,9 @@ mod tests {
let progress = &app.world().resource::<ProgressResource>().0;
assert_eq!(progress.daily_challenge_streak, 0);
let events = app.world().resource::<Messages<DailyChallengeCompletedEvent>>();
let events = app
.world()
.resource::<Messages<DailyChallengeCompletedEvent>>();
let mut cursor = events.get_cursor();
assert!(cursor.read(events).next().is_none());
}
@@ -395,7 +445,10 @@ mod tests {
app.update();
let progress = &app.world().resource::<ProgressResource>().0;
assert_eq!(progress.daily_challenge_streak, 1, "streak does not double-count");
assert_eq!(
progress.daily_challenge_streak, 1,
"streak does not double-count"
);
}
#[test]
@@ -428,7 +481,9 @@ mod tests {
.press(KeyCode::KeyC);
app.update();
let events = app.world().resource::<Messages<DailyGoalAnnouncementEvent>>();
let events = app
.world()
.resource::<Messages<DailyGoalAnnouncementEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).cloned().collect();
assert_eq!(fired.len(), 1);
@@ -439,14 +494,21 @@ mod tests {
fn pressing_c_with_no_description_uses_fallback() {
let mut app = headless_app();
// Ensure no description is set.
assert!(app.world().resource::<DailyChallengeResource>().goal_description.is_none());
assert!(
app.world()
.resource::<DailyChallengeResource>()
.goal_description
.is_none()
);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyC);
app.update();
let events = app.world().resource::<Messages<DailyGoalAnnouncementEvent>>();
let events = app
.world()
.resource::<Messages<DailyGoalAnnouncementEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).cloned().collect();
assert_eq!(fired.len(), 1);
@@ -511,13 +573,8 @@ mod tests {
fn warning_suppressed_when_already_completed_today() {
// 23:50 UTC inside threshold, but today is already done.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
Some(ymd(2026, 5, 8)),
None,
now,
30,
);
let mins =
compute_expiry_warning_minutes(ymd(2026, 5, 8), Some(ymd(2026, 5, 8)), None, now, 30);
assert_eq!(mins, None);
}
@@ -525,26 +582,16 @@ mod tests {
fn warning_suppressed_when_yesterdays_completion_is_stale() {
// Yesterday's completion is irrelevant — we want to warn about today.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
Some(ymd(2026, 5, 7)),
None,
now,
30,
);
let mins =
compute_expiry_warning_minutes(ymd(2026, 5, 8), Some(ymd(2026, 5, 7)), None, now, 30);
assert_eq!(mins, Some(10));
}
#[test]
fn warning_suppressed_when_already_shown_for_this_date() {
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
None,
Some(ymd(2026, 5, 8)),
now,
30,
);
let mins =
compute_expiry_warning_minutes(ymd(2026, 5, 8), None, Some(ymd(2026, 5, 8)), now, 30);
assert_eq!(mins, None);
}
@@ -553,13 +600,8 @@ mod tests {
// Player kept the app open across a midnight rollover. Stale
// "shown" date doesn't suppress today's warning.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
None,
Some(ymd(2026, 5, 7)),
now,
30,
);
let mins =
compute_expiry_warning_minutes(ymd(2026, 5, 8), None, Some(ymd(2026, 5, 7)), now, 30);
assert_eq!(mins, Some(10));
}
@@ -578,9 +620,7 @@ mod tests {
let today = app.world().resource::<DailyChallengeResource>().date;
// Pre-mark warning as already shown for today.
app.world_mut()
.resource_mut::<DailyExpiryWarningShown>()
.0 = Some(today);
app.world_mut().resource_mut::<DailyExpiryWarningShown>().0 = Some(today);
// Flush any stale events from headless_app()'s initial update (the
// double-buffer keeps them visible for one extra frame).
app.update();
@@ -596,9 +636,7 @@ mod tests {
);
// Reset shown, mark today as completed.
app.world_mut()
.resource_mut::<DailyExpiryWarningShown>()
.0 = None;
app.world_mut().resource_mut::<DailyExpiryWarningShown>().0 = None;
app.world_mut()
.resource_mut::<ProgressResource>()
.0