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:
@@ -29,21 +29,21 @@ use crate::events::{
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::game_plugin::{GameOverScreen, GameStatePath};
|
||||
use crate::hud_plugin::HudPopoverOpen;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::selection_plugin::{SelectionKeySet, SelectionState};
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
|
||||
use crate::stats_plugin::StatsResource;
|
||||
use crate::hud_plugin::HudPopoverOpen;
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||
spawn_modal_header, ButtonVariant, ModalScrim,
|
||||
ButtonVariant, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_body_text,
|
||||
spawn_modal_button, spawn_modal_header,
|
||||
};
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use crate::ui_theme::{
|
||||
self, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_3,
|
||||
};
|
||||
use bevy::ecs::system::SystemParam;
|
||||
|
||||
/// Toggleable flag read by `tick_elapsed_time` and `advance_time_attack`.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
@@ -223,11 +223,12 @@ fn toggle_pause(
|
||||
// Clearing DragState and emitting StateChangedEvent snaps the dragged cards
|
||||
// back to their resting positions exactly as a rejected drop does.
|
||||
if let Some(ref mut d) = drag
|
||||
&& !d.is_idle() {
|
||||
d.clear();
|
||||
changed.write(StateChangedEvent);
|
||||
return;
|
||||
}
|
||||
&& !d.is_idle()
|
||||
{
|
||||
d.clear();
|
||||
changed.write(StateChangedEvent);
|
||||
return;
|
||||
}
|
||||
if let Ok(entity) = screens.single() {
|
||||
commands.entity(entity).despawn();
|
||||
paused.0 = false;
|
||||
@@ -236,21 +237,16 @@ fn toggle_pause(
|
||||
let level = progress.as_deref().map(|p| p.0.level);
|
||||
let streak = stats.as_deref().map(|s| s.0.win_streak_current);
|
||||
let draw_mode = settings.as_deref().map(|s| s.0.draw_mode);
|
||||
spawn_pause_screen(
|
||||
&mut commands,
|
||||
level,
|
||||
streak,
|
||||
draw_mode,
|
||||
font_res.as_deref(),
|
||||
);
|
||||
spawn_pause_screen(&mut commands, level, streak, draw_mode, font_res.as_deref());
|
||||
paused.0 = true;
|
||||
// Persist the current game state whenever the player opens the pause
|
||||
// overlay so an OS-level kill still leaves a resumable save.
|
||||
if let (Some(g), Some(p)) = (game, path)
|
||||
&& let Some(disk_path) = p.0.as_deref()
|
||||
&& let Err(e) = save_game_state_to(disk_path, &g.0) {
|
||||
warn!("game_state: failed to save on pause: {e}");
|
||||
}
|
||||
&& let Err(e) = save_game_state_to(disk_path, &g.0)
|
||||
{
|
||||
warn!("game_state: failed to save on pause: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,16 +272,21 @@ fn handle_pause_draw_buttons(
|
||||
return;
|
||||
}
|
||||
let Some(mut settings) = settings else { return };
|
||||
let new_mode = if pressed_one { DrawMode::DrawOne } else { DrawMode::DrawThree };
|
||||
let new_mode = if pressed_one {
|
||||
DrawMode::DrawOne
|
||||
} else {
|
||||
DrawMode::DrawThree
|
||||
};
|
||||
if settings.0.draw_mode == new_mode {
|
||||
return;
|
||||
}
|
||||
settings.0.draw_mode = new_mode;
|
||||
if let Some(p) = &path
|
||||
&& let Some(target) = &p.0
|
||||
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0) {
|
||||
warn!("failed to save settings after draw-mode change: {e}");
|
||||
}
|
||||
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0)
|
||||
{
|
||||
warn!("failed to save settings after draw-mode change: {e}");
|
||||
}
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
}
|
||||
|
||||
@@ -440,7 +441,11 @@ fn close_forfeit_modal(
|
||||
/// Query filter for modals that are not part of the pause flow.
|
||||
/// Excludes both `PauseScreen` (the pause modal itself) and
|
||||
/// `ForfeitConfirmScreen` (spawned from within the pause flow).
|
||||
type NonPauseFamilyScrim = (With<ModalScrim>, Without<PauseScreen>, Without<ForfeitConfirmScreen>);
|
||||
type NonPauseFamilyScrim = (
|
||||
With<ModalScrim>,
|
||||
Without<PauseScreen>,
|
||||
Without<ForfeitConfirmScreen>,
|
||||
);
|
||||
|
||||
fn auto_resume_on_overlay(
|
||||
mut commands: Commands,
|
||||
@@ -536,13 +541,23 @@ fn spawn_draw_mode_row(
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new("Draw Mode"),
|
||||
label_font,
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
spawn_modal_button(row, PauseDrawOneButton, "Draw 1", None, one_variant, font_res);
|
||||
spawn_modal_button(row, PauseDrawThreeButton, "Draw 3", None, three_variant, font_res);
|
||||
row.spawn((Text::new("Draw Mode"), label_font, TextColor(TEXT_PRIMARY)));
|
||||
spawn_modal_button(
|
||||
row,
|
||||
PauseDrawOneButton,
|
||||
"Draw 1",
|
||||
None,
|
||||
one_variant,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
row,
|
||||
PauseDrawThreeButton,
|
||||
"Draw 3",
|
||||
None,
|
||||
three_variant,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
parent.spawn((
|
||||
Text::new("Takes effect next game"),
|
||||
@@ -744,7 +759,10 @@ mod tests {
|
||||
|
||||
// Set known values.
|
||||
app.world_mut().resource_mut::<ProgressResource>().0.level = 7;
|
||||
app.world_mut().resource_mut::<StatsResource>().0.win_streak_current = 3;
|
||||
app.world_mut()
|
||||
.resource_mut::<StatsResource>()
|
||||
.0
|
||||
.win_streak_current = 3;
|
||||
|
||||
press_esc(&mut app);
|
||||
app.update();
|
||||
@@ -797,7 +815,10 @@ mod tests {
|
||||
fn draw_mode_label_covers_all_variants() {
|
||||
for mode in [DrawMode::DrawOne, DrawMode::DrawThree] {
|
||||
let label = draw_mode_label(mode);
|
||||
assert!(!label.is_empty(), "draw_mode_label must never return an empty string");
|
||||
assert!(
|
||||
!label.is_empty(),
|
||||
"draw_mode_label must never return an empty string"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -827,19 +848,12 @@ mod tests {
|
||||
app.world_mut().resource_mut::<PausedResource>().0 = true;
|
||||
|
||||
// Pressing "Draw 3" while DrawOne is active should switch to DrawThree.
|
||||
app.world_mut().spawn((
|
||||
PauseDrawThreeButton,
|
||||
Button,
|
||||
Interaction::Pressed,
|
||||
));
|
||||
app.world_mut()
|
||||
.spawn((PauseDrawThreeButton, Button, Interaction::Pressed));
|
||||
|
||||
app.update();
|
||||
|
||||
let mode = &app
|
||||
.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.draw_mode;
|
||||
let mode = &app.world().resource::<SettingsResource>().0.draw_mode;
|
||||
assert_eq!(
|
||||
*mode,
|
||||
DrawMode::DrawThree,
|
||||
@@ -847,19 +861,12 @@ mod tests {
|
||||
);
|
||||
|
||||
// Pressing "Draw 1" while DrawThree is active should switch back.
|
||||
app.world_mut().spawn((
|
||||
PauseDrawOneButton,
|
||||
Button,
|
||||
Interaction::Pressed,
|
||||
));
|
||||
app.world_mut()
|
||||
.spawn((PauseDrawOneButton, Button, Interaction::Pressed));
|
||||
|
||||
app.update();
|
||||
|
||||
let mode2 = &app
|
||||
.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.draw_mode;
|
||||
let mode2 = &app.world().resource::<SettingsResource>().0.draw_mode;
|
||||
assert_eq!(
|
||||
*mode2,
|
||||
DrawMode::DrawOne,
|
||||
@@ -896,8 +903,14 @@ mod tests {
|
||||
.query::<&PauseForfeitButton>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(resume_count, 1, "Resume button must be present on the pause modal");
|
||||
assert_eq!(forfeit_count, 1, "Forfeit button must be present on the pause modal");
|
||||
assert_eq!(
|
||||
resume_count, 1,
|
||||
"Resume button must be present on the pause modal"
|
||||
);
|
||||
assert_eq!(
|
||||
forfeit_count, 1,
|
||||
"Forfeit button must be present on the pause modal"
|
||||
);
|
||||
}
|
||||
|
||||
/// Clicking the Resume button (via Pressed interaction) closes the
|
||||
@@ -911,20 +924,29 @@ mod tests {
|
||||
|
||||
// Mark the Resume button as Pressed.
|
||||
let resume_entity = {
|
||||
let mut q = app.world_mut().query_filtered::<Entity, With<PauseResumeButton>>();
|
||||
q.iter(app.world()).next().expect("Resume button must exist")
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<PauseResumeButton>>();
|
||||
q.iter(app.world())
|
||||
.next()
|
||||
.expect("Resume button must exist")
|
||||
};
|
||||
app.world_mut()
|
||||
.entity_mut(resume_entity)
|
||||
.insert(Interaction::Pressed);
|
||||
|
||||
// Clear keys so the simulated "click" isn't competing with a real Esc press.
|
||||
app.world_mut().resource_mut::<ButtonInput<KeyCode>>().clear();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.clear();
|
||||
app.update();
|
||||
// One more frame so the resulting PauseRequestEvent is consumed by toggle_pause.
|
||||
app.update();
|
||||
|
||||
assert!(!app.world().resource::<PausedResource>().0, "Resume must clear PausedResource");
|
||||
assert!(
|
||||
!app.world().resource::<PausedResource>().0,
|
||||
"Resume must clear PausedResource"
|
||||
);
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&PauseScreen>()
|
||||
@@ -1137,7 +1159,10 @@ mod tests {
|
||||
app.update();
|
||||
assert!(app.world().resource::<PausedResource>().0);
|
||||
assert_eq!(
|
||||
app.world_mut().query::<&PauseScreen>().iter(app.world()).count(),
|
||||
app.world_mut()
|
||||
.query::<&PauseScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
@@ -1150,7 +1175,10 @@ mod tests {
|
||||
"auto_resume_on_overlay must clear PausedResource when another modal opens"
|
||||
);
|
||||
assert_eq!(
|
||||
app.world_mut().query::<&PauseScreen>().iter(app.world()).count(),
|
||||
app.world_mut()
|
||||
.query::<&PauseScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
0,
|
||||
"auto_resume_on_overlay must despawn PauseScreen when another modal opens"
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user