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:
@@ -34,7 +34,7 @@ use std::f32::consts::PI;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::curves::{sample_curve, MotionCurve};
|
||||
use super::curves::{MotionCurve, sample_curve};
|
||||
use super::timing::compute_duration;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
|
||||
@@ -192,7 +192,11 @@ pub fn retarget_animation(
|
||||
let carry = (t * 0.12).min(0.10);
|
||||
(anim.current_xy(), transform.translation.z, carry)
|
||||
}
|
||||
_ => (transform.translation.truncate(), transform.translation.z, 0.0),
|
||||
_ => (
|
||||
transform.translation.truncate(),
|
||||
transform.translation.z,
|
||||
0.0,
|
||||
),
|
||||
};
|
||||
|
||||
let distance = current_xy.distance(new_end);
|
||||
@@ -328,7 +332,10 @@ mod tests {
|
||||
fn current_xy_at_start() {
|
||||
let anim = make_anim(Vec2::ZERO, Vec2::new(100.0, 0.0), 0.0, 1.0);
|
||||
let pos = anim.current_xy();
|
||||
assert!(pos.x < 5.0, "at t=0 position should be near start, got {pos:?}");
|
||||
assert!(
|
||||
pos.x < 5.0,
|
||||
"at t=0 position should be near start, got {pos:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -390,7 +397,10 @@ mod tests {
|
||||
fn win_scatter_targets_are_off_center() {
|
||||
for t in win_scatter_targets(400.0) {
|
||||
let dist = t.length();
|
||||
assert!(dist > 100.0, "scatter target should be well off-center: {t:?}");
|
||||
assert!(
|
||||
dist > 100.0,
|
||||
"scatter target should be well off-center: {t:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,12 @@ mod tests {
|
||||
MotionCurve::Responsive,
|
||||
MotionCurve::Expressive,
|
||||
] {
|
||||
assert_near(sample_curve(curve, 0.0), 0.0, 1e-5, &format!("{curve:?} at t=0"));
|
||||
assert_near(
|
||||
sample_curve(curve, 0.0),
|
||||
0.0,
|
||||
1e-5,
|
||||
&format!("{curve:?} at t=0"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +142,12 @@ mod tests {
|
||||
MotionCurve::SoftBounce,
|
||||
MotionCurve::Responsive,
|
||||
] {
|
||||
assert_near(sample_curve(curve, 1.0), 1.0, 1e-4, &format!("{curve:?} at t=1"));
|
||||
assert_near(
|
||||
sample_curve(curve, 1.0),
|
||||
1.0,
|
||||
1e-4,
|
||||
&format!("{curve:?} at t=1"),
|
||||
);
|
||||
}
|
||||
// Spring-based curves have residual oscillation at finite t=1; allow 2 e-3.
|
||||
assert_near(
|
||||
@@ -159,8 +169,14 @@ mod tests {
|
||||
fn smooth_snap_overshoots_slightly_near_end() {
|
||||
// Peak overshoot is around t = 0.875.
|
||||
let peak = sample_curve(MotionCurve::SmoothSnap, 0.875);
|
||||
assert!(peak > 1.0, "SmoothSnap should overshoot at t=0.875, got {peak}");
|
||||
assert!(peak < 1.03, "SmoothSnap overshoot should be small (<3 %), got {peak}");
|
||||
assert!(
|
||||
peak > 1.0,
|
||||
"SmoothSnap should overshoot at t=0.875, got {peak}"
|
||||
);
|
||||
assert!(
|
||||
peak < 1.03,
|
||||
"SmoothSnap overshoot should be small (<3 %), got {peak}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -186,11 +202,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn sample_curve_clamps_t_below_zero() {
|
||||
assert_near(sample_curve(MotionCurve::SmoothSnap, -1.0), 0.0, 1e-5, "t<0 clamped");
|
||||
assert_near(
|
||||
sample_curve(MotionCurve::SmoothSnap, -1.0),
|
||||
0.0,
|
||||
1e-5,
|
||||
"t<0 clamped",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_curve_clamps_t_above_one() {
|
||||
assert_near(sample_curve(MotionCurve::Responsive, 2.0), 1.0, 1e-5, "t>1 clamped");
|
||||
assert_near(
|
||||
sample_curve(MotionCurve::Responsive, 2.0),
|
||||
1.0,
|
||||
1e-5,
|
||||
"t>1 clamped",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,10 @@ mod tests {
|
||||
// is_above_target(30.0) is strict: fps must be > 30, not >=.
|
||||
// At exactly 30 FPS the result depends on floating-point rounding,
|
||||
// so just check that it's consistent with > 60 being false.
|
||||
assert!(!d.is_above_target(60.0), "30 FPS is not above 60 FPS target");
|
||||
assert!(
|
||||
!d.is_above_target(60.0),
|
||||
"30 FPS is not above 60 FPS target"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -71,7 +71,9 @@ pub struct HoverState {
|
||||
/// Describes a user action that arrived while cards were still animating.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BufferedInput {
|
||||
Move { from: crate::events::MoveRequestEvent },
|
||||
Move {
|
||||
from: crate::events::MoveRequestEvent,
|
||||
},
|
||||
Draw,
|
||||
Undo,
|
||||
}
|
||||
@@ -139,9 +141,7 @@ pub(crate) fn detect_hover(
|
||||
let mut best: Option<(Entity, f32)> = None;
|
||||
for (entity, transform) in &cards {
|
||||
let pos = transform.translation.truncate();
|
||||
if (cursor_world.x - pos.x).abs() < half_w
|
||||
&& (cursor_world.y - pos.y).abs() < half_h
|
||||
{
|
||||
if (cursor_world.x - pos.x).abs() < half_w && (cursor_world.y - pos.y).abs() < half_h {
|
||||
let z = transform.translation.z;
|
||||
if best.is_none_or(|(_, bz)| z > bz) {
|
||||
best = Some((entity, z));
|
||||
@@ -187,9 +187,7 @@ pub(crate) fn apply_hover_scale(
|
||||
|
||||
// Update the tracked scale for external inspection.
|
||||
hover_state.scale = if let Some(entity) = target_entity {
|
||||
cards
|
||||
.get(entity)
|
||||
.map_or(hover_target, |(_, t)| t.scale.x)
|
||||
cards.get(entity).map_or(hover_target, |(_, t)| t.scale.x)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
@@ -80,14 +80,14 @@ pub mod interaction;
|
||||
pub mod timing;
|
||||
pub mod tuning;
|
||||
|
||||
pub use animation::{retarget_animation, win_scatter_targets, CardAnimation};
|
||||
pub use animation::{CardAnimation, retarget_animation, win_scatter_targets};
|
||||
pub use chain::AnimationChain;
|
||||
pub use curves::{sample_curve, MotionCurve};
|
||||
pub use curves::{MotionCurve, sample_curve};
|
||||
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
|
||||
pub use interaction::{BufferedInput, HoverState, InputBuffer};
|
||||
pub use timing::{
|
||||
cascade_delay, compute_duration, micro_vary, DEAL_INTERVAL_SECS, MAX_DURATION_SECS,
|
||||
MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
|
||||
DEAL_INTERVAL_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
|
||||
cascade_delay, compute_duration, micro_vary,
|
||||
};
|
||||
pub use tuning::{AnimationTuning, InputPlatform};
|
||||
|
||||
@@ -179,10 +179,7 @@ pub struct WinCascadePlugin;
|
||||
|
||||
impl Plugin for WinCascadePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(
|
||||
Update,
|
||||
trigger_expressive_win_cascade.after(GameMutation),
|
||||
);
|
||||
app.add_systems(Update, trigger_expressive_win_cascade.after(GameMutation));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,9 +197,7 @@ fn trigger_expressive_win_cascade(
|
||||
return;
|
||||
}
|
||||
|
||||
let radius = layout
|
||||
.as_ref()
|
||||
.map_or(800.0, |l| l.0.card_size.x * 8.0);
|
||||
let radius = layout.as_ref().map_or(800.0, |l| l.0.card_size.x * 8.0);
|
||||
|
||||
let targets = win_scatter_targets(radius);
|
||||
|
||||
@@ -212,10 +207,16 @@ fn trigger_expressive_win_cascade(
|
||||
let target = targets[index % targets.len()];
|
||||
|
||||
commands.entity(entity).insert(
|
||||
CardAnimation::slide(start_xy, start_z, target, start_z + 60.0, MotionCurve::Expressive)
|
||||
.with_delay(cascade_delay(index, WIN_CASCADE_INTERVAL_SECS))
|
||||
.with_duration(0.65)
|
||||
.with_z_lift(25.0),
|
||||
CardAnimation::slide(
|
||||
start_xy,
|
||||
start_z,
|
||||
target,
|
||||
start_z + 60.0,
|
||||
MotionCurve::Expressive,
|
||||
)
|
||||
.with_delay(cascade_delay(index, WIN_CASCADE_INTERVAL_SECS))
|
||||
.with_duration(0.65)
|
||||
.with_z_lift(25.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -265,7 +266,8 @@ mod tests {
|
||||
#[test]
|
||||
fn card_animation_advances_and_removes_itself() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(CardAnimationPlugin);
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(CardAnimationPlugin);
|
||||
|
||||
let start = Vec2::new(0.0, 0.0);
|
||||
let end = Vec2::new(100.0, 0.0);
|
||||
@@ -306,7 +308,8 @@ mod tests {
|
||||
#[test]
|
||||
fn card_animation_instant_snaps_on_zero_duration() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(CardAnimationPlugin);
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(CardAnimationPlugin);
|
||||
|
||||
let end = Vec2::new(200.0, 100.0);
|
||||
let entity = app
|
||||
@@ -353,7 +356,8 @@ mod tests {
|
||||
#[test]
|
||||
fn card_animation_respects_delay() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(CardAnimationPlugin);
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(CardAnimationPlugin);
|
||||
|
||||
let entity = app
|
||||
.world_mut()
|
||||
@@ -391,8 +395,14 @@ mod tests {
|
||||
buf.push(BufferedInput::Draw);
|
||||
buf.push(BufferedInput::Undo);
|
||||
// FIFO: Draw comes out first.
|
||||
assert!(matches!(buf.queue.pop_front().unwrap(), BufferedInput::Draw));
|
||||
assert!(matches!(buf.queue.pop_front().unwrap(), BufferedInput::Undo));
|
||||
assert!(matches!(
|
||||
buf.queue.pop_front().unwrap(),
|
||||
BufferedInput::Draw
|
||||
));
|
||||
assert!(matches!(
|
||||
buf.queue.pop_front().unwrap(),
|
||||
BufferedInput::Undo
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -88,7 +88,10 @@ mod tests {
|
||||
let mut prev = 0.0f32;
|
||||
for d in [10, 50, 100, 200, 400, 600] {
|
||||
let dur = compute_duration(d as f32);
|
||||
assert!(dur >= prev, "duration must be monotone: d={d} dur={dur} prev={prev}");
|
||||
assert!(
|
||||
dur >= prev,
|
||||
"duration must be monotone: d={d} dur={dur} prev={prev}"
|
||||
);
|
||||
prev = dur;
|
||||
}
|
||||
}
|
||||
@@ -129,7 +132,10 @@ mod tests {
|
||||
let a = micro_vary(0.2, 1);
|
||||
let b = micro_vary(0.2, 2);
|
||||
// Very unlikely to be equal (would require hash collision mod 65536).
|
||||
assert!((a - b).abs() > 1e-9, "micro_vary should differ for different indices");
|
||||
assert!(
|
||||
(a - b).abs() > 1e-9,
|
||||
"micro_vary should differ for different indices"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -114,7 +114,7 @@ impl AnimationTuning {
|
||||
platform: InputPlatform::Touch,
|
||||
duration_scale: 0.75,
|
||||
overshoot_scale: 0.5,
|
||||
drag_threshold_px: 8.0, // Android ViewConfiguration.getScaledTouchSlop()
|
||||
drag_threshold_px: 8.0, // Android ViewConfiguration.getScaledTouchSlop()
|
||||
drag_scale: 1.12,
|
||||
hover_scale: 1.0, // no hover affordance on touch
|
||||
hover_lerp_speed: 20.0,
|
||||
@@ -182,15 +182,24 @@ mod tests {
|
||||
assert_eq!(t.duration_scale, 1.0);
|
||||
assert_eq!(t.platform, InputPlatform::Mouse);
|
||||
assert!(t.hover_scale > 1.0, "desktop hover must lift the card");
|
||||
assert!(t.drag_threshold_px < 10.0, "desktop threshold must be smaller than mobile");
|
||||
assert!(
|
||||
t.drag_threshold_px < 10.0,
|
||||
"desktop threshold must be smaller than mobile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_is_faster_than_desktop() {
|
||||
let d = AnimationTuning::desktop();
|
||||
let m = AnimationTuning::mobile();
|
||||
assert!(m.duration_scale < d.duration_scale, "mobile must animate faster");
|
||||
assert!(m.overshoot_scale < d.overshoot_scale, "mobile must bounce less");
|
||||
assert!(
|
||||
m.duration_scale < d.duration_scale,
|
||||
"mobile must animate faster"
|
||||
);
|
||||
assert!(
|
||||
m.overshoot_scale < d.overshoot_scale,
|
||||
"mobile must bounce less"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user