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