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:
@@ -42,8 +42,8 @@
|
||||
//! real `PrimaryWindow` / camera, since `MinimalPlugins` provides
|
||||
//! neither.
|
||||
|
||||
use bevy::input::touch::Touches;
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::input::touch::Touches;
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
@@ -58,7 +58,9 @@ use crate::layout::{Layout, LayoutResource, TABLEAU_FAN_FRAC};
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, BORDER_SUBTLE_HC, STATE_SUCCESS};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, BORDER_SUBTLE_HC, STATE_SUCCESS,
|
||||
};
|
||||
|
||||
/// Seconds a finger must be held on a face-up card (without crossing the
|
||||
/// drag threshold) before the radial menu opens. Matches Android's long-press
|
||||
@@ -219,7 +221,10 @@ pub fn radial_anchor_for_index(centre: Vec2, count: usize, index: usize, radius:
|
||||
// index 0 sits at 12 o'clock and increasing indices sweep right.
|
||||
let frac = (index as f32) / (count as f32);
|
||||
let angle = std::f32::consts::TAU * frac;
|
||||
Vec2::new(centre.x + radius * angle.sin(), centre.y + radius * angle.cos())
|
||||
Vec2::new(
|
||||
centre.x + radius * angle.sin(),
|
||||
centre.y + radius * angle.cos(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns `(hit?, index)` — whether `cursor` falls within any icon's
|
||||
@@ -363,7 +368,12 @@ fn build_radial_destinations(centre: Vec2, dests: Vec<PileType>) -> Vec<(PileTyp
|
||||
dests
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, d)| (d, radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX)))
|
||||
.map(|(i, d)| {
|
||||
(
|
||||
d,
|
||||
radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -493,7 +503,9 @@ fn radial_open_on_long_press(
|
||||
let Some(touch) = touches.iter().find(|t| t.id() == active_id) else {
|
||||
return;
|
||||
};
|
||||
let Some((camera, cam_xf)) = cameras.single().ok() else { return };
|
||||
let Some((camera, cam_xf)) = cameras.single().ok() else {
|
||||
return;
|
||||
};
|
||||
let Some(world) = camera.viewport_to_world_2d(cam_xf, touch.position()).ok() else {
|
||||
return;
|
||||
};
|
||||
@@ -668,7 +680,11 @@ fn radial_redraw_overlay(
|
||||
for (i, (_pile, anchor)) in legal_destinations.iter().enumerate() {
|
||||
let focused = *hovered_index == Some(i);
|
||||
let scale = if focused { RADIAL_HOVER_SCALE } else { 1.0 };
|
||||
let fill = if focused { STATE_SUCCESS } else { ACCENT_PRIMARY };
|
||||
let fill = if focused {
|
||||
STATE_SUCCESS
|
||||
} else {
|
||||
ACCENT_PRIMARY
|
||||
};
|
||||
let outline = radial_rim_outline(focused, high_contrast);
|
||||
|
||||
commands
|
||||
@@ -758,10 +774,18 @@ mod tests {
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
g.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
g.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
// Ace of Clubs on Tableau(0).
|
||||
g.piles
|
||||
@@ -784,10 +808,18 @@ mod tests {
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
g.piles.get_mut(&PileType::Foundation(slot)).unwrap().cards.clear();
|
||||
g.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
g.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
@@ -804,7 +836,12 @@ mod tests {
|
||||
|
||||
fn install_resources(app: &mut App, state: GameState, layout_window: Vec2, cursor: Vec2) {
|
||||
app.insert_resource(GameStateResource(state));
|
||||
app.insert_resource(LayoutResource(compute_layout(layout_window, 0.0, 0.0, true)));
|
||||
app.insert_resource(LayoutResource(compute_layout(
|
||||
layout_window,
|
||||
0.0,
|
||||
0.0,
|
||||
true,
|
||||
)));
|
||||
app.world_mut().resource_mut::<RadialCursorOverride>().0 = Some(cursor);
|
||||
}
|
||||
|
||||
@@ -867,13 +904,19 @@ mod tests {
|
||||
fn radial_hovered_index_inside_box_returns_index() {
|
||||
let anchors = vec![Vec2::new(100.0, 0.0), Vec2::new(0.0, 100.0)];
|
||||
// Cursor squarely inside icon 1's box.
|
||||
assert_eq!(radial_hovered_index(Vec2::new(0.0, 100.0), &anchors), Some(1));
|
||||
assert_eq!(
|
||||
radial_hovered_index(Vec2::new(0.0, 100.0), &anchors),
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_hovered_index_outside_returns_none() {
|
||||
let anchors = vec![Vec2::new(100.0, 0.0), Vec2::new(0.0, 100.0)];
|
||||
assert_eq!(radial_hovered_index(Vec2::new(500.0, 500.0), &anchors), None);
|
||||
assert_eq!(
|
||||
radial_hovered_index(Vec2::new(500.0, 500.0), &anchors),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -888,7 +931,10 @@ mod tests {
|
||||
let dests = legal_destinations_for_card(&card, &PileType::Tableau(0), &g);
|
||||
// Ace can be placed on every empty foundation. We only need
|
||||
// the count to be ≥ 1 and the source pile to be excluded.
|
||||
assert!(!dests.is_empty(), "Ace must have at least one legal destination");
|
||||
assert!(
|
||||
!dests.is_empty(),
|
||||
"Ace must have at least one legal destination"
|
||||
);
|
||||
assert!(!dests.contains(&PileType::Tableau(0)));
|
||||
}
|
||||
|
||||
@@ -921,7 +967,10 @@ mod tests {
|
||||
|
||||
install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
|
||||
// Initial state — Idle.
|
||||
assert_eq!(*app.world().resource::<RightClickRadialState>(), RightClickRadialState::Idle);
|
||||
assert_eq!(
|
||||
*app.world().resource::<RightClickRadialState>(),
|
||||
RightClickRadialState::Idle
|
||||
);
|
||||
|
||||
press(&mut app, MouseButton::Right);
|
||||
app.update();
|
||||
@@ -939,9 +988,11 @@ mod tests {
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(cards, vec![100]);
|
||||
assert!(!legal_destinations.is_empty());
|
||||
assert!(legal_destinations
|
||||
.iter()
|
||||
.any(|(p, _)| matches!(p, PileType::Foundation(_))));
|
||||
assert!(
|
||||
legal_destinations
|
||||
.iter()
|
||||
.any(|(p, _)| matches!(p, PileType::Foundation(_)))
|
||||
);
|
||||
}
|
||||
other => panic!("expected Active, got {other:?}"),
|
||||
}
|
||||
@@ -962,7 +1013,9 @@ mod tests {
|
||||
|
||||
// Capture the destination chosen — pull anchor[0] from the state.
|
||||
let (dest_pile, anchor) = match app.world().resource::<RightClickRadialState>() {
|
||||
RightClickRadialState::Active { legal_destinations, .. } => legal_destinations[0].clone(),
|
||||
RightClickRadialState::Active {
|
||||
legal_destinations, ..
|
||||
} => legal_destinations[0].clone(),
|
||||
_ => panic!("expected Active"),
|
||||
};
|
||||
|
||||
@@ -983,7 +1036,10 @@ mod tests {
|
||||
assert_eq!(evt.to, dest_pile);
|
||||
assert_eq!(evt.count, 1);
|
||||
// State must return to Idle.
|
||||
assert_eq!(*app.world().resource::<RightClickRadialState>(), RightClickRadialState::Idle);
|
||||
assert_eq!(
|
||||
*app.world().resource::<RightClickRadialState>(),
|
||||
RightClickRadialState::Idle
|
||||
);
|
||||
}
|
||||
|
||||
/// Releasing the right button far from any icon must clear state
|
||||
@@ -1001,7 +1057,8 @@ mod tests {
|
||||
assert!(app.world().resource::<RightClickRadialState>().is_active());
|
||||
|
||||
// Move cursor far away — well outside every icon's hit-box.
|
||||
app.world_mut().resource_mut::<RadialCursorOverride>().0 = Some(Vec2::new(10_000.0, 10_000.0));
|
||||
app.world_mut().resource_mut::<RadialCursorOverride>().0 =
|
||||
Some(Vec2::new(10_000.0, 10_000.0));
|
||||
app.update();
|
||||
|
||||
clear_buttons(&mut app);
|
||||
@@ -1010,7 +1067,10 @@ mod tests {
|
||||
|
||||
let events = collect_move_events(&mut app);
|
||||
assert!(events.is_empty(), "no MoveRequestEvent on outside-release");
|
||||
assert_eq!(*app.world().resource::<RightClickRadialState>(), RightClickRadialState::Idle);
|
||||
assert_eq!(
|
||||
*app.world().resource::<RightClickRadialState>(),
|
||||
RightClickRadialState::Idle
|
||||
);
|
||||
}
|
||||
|
||||
/// Pressing Escape while the radial is active must cancel cleanly,
|
||||
@@ -1034,7 +1094,10 @@ mod tests {
|
||||
|
||||
let events = collect_move_events(&mut app);
|
||||
assert!(events.is_empty(), "no MoveRequestEvent on Escape cancel");
|
||||
assert_eq!(*app.world().resource::<RightClickRadialState>(), RightClickRadialState::Idle);
|
||||
assert_eq!(
|
||||
*app.world().resource::<RightClickRadialState>(),
|
||||
RightClickRadialState::Idle
|
||||
);
|
||||
}
|
||||
|
||||
/// Right-clicking on a face-down card must NOT open the radial.
|
||||
|
||||
Reference in New Issue
Block a user