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
+87 -41
View File
@@ -17,7 +17,7 @@ use solitaire_data::{AnimSpeed, Settings};
use crate::achievement_plugin::display_name_for;
use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::{sample_curve, CardAnimation, MotionCurve};
use crate::card_animation::{CardAnimation, MotionCurve, sample_curve};
use crate::card_plugin::CardEntity;
use crate::challenge_plugin::ChallengeAdvancedEvent;
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
@@ -32,9 +32,9 @@ use crate::progress_plugin::LevelUpEvent;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::time_attack_plugin::TimeAttackEndedEvent;
use crate::ui_theme::{
scaled_duration, ACCENT_SECONDARY, BG_ELEVATED, MOTION_CASCADE_SLIDE_SECS,
MOTION_CASCADE_STAGGER_SECS, MOTION_SLIDE_SECS, RADIUS_MD, STATE_DANGER, STATE_INFO,
STATE_WARNING, TEXT_PRIMARY, TYPE_BODY_LG, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_TOAST,
ACCENT_SECONDARY, BG_ELEVATED, MOTION_CASCADE_SLIDE_SECS, MOTION_CASCADE_STAGGER_SECS,
MOTION_SLIDE_SECS, RADIUS_MD, STATE_DANGER, STATE_INFO, STATE_WARNING, TEXT_PRIMARY,
TYPE_BODY_LG, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_TOAST, scaled_duration,
};
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
@@ -53,7 +53,9 @@ pub struct EffectiveSlideDuration {
impl Default for EffectiveSlideDuration {
fn default() -> Self {
Self { slide_secs: SLIDE_SECS }
Self {
slide_secs: SLIDE_SECS,
}
}
}
@@ -329,12 +331,12 @@ fn handle_win_cascade(
Vec3::new(-margin, 0.0, 300.0),
];
let step = settings
.as_ref()
.map_or(CASCADE_STAGGER_NORMAL, |s| cascade_step_secs(s.0.animation_speed));
let duration = settings
.as_ref()
.map_or(CASCADE_DURATION_NORMAL, |s| cascade_duration_secs(s.0.animation_speed));
let step = settings.as_ref().map_or(CASCADE_STAGGER_NORMAL, |s| {
cascade_step_secs(s.0.animation_speed)
});
let duration = settings.as_ref().map_or(CASCADE_DURATION_NORMAL, |s| {
cascade_duration_secs(s.0.animation_speed)
});
for (i, (entity, transform)) in cards.iter().enumerate() {
// Use the curve-aware `CardAnimation` here (not `CardAnim`) so we can
@@ -444,7 +446,11 @@ fn handle_time_attack_toast(
for ev in events.read() {
spawn_toast(
&mut commands,
format!("Time Attack: {} win{}", ev.wins, if ev.wins == 1 { "" } else { "s" }),
format!(
"Time Attack: {} win{}",
ev.wins,
if ev.wins == 1 { "" } else { "s" }
),
TIME_ATTACK_TOAST_SECS,
ToastVariant::Info,
);
@@ -528,10 +534,7 @@ fn handle_auto_complete_toast(
/// This is the first half of the two-system toast queue (Task #67). The queue
/// decouples event production from rendering so multiple simultaneous events do
/// not cause overlapping toast text on screen.
fn enqueue_toasts(
mut events: MessageReader<InfoToastEvent>,
mut queue: ResMut<ToastQueue>,
) {
fn enqueue_toasts(mut events: MessageReader<InfoToastEvent>, mut queue: ResMut<ToastQueue>) {
for ev in events.read() {
queue.0.push_back(ev.0.clone());
}
@@ -572,11 +575,12 @@ fn drive_toast_display(
// If no active toast and the queue has messages, show the next one.
if active.entity.is_none()
&& let Some(message) = queue.0.pop_front() {
let entity = spawn_queued_toast(&mut commands, message);
active.entity = Some(entity);
active.timer = QUEUED_TOAST_SECS;
}
&& let Some(message) = queue.0.pop_front()
{
let entity = spawn_queued_toast(&mut commands, message);
active.entity = Some(entity);
active.timer = QUEUED_TOAST_SECS;
}
}
/// Visual variant of a toast — drives the 1px border accent per the
@@ -682,10 +686,7 @@ fn handle_move_rejected_toast(
/// Mirrors [`handle_move_rejected_toast`] but reads a generic carrier
/// event (not a domain-specific one) because Warning has multiple
/// candidate drivers and the call-site knows the message wording.
fn handle_warning_toast(
mut commands: Commands,
mut events: MessageReader<WarningToastEvent>,
) {
fn handle_warning_toast(mut commands: Commands, mut events: MessageReader<WarningToastEvent>) {
for ev in events.read() {
spawn_toast(&mut commands, ev.0.clone(), 4.0, ToastVariant::Warning);
}
@@ -832,7 +833,11 @@ mod tests {
reduce_motion_mode: true,
..Settings::default()
};
assert_eq!(effective_slide_secs(&s), 0.0, "Fast + reduce-motion still 0.0");
assert_eq!(
effective_slide_secs(&s),
0.0,
"Fast + reduce-motion still 0.0"
);
}
#[test]
@@ -869,13 +874,24 @@ mod tests {
.world_mut()
.spawn((
Transform::from_translation(start),
CardAnim { start, target, elapsed: 0.5, duration: 1.0, delay: 0.0 },
CardAnim {
start,
target,
elapsed: 0.5,
duration: 1.0,
delay: 0.0,
},
))
.id();
app.update();
let pos = app.world().entity(entity).get::<Transform>().unwrap().translation;
let pos = app
.world()
.entity(entity)
.get::<Transform>()
.unwrap()
.translation;
assert!(
pos.x > 50.0 && pos.x < 100.0,
"with SmoothSnap, t=0.5 should be past geometric midpoint but short of target; got {}",
@@ -897,7 +913,13 @@ mod tests {
.world_mut()
.spawn((
Transform::from_translation(Vec3::ZERO),
CardAnim { start: Vec3::ZERO, target, elapsed: 1.0, duration: 1.0, delay: 0.0 },
CardAnim {
start: Vec3::ZERO,
target,
elapsed: 1.0,
duration: 1.0,
delay: 0.0,
},
))
.id();
@@ -907,7 +929,12 @@ mod tests {
app.world().entity(entity).get::<CardAnim>().is_none(),
"CardAnim should be removed when done"
);
let pos = app.world().entity(entity).get::<Transform>().unwrap().translation;
let pos = app
.world()
.entity(entity)
.get::<Transform>()
.unwrap()
.translation;
assert!((pos.x - 10.0).abs() < 1e-3);
}
@@ -932,7 +959,12 @@ mod tests {
app.update();
let pos = app.world().entity(entity).get::<Transform>().unwrap().translation;
let pos = app
.world()
.entity(entity)
.get::<Transform>()
.unwrap()
.translation;
assert!(pos.x.abs() < 1e-3, "card must not move during delay period");
}
@@ -1021,7 +1053,8 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
app.world_mut().write_message(InfoToastEvent("hello".to_string()));
app.world_mut()
.write_message(InfoToastEvent("hello".to_string()));
app.update();
let count = app
@@ -1125,8 +1158,12 @@ mod tests {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
let fast_settings = Settings { animation_speed: AnimSpeed::Fast, ..Default::default() };
app.world_mut().write_message(SettingsChangedEvent(fast_settings));
let fast_settings = Settings {
animation_speed: AnimSpeed::Fast,
..Default::default()
};
app.world_mut()
.write_message(SettingsChangedEvent(fast_settings));
app.update();
let dur = app.world().resource::<EffectiveSlideDuration>().slide_secs;
@@ -1144,8 +1181,10 @@ mod tests {
.count();
assert_eq!(before, 0, "no animations before win");
app.world_mut()
.write_message(GameWonEvent { score: 500, time_seconds: 60 });
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 60,
});
app.update();
let after = app
@@ -1162,8 +1201,10 @@ mod tests {
#[test]
fn win_cascade_uses_expressive_curve() {
let mut app = app_with_anim();
app.world_mut()
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 0,
});
app.update();
let mut q = app.world_mut().query::<&CardAnimation>();
@@ -1179,8 +1220,10 @@ mod tests {
#[test]
fn win_cascade_applies_per_card_rotation() {
let mut app = app_with_anim();
app.world_mut()
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 0,
});
app.update();
// At least one card's rotation must differ from identity — the
@@ -1190,7 +1233,10 @@ mod tests {
let any_rotated = q
.iter(app.world())
.any(|(_, t)| t.rotation.z.abs() > 1e-4 || t.rotation.w < 0.999);
assert!(any_rotated, "expected at least one card to receive a Z rotation drift");
assert!(
any_rotated,
"expected at least one card to receive a Z rotation drift"
);
}
#[test]