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