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:
@@ -11,12 +11,12 @@ use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use chrono::{Local, Timelike, Utc};
|
||||
use solitaire_core::achievement::{
|
||||
achievement_by_id, check_achievements, AchievementContext, AchievementDef, Reward,
|
||||
ALL_ACHIEVEMENTS,
|
||||
ALL_ACHIEVEMENTS, AchievementContext, AchievementDef, Reward, achievement_by_id,
|
||||
check_achievements,
|
||||
};
|
||||
use solitaire_data::{
|
||||
achievements_file_path, load_achievements_from, save_achievements_to, save_settings_to,
|
||||
AchievementRecord, save_progress_to,
|
||||
AchievementRecord, achievements_file_path, load_achievements_from, save_achievements_to,
|
||||
save_progress_to, save_settings_to,
|
||||
};
|
||||
|
||||
use crate::events::{
|
||||
@@ -31,8 +31,8 @@ use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||
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, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||
@@ -140,7 +140,10 @@ impl Plugin for AchievementPlugin {
|
||||
.add_systems(Update, toggle_achievements_screen)
|
||||
.add_systems(Update, handle_achievements_close_button)
|
||||
.add_systems(Update, scroll_achievements_panel)
|
||||
.add_systems(Update, crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>)
|
||||
.add_systems(
|
||||
Update,
|
||||
crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>,
|
||||
)
|
||||
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
|
||||
// `cinephile` the first time playback runs to natural completion.
|
||||
// Reads the resource via `Option<Res<_>>` so headless tests that
|
||||
@@ -235,17 +238,23 @@ fn evaluate_on_win(
|
||||
unlocks.write(AchievementUnlockedEvent(record.clone()));
|
||||
}
|
||||
|
||||
if achievements_changed
|
||||
&& let Some(target) = &path.0
|
||||
&& let Err(e) = save_achievements_to(target, &achievements.0) {
|
||||
warn!("failed to save achievements: {e}");
|
||||
}
|
||||
|
||||
// Persist progress FIRST. Only if that succeeds do we mark
|
||||
// `reward_granted = true` on the achievements and save them.
|
||||
// This prevents the corruption where reward_granted is persisted
|
||||
// but the XP was not (permanent XP loss on next launch).
|
||||
if progress_changed
|
||||
&& let Some(target) = &progress_path.0
|
||||
&& let Err(e) = save_progress_to(target, &progress.0) {
|
||||
warn!("failed to save progress after reward: {e}");
|
||||
}
|
||||
&& let Err(e) = save_progress_to(target, &progress.0)
|
||||
{
|
||||
warn!("failed to save progress after reward: {e}");
|
||||
}
|
||||
|
||||
if achievements_changed
|
||||
&& let Some(target) = &path.0
|
||||
&& let Err(e) = save_achievements_to(target, &achievements.0)
|
||||
{
|
||||
warn!("failed to save achievements: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,9 +495,7 @@ fn spawn_achievements_screen(
|
||||
// greyed-out grid.
|
||||
if !any_unlocked {
|
||||
card.spawn((
|
||||
Text::new(
|
||||
"Complete games and try new modes to unlock achievements and rewards.",
|
||||
),
|
||||
Text::new("Complete games and try new modes to unlock achievements and rewards."),
|
||||
TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
@@ -802,7 +809,10 @@ mod tests {
|
||||
// trigger update_stats_on_win first (StatsUpdate runs before
|
||||
// evaluate_on_win), bumping draw_three_wins to 10 — the unlock
|
||||
// threshold for the draw_three_master achievement.
|
||||
app.world_mut().resource_mut::<StatsResource>().0.draw_three_wins = 9;
|
||||
app.world_mut()
|
||||
.resource_mut::<StatsResource>()
|
||||
.0
|
||||
.draw_three_wins = 9;
|
||||
|
||||
// The current game must be in DrawThree mode so update_on_win
|
||||
// increments draw_three_wins (and not draw_one_wins).
|
||||
@@ -830,7 +840,10 @@ mod tests {
|
||||
.find(|r| r.id == "draw_three_master")
|
||||
.map(|r| r.unlocked)
|
||||
.unwrap_or(false);
|
||||
assert!(unlocked, "draw_three_master must unlock at the 10th Draw-Three win");
|
||||
assert!(
|
||||
unlocked,
|
||||
"draw_three_master must unlock at the 10th Draw-Three win"
|
||||
);
|
||||
|
||||
// Verify the AchievementUnlockedEvent fired for this id.
|
||||
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
|
||||
@@ -848,7 +861,10 @@ mod tests {
|
||||
|
||||
// Pre-seed eight prior Draw-Three wins. The pending GameWonEvent
|
||||
// brings draw_three_wins to 9 — one short of the threshold.
|
||||
app.world_mut().resource_mut::<StatsResource>().0.draw_three_wins = 8;
|
||||
app.world_mut()
|
||||
.resource_mut::<StatsResource>()
|
||||
.0
|
||||
.draw_three_wins = 8;
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
@@ -871,7 +887,10 @@ mod tests {
|
||||
.find(|r| r.id == "draw_three_master")
|
||||
.map(|r| r.unlocked)
|
||||
.unwrap_or(false);
|
||||
assert!(!unlocked, "draw_three_master must remain locked at 9 Draw-Three wins");
|
||||
assert!(
|
||||
!unlocked,
|
||||
"draw_three_master must remain locked at 9 Draw-Three wins"
|
||||
);
|
||||
|
||||
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
@@ -892,10 +911,8 @@ mod tests {
|
||||
|
||||
// Put the active game in Zen mode. evaluate_on_win reads
|
||||
// GameStateResource.mode directly to populate last_win_is_zen.
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.mode = solitaire_core::game_state::GameMode::Zen;
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.mode =
|
||||
solitaire_core::game_state::GameMode::Zen;
|
||||
|
||||
app.world_mut().write_message(GameWonEvent {
|
||||
score: 0,
|
||||
@@ -1170,9 +1187,9 @@ mod tests {
|
||||
// canonical secret description in `solitaire_core` is already
|
||||
// generic ("A secret achievement"); these checks guard against a
|
||||
// future leak where someone replaces it with the literal predicate.
|
||||
let leaked_predicate = tips.iter().any(|t| {
|
||||
t.contains("90") && t.to_lowercase().contains("without undo")
|
||||
});
|
||||
let leaked_predicate = tips
|
||||
.iter()
|
||||
.any(|t| t.contains("90") && t.to_lowercase().contains("without undo"));
|
||||
assert!(
|
||||
!leaked_predicate,
|
||||
"no tooltip may state the speed_and_skill predicate: {tips:?}"
|
||||
@@ -1375,9 +1392,9 @@ mod tests {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
|
||||
/// Headless app variant that injects a default `ReplayPlaybackState`
|
||||
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
|
||||
@@ -1441,13 +1458,12 @@ mod tests {
|
||||
|
||||
// Frame 1: enter Playing. The observer's first sample sees
|
||||
// `last_was_playing = false` and `now_playing = true`.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
app.update();
|
||||
assert!(
|
||||
!cinephile_unlocked(&app),
|
||||
@@ -1456,8 +1472,7 @@ mod tests {
|
||||
|
||||
// Frame 2: transition to Completed. The observer must detect
|
||||
// `last_was_playing = true && now_completed = true` and unlock.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Completed;
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
@@ -1477,19 +1492,17 @@ mod tests {
|
||||
fn cinephile_does_not_unlock_on_stop_button_abort() {
|
||||
let mut app = cinephile_app();
|
||||
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
app.update();
|
||||
|
||||
// Direct Playing → Inactive — the path the Stop button takes via
|
||||
// `stop_replay_playback`. Must not unlock cinephile.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Inactive;
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive;
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
@@ -1510,18 +1523,19 @@ mod tests {
|
||||
let mut app = cinephile_app();
|
||||
|
||||
// First completion cycle to unlock.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Completed;
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
assert!(cinephile_unlocked(&app), "precondition: first cycle must unlock");
|
||||
assert!(
|
||||
cinephile_unlocked(&app),
|
||||
"precondition: first cycle must unlock"
|
||||
);
|
||||
|
||||
// Drain the event queue so the next assertion doesn't double-count
|
||||
// the legitimate first-time unlock event.
|
||||
@@ -1530,19 +1544,16 @@ mod tests {
|
||||
.clear();
|
||||
|
||||
// Second cycle: Inactive → Playing → Completed once more.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Inactive;
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive;
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Completed;
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
@@ -1559,16 +1570,14 @@ mod tests {
|
||||
fn cinephile_fires_once_across_completed_linger() {
|
||||
let mut app = cinephile_app();
|
||||
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::Completed;
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
// Stay in Completed for a few more frames as the real auto-clear
|
||||
// does. Each subsequent frame the resource is still `Completed`
|
||||
|
||||
Reference in New Issue
Block a user