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
+237 -73
View File
@@ -17,13 +17,14 @@ use bevy::ui::{ComputedNode, UiGlobalTransform};
use bevy::window::{WindowMoved, WindowResized};
use solitaire_core::game_state::DrawMode;
use solitaire_data::{
load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings,
WindowGeometry, REPLAY_MOVE_INTERVAL_STEP_SECS, TIME_BONUS_MULTIPLIER_STEP,
TOOLTIP_DELAY_STEP_SECS,
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, Settings, TIME_BONUS_MULTIPLIER_STEP,
TOOLTIP_DELAY_STEP_SECS, WindowGeometry, load_settings_from, save_settings_to, settings::Theme,
settings_file_path,
};
use solitaire_data::settings::SyncBackend;
use crate::assets::user_theme_dir;
use crate::events::{
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
@@ -31,20 +32,20 @@ use crate::events::{
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
use crate::assets::user_theme_dir;
use crate::theme::{import_theme, ImportError, ThemeThumbnailCache, ThemeThumbnailPair, refresh_registry};
use crate::theme::{
ImportError, ThemeThumbnailCache, ThemeThumbnailPair, import_theme, refresh_registry,
};
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ModalButton, ModalScrim,
ButtonVariant, ModalButton, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button,
spawn_modal_header,
};
use crate::ui_tooltip::Tooltip;
use crate::ui_theme::{
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, BORDER_SUBTLE_HC, HighContrastBackground,
HighContrastBorder,
RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
HighContrastBorder, RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
};
use crate::ui_tooltip::Tooltip;
/// Side length of a swatch button in the card-back / background pickers.
/// Smaller than the smallest spacing rung so it stays a literal.
@@ -401,10 +402,8 @@ impl Plugin for SettingsPlugin {
update_anim_speed_text,
update_color_blind_text,
update_high_contrast_text,
update_high_contrast_borders
.run_if(resource_changed::<SettingsResource>),
update_high_contrast_backgrounds
.run_if(resource_changed::<SettingsResource>),
update_high_contrast_borders.run_if(resource_changed::<SettingsResource>),
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
update_reduce_motion_text,
update_tooltip_delay_text,
update_time_bonus_multiplier_text,
@@ -454,7 +453,12 @@ fn merge_geometry(
let (x, y) = new_pos
.or_else(|| existing.map(|g| (g.x, g.y)))
.unwrap_or((0, 0));
Some(WindowGeometry { width, height, x, y })
Some(WindowGeometry {
width,
height,
x,
y,
})
}
// ---------------------------------------------------------------------------
@@ -527,8 +531,10 @@ fn sync_settings_panel_visibility(
}
if screen.0 {
if panels.is_empty() && other_modal_scrims.is_empty() {
let status_label = sync_status
.map_or_else(|| "Status: local only".to_string(), |s| sync_status_label(&s.0));
let status_label = sync_status.map_or_else(
|| "Status: local only".to_string(),
|s| sync_status_label(&s.0),
);
let unlocked_backs = progress
.as_ref()
.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
@@ -894,14 +900,110 @@ fn handle_settings_buttons(
mut screen: ResMut<SettingsScreen>,
path: Res<SettingsStoragePath>,
mut changed: MessageWriter<SettingsChangedEvent>,
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
mut theme_text: Query<&mut Text, (With<ThemeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
mut anim_speed_text: Query<&mut Text, (With<AnimSpeedText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<ColorBlindText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
mut color_blind_text: Query<&mut Text, (With<ColorBlindText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<HighContrastText>, Without<ReduceMotionText>)>,
mut high_contrast_text: Query<&mut Text, (With<HighContrastText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<ReduceMotionText>)>,
mut reduce_motion_text: Query<&mut Text, (With<ReduceMotionText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>, Without<HighContrastText>)>,
mut sfx_text: Query<
&mut Text,
(
With<SfxVolumeText>,
Without<MusicVolumeText>,
Without<DrawModeText>,
Without<ThemeText>,
Without<AnimSpeedText>,
Without<ColorBlindText>,
Without<HighContrastText>,
Without<ReduceMotionText>,
),
>,
mut music_text: Query<
&mut Text,
(
With<MusicVolumeText>,
Without<SfxVolumeText>,
Without<DrawModeText>,
Without<ThemeText>,
Without<AnimSpeedText>,
Without<ColorBlindText>,
Without<HighContrastText>,
Without<ReduceMotionText>,
),
>,
mut draw_text: Query<
&mut Text,
(
With<DrawModeText>,
Without<SfxVolumeText>,
Without<MusicVolumeText>,
Without<ThemeText>,
Without<AnimSpeedText>,
Without<ColorBlindText>,
Without<HighContrastText>,
Without<ReduceMotionText>,
),
>,
mut theme_text: Query<
&mut Text,
(
With<ThemeText>,
Without<SfxVolumeText>,
Without<MusicVolumeText>,
Without<DrawModeText>,
Without<AnimSpeedText>,
Without<ColorBlindText>,
Without<HighContrastText>,
Without<ReduceMotionText>,
),
>,
mut anim_speed_text: Query<
&mut Text,
(
With<AnimSpeedText>,
Without<SfxVolumeText>,
Without<MusicVolumeText>,
Without<DrawModeText>,
Without<ThemeText>,
Without<ColorBlindText>,
Without<HighContrastText>,
Without<ReduceMotionText>,
),
>,
mut color_blind_text: Query<
&mut Text,
(
With<ColorBlindText>,
Without<SfxVolumeText>,
Without<MusicVolumeText>,
Without<DrawModeText>,
Without<ThemeText>,
Without<AnimSpeedText>,
Without<HighContrastText>,
Without<ReduceMotionText>,
),
>,
mut high_contrast_text: Query<
&mut Text,
(
With<HighContrastText>,
Without<SfxVolumeText>,
Without<MusicVolumeText>,
Without<DrawModeText>,
Without<ThemeText>,
Without<AnimSpeedText>,
Without<ColorBlindText>,
Without<ReduceMotionText>,
),
>,
mut reduce_motion_text: Query<
&mut Text,
(
With<ReduceMotionText>,
Without<SfxVolumeText>,
Without<MusicVolumeText>,
Without<DrawModeText>,
Without<ThemeText>,
Without<AnimSpeedText>,
Without<ColorBlindText>,
Without<HighContrastText>,
),
>,
) {
for (interaction, button) in &interaction_query {
if *interaction != Interaction::Pressed {
@@ -995,7 +1097,9 @@ fn handle_settings_buttons(
}
SettingsButton::TimeBonusDown => {
let before = settings.0.time_bonus_multiplier;
let after = settings.0.adjust_time_bonus_multiplier(-TIME_BONUS_MULTIPLIER_STEP);
let after = settings
.0
.adjust_time_bonus_multiplier(-TIME_BONUS_MULTIPLIER_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
@@ -1006,7 +1110,9 @@ fn handle_settings_buttons(
}
SettingsButton::TimeBonusUp => {
let before = settings.0.time_bonus_multiplier;
let after = settings.0.adjust_time_bonus_multiplier(TIME_BONUS_MULTIPLIER_STEP);
let after = settings
.0
.adjust_time_bonus_multiplier(TIME_BONUS_MULTIPLIER_STEP);
if (before - after).abs() > f32::EPSILON {
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
@@ -1085,8 +1191,7 @@ fn handle_settings_buttons(
// Text refreshed by `update_analytics_enabled_text` next frame.
}
SettingsButton::ToggleSmartDefaultSize => {
settings.0.disable_smart_default_size =
!settings.0.disable_smart_default_size;
settings.0.disable_smart_default_size = !settings.0.disable_smart_default_size;
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// The Text node is refreshed by
@@ -1144,15 +1249,21 @@ fn handle_sync_buttons(
continue;
}
match button {
SettingsButton::SyncNow => { manual_sync.write(ManualSyncRequestEvent); }
SettingsButton::SyncNow => {
manual_sync.write(ManualSyncRequestEvent);
}
SettingsButton::ConnectSync => {
// Close settings before the sync-setup modal opens so the
// guard in open_sync_setup_modal doesn't block on our own scrim.
screen.0 = false;
configure_sync.write(SyncConfigureRequestEvent);
}
SettingsButton::DisconnectSync => { logout_sync.write(SyncLogoutRequestEvent); }
SettingsButton::DeleteAccount => { delete_account.write(DeleteAccountRequestEvent); }
SettingsButton::DisconnectSync => {
logout_sync.write(SyncLogoutRequestEvent);
}
SettingsButton::DeleteAccount => {
delete_account.write(DeleteAccountRequestEvent);
}
_ => {}
}
}
@@ -1334,10 +1445,11 @@ fn scroll_focus_into_view(
Err(_) => break,
}
}
let Some(container) = container_entity else { return };
let Some(container) = container_entity else {
return;
};
let Ok((mut scroll, container_transform, container_node)) =
containers.get_mut(container)
let Ok((mut scroll, container_transform, container_node)) = containers.get_mut(container)
else {
return;
};
@@ -1430,10 +1542,12 @@ fn record_window_geometry_changes(
) {
// Read .last() — only the final event matters for persistence; the
// intermediate sizes/positions are noise during a drag.
let new_size = resized
.read()
.last()
.map(|ev| (ev.width.round().max(0.0) as u32, ev.height.round().max(0.0) as u32));
let new_size = resized.read().last().map(|ev| {
(
ev.width.round().max(0.0) as u32,
ev.height.round().max(0.0) as u32,
)
});
let new_pos = moved.read().last().map(|ev| (ev.position.x, ev.position.y));
if new_size.is_none() && new_pos.is_none() {
@@ -2030,7 +2144,12 @@ fn toggle_row<Marker: Component>(
..default()
})
.with_children(|cluster| {
cluster.spawn((marker, Text::new(value), value_font, TextColor(TEXT_PRIMARY)));
cluster.spawn((
marker,
Text::new(value),
value_font,
TextColor(TEXT_PRIMARY),
));
icon_button(cluster, "", action, tooltip, font_res);
});
});
@@ -2082,7 +2201,11 @@ fn picker_row(
let entries: &[usize] = if unlocked.is_empty() { &[0] } else { unlocked };
for &idx in entries {
let is_selected = idx == selected;
let bg = if is_selected { STATE_SUCCESS } else { BG_ELEVATED_HI };
let bg = if is_selected {
STATE_SUCCESS
} else {
BG_ELEVATED_HI
};
row.spawn((
make_button(idx),
Button,
@@ -2215,7 +2338,11 @@ fn theme_picker_row(
));
for entry in themes {
let is_selected = entry.id == selected_id;
let bg = if is_selected { STATE_SUCCESS } else { BG_ELEVATED_HI };
let bg = if is_selected {
STATE_SUCCESS
} else {
BG_ELEVATED_HI
};
row.spawn((
SettingsButton::SelectTheme(entry.id.clone()),
Button,
@@ -2274,16 +2401,14 @@ fn spawn_thumbnail_pair(
align_items: AlignItems::Center,
..default()
})
.with_children(|pair| {
match thumbnails {
Some(t) if t.is_fully_populated() => {
spawn_thumbnail_image(pair, t.ace.clone());
spawn_thumbnail_image(pair, t.back.clone());
}
_ => {
spawn_thumbnail_placeholder(pair);
spawn_thumbnail_placeholder(pair);
}
.with_children(|pair| match thumbnails {
Some(t) if t.is_fully_populated() => {
spawn_thumbnail_image(pair, t.ace.clone());
spawn_thumbnail_image(pair, t.back.clone());
}
_ => {
spawn_thumbnail_placeholder(pair);
spawn_thumbnail_placeholder(pair);
}
});
}
@@ -2360,11 +2485,7 @@ fn sync_row(
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((
Text::new(label.to_string()),
font,
TextColor(TEXT_PRIMARY),
));
b.spawn((Text::new(label.to_string()), font, TextColor(TEXT_PRIMARY)));
});
};
@@ -2658,7 +2779,11 @@ fn icon_button(
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((Text::new(label.to_string()), glyph_font, TextColor(TEXT_PRIMARY)));
b.spawn((
Text::new(label.to_string()),
glyph_font,
TextColor(TEXT_PRIMARY),
));
});
}
@@ -2714,7 +2839,10 @@ mod tests {
#[test]
fn pressing_right_bracket_increases_volume() {
let mut app = headless_app();
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 0.5;
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.sfx_volume = 0.5;
press(&mut app, KeyCode::BracketRight);
app.update();
@@ -2726,7 +2854,10 @@ mod tests {
#[test]
fn clamped_change_does_not_emit_event() {
let mut app = headless_app();
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 1.0;
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.sfx_volume = 1.0;
press(&mut app, KeyCode::BracketRight);
app.update();
@@ -2739,7 +2870,10 @@ mod tests {
#[test]
fn volume_clamped_at_zero_does_not_emit_event() {
let mut app = headless_app();
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 0.0;
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.sfx_volume = 0.0;
press(&mut app, KeyCode::BracketLeft);
app.update();
@@ -2749,21 +2883,34 @@ mod tests {
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 0, "no event when clamped at floor");
assert_eq!(
cursor.read(events).count(),
0,
"no event when clamped at floor"
);
}
#[test]
fn pressing_o_toggles_settings_screen_flag() {
let mut app = headless_app();
assert!(!app.world().resource::<SettingsScreen>().0, "screen is closed initially");
assert!(
!app.world().resource::<SettingsScreen>().0,
"screen is closed initially"
);
press(&mut app, KeyCode::KeyO);
app.update();
assert!(app.world().resource::<SettingsScreen>().0, "O opens settings");
assert!(
app.world().resource::<SettingsScreen>().0,
"O opens settings"
);
press(&mut app, KeyCode::KeyO);
app.update();
assert!(!app.world().resource::<SettingsScreen>().0, "second O closes settings");
assert!(
!app.world().resource::<SettingsScreen>().0,
"second O closes settings"
);
}
// cycle_unlocked pure-function tests
@@ -2819,7 +2966,8 @@ mod tests {
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.0.y;
.0
.y;
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
}
@@ -2850,8 +2998,12 @@ mod tests {
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.0.y;
assert!((offset - 200.0).abs() < 1e-3, "scrolling down should increase offset_y; got {offset}");
.0
.y;
assert!(
(offset - 200.0).abs() < 1e-3,
"scrolling down should increase offset_y; got {offset}"
);
}
// -----------------------------------------------------------------------
@@ -3102,7 +3254,12 @@ mod tests {
#[test]
fn merge_geometry_uses_existing_when_event_components_missing() {
let existing = WindowGeometry { width: 1280, height: 800, x: 100, y: 50 };
let existing = WindowGeometry {
width: 1280,
height: 800,
x: 100,
y: 50,
};
// Position-only event keeps existing size.
let merged = merge_geometry(Some(existing), None, Some((200, 75))).unwrap();
assert_eq!(merged.width, 1280);
@@ -3214,7 +3371,10 @@ mod tests {
.0
.window_geometry
.unwrap();
assert_eq!(geom.width, 1280, "size must be preserved across a move-only update");
assert_eq!(
geom.width, 1280,
"size must be preserved across a move-only update"
);
assert_eq!(geom.height, 800);
assert_eq!(geom.x, 250);
assert_eq!(geom.y, 175);
@@ -3280,7 +3440,11 @@ mod tests {
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.0.y;
assert_eq!(offset, 0.0, "scrolling past top must clamp to 0, got {offset}");
.0
.y;
assert_eq!(
offset, 0.0,
"scrolling past top must clamp to 0, got {offset}"
);
}
}