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
+98 -34
View File
@@ -23,20 +23,20 @@
use std::path::PathBuf;
use bevy::prelude::*;
use solitaire_data::{save_settings_to, Settings};
use solitaire_data::{Settings, save_settings_to};
use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, ButtonVariant,
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header,
};
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
#[cfg(not(target_os = "android"))]
use crate::ui_theme::{
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
};
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
// ---------------------------------------------------------------------------
// Constants
@@ -101,16 +101,46 @@ struct HotkeyRow {
/// refactor the help plugin.
#[cfg(not(target_os = "android"))]
const HOTKEYS: &[HotkeyRow] = &[
HotkeyRow { keys: "D / Space", description: "Draw from stock" },
HotkeyRow { keys: "U", description: "Undo last move" },
HotkeyRow { keys: "Tab → Enter", description: "Pick a card; arrows pick where; Enter to drop" },
HotkeyRow { keys: "N", description: "New Classic game" },
HotkeyRow { keys: "M", description: "Open Mode Launcher (then 15 to pick)" },
HotkeyRow { keys: "S", description: "Stats & progression" },
HotkeyRow { keys: "A", description: "Achievements" },
HotkeyRow { keys: "O", description: "Settings" },
HotkeyRow { keys: "Esc", description: "Pause / resume" },
HotkeyRow { keys: "F1", description: "Help / controls" },
HotkeyRow {
keys: "D / Space",
description: "Draw from stock",
},
HotkeyRow {
keys: "U",
description: "Undo last move",
},
HotkeyRow {
keys: "Tab → Enter",
description: "Pick a card; arrows pick where; Enter to drop",
},
HotkeyRow {
keys: "N",
description: "New Classic game",
},
HotkeyRow {
keys: "M",
description: "Open Mode Launcher (then 15 to pick)",
},
HotkeyRow {
keys: "S",
description: "Stats & progression",
},
HotkeyRow {
keys: "A",
description: "Achievements",
},
HotkeyRow {
keys: "O",
description: "Settings",
},
HotkeyRow {
keys: "Esc",
description: "Pause / resume",
},
HotkeyRow {
keys: "F1",
description: "Help / controls",
},
];
// ---------------------------------------------------------------------------
@@ -126,11 +156,7 @@ impl Plugin for OnboardingPlugin {
.add_systems(PostStartup, spawn_if_first_run)
.add_systems(
Update,
(
handle_onboarding_buttons,
handle_onboarding_keyboard,
)
.chain(),
(handle_onboarding_buttons, handle_onboarding_keyboard).chain(),
);
}
}
@@ -523,11 +549,16 @@ mod tests {
assert_eq!(current_slide(&app), 0);
// Spawn a Next button with Pressed interaction.
app.world_mut().spawn((OnboardingNextButton, Button, Interaction::Pressed));
app.world_mut()
.spawn((OnboardingNextButton, Button, Interaction::Pressed));
app.update();
assert_eq!(current_slide(&app), 1, "Next must advance to slide 1");
assert_eq!(count_screens(&mut app), 1, "exactly one modal must be visible");
assert_eq!(
count_screens(&mut app),
1,
"exactly one modal must be visible"
);
}
#[test]
@@ -548,10 +579,15 @@ mod tests {
}
}
app.world_mut().spawn((OnboardingBackButton, Button, Interaction::Pressed));
app.world_mut()
.spawn((OnboardingBackButton, Button, Interaction::Pressed));
app.update();
assert_eq!(current_slide(&app), 1, "Back must retreat from slide 2 to slide 1");
assert_eq!(
current_slide(&app),
1,
"Back must retreat from slide 2 to slide 1"
);
}
#[test]
@@ -561,7 +597,8 @@ mod tests {
assert_eq!(current_slide(&app), 0);
// Pressing Back on slide 0 must be a no-op (no underflow to u8::MAX).
app.world_mut().spawn((OnboardingBackButton, Button, Interaction::Pressed));
app.world_mut()
.spawn((OnboardingBackButton, Button, Interaction::Pressed));
app.update();
assert_eq!(current_slide(&app), 0, "Back on slide 0 must not underflow");
@@ -576,15 +613,23 @@ mod tests {
app.world_mut().resource_mut::<OnboardingSlideIndex>().0 = SLIDE_COUNT - 1;
// Next on the last slide should complete onboarding, not advance further.
app.world_mut().spawn((OnboardingNextButton, Button, Interaction::Pressed));
app.world_mut()
.spawn((OnboardingNextButton, Button, Interaction::Pressed));
app.update();
// first_run_complete must be set.
assert!(
app.world().resource::<SettingsResource>().0.first_run_complete,
app.world()
.resource::<SettingsResource>()
.0
.first_run_complete,
"Next on last slide must set first_run_complete"
);
assert_eq!(count_screens(&mut app), 0, "modal must be gone after completion");
assert_eq!(
count_screens(&mut app),
0,
"modal must be gone after completion"
);
}
// -----------------------------------------------------------------------
@@ -596,11 +641,15 @@ mod tests {
let mut app = headless_app();
app.update();
app.world_mut().spawn((OnboardingSkipButton, Button, Interaction::Pressed));
app.world_mut()
.spawn((OnboardingSkipButton, Button, Interaction::Pressed));
app.update();
assert!(
app.world().resource::<SettingsResource>().0.first_run_complete,
app.world()
.resource::<SettingsResource>()
.0
.first_run_complete,
"Skip must set first_run_complete"
);
assert_eq!(count_screens(&mut app), 0);
@@ -658,7 +707,10 @@ mod tests {
assert_eq!(count_screens(&mut app), 0, "Esc must dismiss onboarding");
assert!(
app.world().resource::<SettingsResource>().0.first_run_complete,
app.world()
.resource::<SettingsResource>()
.0
.first_run_complete,
"Esc must set first_run_complete"
);
}
@@ -675,7 +727,10 @@ mod tests {
app.update();
assert!(
app.world().resource::<SettingsResource>().0.first_run_complete,
app.world()
.resource::<SettingsResource>()
.0
.first_run_complete,
"Enter on last slide must complete onboarding"
);
assert_eq!(count_screens(&mut app), 0);
@@ -694,7 +749,10 @@ mod tests {
#[test]
#[cfg(target_os = "android")]
fn slide_count_constant_is_two_on_android() {
assert_eq!(SLIDE_COUNT, 2, "SLIDE_COUNT must be 2 on Android (no keyboard slide)");
assert_eq!(
SLIDE_COUNT, 2,
"SLIDE_COUNT must be 2 on Android (no keyboard slide)"
);
}
#[test]
@@ -727,7 +785,10 @@ mod tests {
app.update();
assert!(
app.world().resource::<SettingsResource>().0.first_run_complete,
app.world()
.resource::<SettingsResource>()
.0
.first_run_complete,
"completing the last slide must set first_run_complete"
);
assert_eq!(count_screens(&mut app), 0);
@@ -746,7 +807,10 @@ mod tests {
fn all_hotkey_rows_have_non_empty_fields() {
for row in HOTKEYS {
assert!(!row.keys.is_empty(), "hotkey key field must not be empty");
assert!(!row.description.is_empty(), "hotkey description must not be empty");
assert!(
!row.description.is_empty(),
"hotkey description must not be empty"
);
}
}
}