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
+105 -51
View File
@@ -9,9 +9,13 @@
//! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`)
//! the panel shows "Not available" immediately.
use bevy::input::{ButtonState, keyboard::KeyboardInput, mouse::{MouseScrollUnit, MouseWheel}};
use bevy::input::{
ButtonState,
keyboard::KeyboardInput,
mouse::{MouseScrollUnit, MouseWheel},
};
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_data::{save_settings_to, settings::SyncBackend};
use solitaire_sync::LeaderboardEntry;
@@ -20,13 +24,13 @@ use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ModalScrim, ScrimDismissible,
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO,
TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL, Z_PAUSE_DIALOG,
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
Z_MODAL_PANEL, Z_PAUSE_DIALOG,
};
// ---------------------------------------------------------------------------
@@ -208,18 +212,30 @@ fn toggle_leaderboard_screen(
let remote_available = provider
.as_ref()
.is_some_and(|p| p.0.backend_name() != "local");
let dn = settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref());
spawn_leaderboard_screen(&mut commands, &data, remote_available, dn, font_res.as_deref());
let dn = settings
.as_ref()
.and_then(|s| s.0.leaderboard_display_name.as_deref());
spawn_leaderboard_screen(
&mut commands,
&data,
remote_available,
dn,
font_res.as_deref(),
);
// Start a background fetch if not already in flight.
if task_res.0.is_none()
&& let Some(p) = provider {
let provider = p.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
provider.fetch_leaderboard().await.map_err(|e| e.to_string())
});
task_res.0 = Some(task);
}
&& let Some(p) = provider
{
let provider = p.0.clone();
let task = AsyncComputeTaskPool::get().spawn(async move {
provider
.fetch_leaderboard()
.await
.map_err(|e| e.to_string())
});
task_res.0 = Some(task);
}
}
/// Poll the background fetch task; store results when complete.
@@ -227,8 +243,12 @@ fn poll_leaderboard_fetch(
mut task_res: ResMut<LeaderboardFetchTask>,
mut result_res: ResMut<LeaderboardFetchResult>,
) {
let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return };
let Some(task) = task_res.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
task_res.0 = None;
result_res.0 = Some(result);
}
@@ -247,7 +267,9 @@ fn update_leaderboard_panel(
font_res: Option<Res<FontResource>>,
closed_flag: Res<ClosedThisFrame>,
) {
let Some(result) = result_res.0.take() else { return };
let Some(result) = result_res.0.take() else {
return;
};
match result {
Ok(entries) => {
@@ -272,10 +294,18 @@ fn update_leaderboard_panel(
let remote_available = provider
.as_ref()
.is_some_and(|p| p.0.backend_name() != "local");
let dn = settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref());
let dn = settings
.as_ref()
.and_then(|s| s.0.leaderboard_display_name.as_deref());
for entity in &screens {
commands.entity(entity).despawn();
spawn_leaderboard_screen(&mut commands, &data, remote_available, dn, font_res.as_deref());
spawn_leaderboard_screen(
&mut commands,
&data,
remote_available,
dn,
font_res.as_deref(),
);
}
}
@@ -358,8 +388,12 @@ fn handle_opt_in_button(
.unwrap_or_else(|| "Player".to_string());
let provider = provider.0.clone();
let task = AsyncComputeTaskPool::get()
.spawn(async move { provider.opt_in_leaderboard(&display_name).await.map_err(|e| e.to_string()) });
let task = AsyncComputeTaskPool::get().spawn(async move {
provider
.opt_in_leaderboard(&display_name)
.await
.map_err(|e| e.to_string())
});
task_res.0 = Some(task);
}
}
@@ -372,8 +406,12 @@ fn poll_opt_in_task(
settings: Option<ResMut<SettingsResource>>,
settings_path: Option<Res<SettingsStoragePath>>,
) {
let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return };
let Some(task) = task_res.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
task_res.0 = None;
match result {
Ok(()) => {
@@ -409,8 +447,12 @@ fn handle_opt_out_button(
continue;
}
let provider = provider.0.clone();
let task = AsyncComputeTaskPool::get()
.spawn(async move { provider.opt_out_leaderboard().await.map_err(|e| e.to_string()) });
let task = AsyncComputeTaskPool::get().spawn(async move {
provider
.opt_out_leaderboard()
.await
.map_err(|e| e.to_string())
});
task_res.0 = Some(task);
}
}
@@ -423,8 +465,12 @@ fn poll_opt_out_task(
settings: Option<ResMut<SettingsResource>>,
settings_path: Option<Res<SettingsStoragePath>>,
) {
let Some(task) = task_res.0.as_mut() else { return };
let Some(result) = future::block_on(future::poll_once(task)) else { return };
let Some(task) = task_res.0.as_mut() else {
return;
};
let Some(result) = future::block_on(future::poll_once(task)) else {
return;
};
task_res.0 = None;
match result {
Ok(()) => {
@@ -941,7 +987,10 @@ fn update_leaderboard_public_name_label(
if labels.is_empty() {
return;
}
let new_label = match settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref()) {
let new_label = match settings
.as_ref()
.and_then(|s| s.0.leaderboard_display_name.as_deref())
{
Some(n) => format!("Public name: {n}"),
None => "Public name: (same as username)".to_string(),
};
@@ -974,14 +1023,14 @@ fn format_secs(secs: u64) -> String {
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::table_plugin::TablePlugin;
use crate::sync_plugin::SyncPlugin;
use solitaire_data::SyncError;
use solitaire_sync::{SyncPayload, SyncResponse};
use crate::table_plugin::TablePlugin;
use chrono::Utc;
use uuid::Uuid;
use solitaire_sync::PlayerProgress;
use solitaire_data::StatsSnapshot;
use solitaire_data::SyncError;
use solitaire_sync::PlayerProgress;
use solitaire_sync::{SyncPayload, SyncResponse};
use uuid::Uuid;
struct NoOpProvider;
@@ -1009,18 +1058,20 @@ mod tests {
conflicts: vec![],
})
}
fn backend_name(&self) -> &'static str { "no-op" }
fn is_authenticated(&self) -> bool { false }
fn backend_name(&self) -> &'static str {
"no-op"
}
fn is_authenticated(&self) -> bool {
false
}
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
Ok(vec![
LeaderboardEntry {
display_name: "Alice".to_string(),
best_score: Some(5000),
best_time_secs: Some(180),
recorded_at: Utc::now(),
},
])
Ok(vec![LeaderboardEntry {
display_name: "Alice".to_string(),
best_score: Some(5000),
best_time_secs: Some(180),
recorded_at: Utc::now(),
}])
}
}
@@ -1148,7 +1199,9 @@ mod tests {
fn headless_app_with_settings() -> App {
let mut app = headless_app();
app.insert_resource(SettingsResource(solitaire_data::settings::Settings::default()));
app.insert_resource(SettingsResource(
solitaire_data::settings::Settings::default(),
));
app
}
@@ -1231,11 +1284,12 @@ mod tests {
let mut app = headless_app_with_settings();
// Confirm the flag starts false.
assert!(!app
.world()
.resource::<SettingsResource>()
.0
.leaderboard_opted_in);
assert!(
!app.world()
.resource::<SettingsResource>()
.0
.leaderboard_opted_in
);
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
app.world_mut().resource_mut::<OptInTask>().0 = Some(ok_task);