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
+182 -86
View File
@@ -14,21 +14,8 @@ use solitaire_core::pile::PileType;
use crate::auto_complete_plugin::AutoCompleteState;
use crate::avatar_plugin::AvatarResource;
use solitaire_data::SyncBackend;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::progress_plugin::ProgressResource;
use crate::settings_plugin::SettingsResource;
use crate::layout::HUD_BAND_HEIGHT;
use crate::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop};
use crate::ui_theme::SPACE_2;
use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
};
use crate::events::{
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
@@ -40,18 +27,32 @@ use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation;
#[cfg(target_os = "android")]
use crate::input_plugin::TouchDragSet;
use crate::layout::HUD_BAND_HEIGHT;
use crate::layout::LayoutSystem;
use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT};
#[cfg(target_os = "android")]
use crate::pause_plugin::PausedResource;
use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT};
use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource;
#[cfg(target_os = "android")]
use crate::resources::{DragState, GameInputConsumedResource};
use crate::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop};
use crate::selection_plugin::SelectionState;
use crate::settings_plugin::SettingsResource;
use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_focus::{FocusGroup, Focusable};
use crate::ui_modal::ModalScrim;
use crate::ui_theme::SPACE_2;
use crate::ui_theme::{
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
scaled_duration,
};
use crate::ui_tooltip::Tooltip;
use solitaire_data::SyncBackend;
/// Marker on the score text node.
#[derive(Component, Debug)]
@@ -310,9 +311,25 @@ pub struct HintButton;
pub(crate) const ANDROID_HINT_LABEL: &str = "!";
#[cfg(target_os = "android")]
const ACTION_BAR_LABELS: [&str; 7] = ["\u{2261}", "\u{2190}", "||", "?", ANDROID_HINT_LABEL, "M", "+"];
const ACTION_BAR_LABELS: [&str; 7] = [
"\u{2261}",
"\u{2190}",
"||",
"?",
ANDROID_HINT_LABEL,
"M",
"+",
];
#[cfg(not(target_os = "android"))]
const ACTION_BAR_LABELS: [&str; 7] = ["Menu \u{2193}", "Undo", "Pause", "Help", "Hint", "Modes \u{2193}", "New Game"];
const ACTION_BAR_LABELS: [&str; 7] = [
"Menu \u{2193}",
"Undo",
"Pause",
"Help",
"Hint",
"Modes \u{2193}",
"New Game",
];
#[cfg(target_os = "android")]
const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0);
#[cfg(not(target_os = "android"))]
@@ -564,10 +581,7 @@ fn spawn_hud_band(mut commands: Commands) {
/// player's #1 complaint. This restructure groups by purpose, lets
/// transient items disappear cleanly, and uses the typography scale to
/// make Score the visual protagonist.
fn spawn_hud(
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
let font_score = TextFont {
font: font_handle.clone(),
@@ -637,9 +651,7 @@ fn spawn_hud(
));
t1.spawn((
HudMoves,
Tooltip::new(
"Moves you've made this game. Counts placements and stock draws.",
),
Tooltip::new("Moves you've made this game. Counts placements and stock draws."),
Text::new("Moves: 0"),
font_lg.clone(),
TextColor(TEXT_SECONDARY),
@@ -680,9 +692,7 @@ fn spawn_hud(
));
t2.spawn((
HudWonPreviously,
Tooltip::new(
"You've won this deal before. Same seed in your replay history.",
),
Tooltip::new("You've won this deal before. Same seed in your replay history."),
Text::new(""),
font_body.clone(),
TextColor(STATE_SUCCESS),
@@ -695,9 +705,7 @@ fn spawn_hud(
hud.spawn(row_node()).with_children(|t3| {
t3.spawn((
HudUndos,
Tooltip::new(
"Undos used this game. Any undo blocks the No Undo achievement.",
),
Tooltip::new("Undos used this game. Any undo blocks the No Undo achievement."),
Text::new(""),
font_body.clone(),
TextColor(STATE_WARNING),
@@ -874,7 +882,8 @@ fn spawn_action_buttons(
windows: Query<&Window>,
mut commands: Commands,
) {
let action_font_size = action_bar_font_size(windows.iter().next().map_or(900.0, |win| win.width()));
let action_font_size =
action_bar_font_size(windows.iter().next().map_or(900.0, |win| win.width()));
let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
font_size: action_font_size,
@@ -915,13 +924,76 @@ fn spawn_action_buttons(
// so Tab cycles the action bar in visual reading order.
// Undo and Pause are the primary gameplay actions — full brightness.
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
spawn_action_button(row, MenuButton, ACTION_BAR_LABELS[0], None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0, TEXT_SECONDARY);
spawn_action_button(row, UndoButton, ACTION_BAR_LABELS[1], Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1, TEXT_PRIMARY);
spawn_action_button(row, PauseButton, ACTION_BAR_LABELS[2], Some("Esc"), "Pause the game and freeze the timer.", &font, 2, TEXT_PRIMARY);
spawn_action_button(row, HelpButton, ACTION_BAR_LABELS[3], Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3, TEXT_SECONDARY);
spawn_action_button(row, HintButton, ACTION_BAR_LABELS[4], Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4, TEXT_SECONDARY);
spawn_action_button(row, ModesButton, ACTION_BAR_LABELS[5], None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5, TEXT_SECONDARY);
spawn_action_button(row, NewGameButton, ACTION_BAR_LABELS[6], Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, 6, TEXT_SECONDARY);
spawn_action_button(
row,
MenuButton,
ACTION_BAR_LABELS[0],
None,
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
&font,
0,
TEXT_SECONDARY,
);
spawn_action_button(
row,
UndoButton,
ACTION_BAR_LABELS[1],
Some("U"),
"Take back your last move. Costs points and blocks No Undo.",
&font,
1,
TEXT_PRIMARY,
);
spawn_action_button(
row,
PauseButton,
ACTION_BAR_LABELS[2],
Some("Esc"),
"Pause the game and freeze the timer.",
&font,
2,
TEXT_PRIMARY,
);
spawn_action_button(
row,
HelpButton,
ACTION_BAR_LABELS[3],
Some("F1"),
"Show controls, rules, and keyboard shortcuts.",
&font,
3,
TEXT_SECONDARY,
);
spawn_action_button(
row,
HintButton,
ACTION_BAR_LABELS[4],
Some("H"),
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
&font,
4,
TEXT_SECONDARY,
);
spawn_action_button(
row,
ModesButton,
ACTION_BAR_LABELS[5],
None,
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
&font,
5,
TEXT_SECONDARY,
);
spawn_action_button(
row,
NewGameButton,
ACTION_BAR_LABELS[6],
Some("N"),
"Start a fresh deal. Confirms first if a game is in progress.",
&font,
6,
TEXT_SECONDARY,
);
});
}
@@ -952,7 +1024,11 @@ fn spawn_action_button<M: Component>(
// touch device — the button itself is the affordance — and they
// visibly clutter the narrow-viewport action row. The chevrons on
// Menu/Modes remain because they indicate dropdown behaviour.
let hotkey = if SHOW_KEYBOARD_ACCELERATORS { hotkey } else { None };
let hotkey = if SHOW_KEYBOARD_ACCELERATORS {
hotkey
} else {
None
};
let hotkey_font = TextFont {
font: font.font.clone(),
@@ -1082,9 +1158,7 @@ fn handle_modes_button(
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
let pressed = interaction_query
.iter()
.any(|i| *i == Interaction::Pressed);
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
if !pressed {
return;
}
@@ -1261,9 +1335,7 @@ fn handle_mode_option_click(
}
}
}
if clicked_any
&& let Ok(entity) = popovers.single()
{
if clicked_any && let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
@@ -1282,9 +1354,7 @@ fn handle_menu_button(
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
let pressed = interaction_query
.iter()
.any(|i| *i == Interaction::Pressed);
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
if !pressed {
return;
}
@@ -1462,13 +1532,12 @@ fn handle_menu_option_click(
}
}
}
if clicked_any
&& let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
if clicked_any && let Ok(entity) = popovers.single() {
commands.entity(entity).despawn();
for e in &backdrops {
commands.entity(e).despawn();
}
}
if open_modes {
spawn_modes_popover(
&mut commands,
@@ -1587,11 +1656,7 @@ const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
/// `target` at a fixed rate so the visual transition is smooth across
/// variable framerates.
fn update_action_fade(
windows: Query<&Window>,
time: Res<Time>,
mut fade: ResMut<HudActionFade>,
) {
fn update_action_fade(windows: Query<&Window>, time: Res<Time>, mut fade: ResMut<HudActionFade>) {
let Ok(window) = windows.single() else {
return;
};
@@ -2022,12 +2087,14 @@ fn update_won_previously(
let won_before = !game.0.is_won
&& history.as_ref().is_some_and(|h| {
h.0.replays.iter().any(|r| {
r.seed == game.0.seed
&& r.draw_mode == game.0.draw_mode
&& r.mode == game.0.mode
r.seed == game.0.seed && r.draw_mode == game.0.draw_mode && r.mode == game.0.mode
})
});
let next = if won_before { "\u{2713} Won before" } else { "" };
let next = if won_before {
"\u{2713} Won before"
} else {
""
};
if text.0 != next {
text.0 = next.to_string();
}
@@ -2290,13 +2357,14 @@ fn update_hud(
let ac_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
let ac_changed = auto_complete.as_ref().is_some_and(|ac| ac.is_changed());
if (ac_changed || game.is_changed())
&& let Ok(mut t) = auto_q.single_mut() {
**t = if ac_active {
"AUTO".to_string()
} else {
String::new()
};
}
&& let Ok(mut t) = auto_q.single_mut()
{
**t = if ac_active {
"AUTO".to_string()
} else {
String::new()
};
}
}
/// Updates the `HudSelection` text node to show which pile is Tab-selected.
@@ -2480,9 +2548,17 @@ fn action_bar_font_size(window_width: f32) -> f32 {
fn action_button_metrics() -> (UiRect, Val, Val) {
if USE_TOUCH_UI_LAYOUT {
(UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(52.0), Val::Px(44.0))
(
UiRect::axes(Val::Px(4.0), Val::Px(4.0)),
Val::Px(52.0),
Val::Px(44.0),
)
} else {
(UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), Val::Px(48.0), Val::Px(48.0))
(
UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
Val::Px(48.0),
Val::Px(48.0),
)
}
}
@@ -2493,7 +2569,12 @@ fn spawn_action_button_label(
text_color: Color,
) {
if USE_TOUCH_UI_LAYOUT {
parent.spawn((ActionButtonLabel, Text::new(label), font.clone(), TextColor(text_color)));
parent.spawn((
ActionButtonLabel,
Text::new(label),
font.clone(),
TextColor(text_color),
));
} else {
parent.spawn((Text::new(label), font.clone(), TextColor(text_color)));
}
@@ -2508,7 +2589,10 @@ fn resize_action_bar_labels(
windows: Query<&Window>,
mut labels: Query<&mut TextFont, With<ActionButtonLabel>>,
) {
let w = windows.iter().next().map_or(layout.0.card_size.x * 7.25, |win| win.width());
let w = windows
.iter()
.next()
.map_or(layout.0.card_size.x * 7.25, |win| win.width());
let new_size = action_bar_font_size(w);
for mut font in &mut labels {
font.font_size = new_size;
@@ -2545,8 +2629,7 @@ fn toggle_hud_on_tap(
// Record whether the finger-down landed on a button so
// the finger-up doesn't double-fire (toggle bar + press
// button at the same time).
tracker.started_on_button =
buttons.iter().any(|i| *i != Interaction::None);
tracker.started_on_button = buttons.iter().any(|i| *i != Interaction::None);
}
TouchPhase::Ended if drag.is_idle() => {
// Also treat taps where game logic consumed the touch (e.g.
@@ -2630,7 +2713,10 @@ mod tests {
#[test]
fn moves_reflects_game_state() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.move_count = 42;
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count = 42;
app.update();
assert_eq!(read_hud_text::<HudMoves>(&mut app), "Moves: 42");
}
@@ -2660,7 +2746,10 @@ mod tests {
#[test]
fn time_display_uses_mm_ss_format() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.elapsed_seconds = 125;
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.elapsed_seconds = 125;
app.update();
// 125 seconds = 2 minutes 5 seconds → "2:05"
assert_eq!(read_hud_text::<HudTime>(&mut app), "2:05");
@@ -2834,7 +2923,10 @@ mod tests {
#[test]
fn undos_hud_shows_count_after_undo() {
let mut app = headless_app();
app.world_mut().resource_mut::<GameStateResource>().0.undo_count = 3;
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.undo_count = 3;
app.update();
assert_eq!(read_hud_text::<HudUndos>(&mut app), "Undos: 3");
}
@@ -2859,7 +2951,10 @@ mod tests {
let mut app = headless_app_with_auto_complete();
app.world_mut().resource_mut::<AutoCompleteState>().active = true;
// Also trigger game state change so the update fires.
app.world_mut().resource_mut::<GameStateResource>().0.move_count += 1;
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count += 1;
app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "AUTO");
}
@@ -2868,7 +2963,10 @@ mod tests {
fn auto_complete_badge_empty_when_inactive() {
let mut app = headless_app_with_auto_complete();
// active is false by default.
app.world_mut().resource_mut::<GameStateResource>().0.move_count += 1;
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.move_count += 1;
app.update();
assert_eq!(read_hud_text::<HudAutoComplete>(&mut app), "");
}
@@ -2928,9 +3026,9 @@ mod tests {
fn set_manual_time_step(app: &mut App, secs: f32) {
use bevy::time::TimeUpdateStrategy;
use std::time::Duration;
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(secs),
));
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
secs,
)));
}
/// Counts entities matching component `M` currently in the world.
@@ -3130,9 +3228,7 @@ mod tests {
/// which is the invariant we want to enforce for HUD readouts and
/// action buttons (each marker is spawned exactly once).
fn tooltip_for<M: Component>(app: &mut App) -> String {
let mut q = app
.world_mut()
.query_filtered::<&Tooltip, With<M>>();
let mut q = app.world_mut().query_filtered::<&Tooltip, With<M>>();
let world = app.world();
let mut iter = q.iter(world);
let first = iter