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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user