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
+196 -76
View File
@@ -26,11 +26,11 @@ use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_modal::ModalScrim;
use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
MOTION_SCORE_BREAKDOWN_STAGGER_SECS, MOTION_WIN_SHAKE_AMPLITUDE, MOTION_WIN_SHAKE_SECS,
RADIUS_LG, RADIUS_MD, SCRIM, STATE_INFO, STATE_SUCCESS, STATE_WARNING, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_DISPLAY, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
Z_WIN_CASCADE,
Z_WIN_CASCADE, scaled_duration,
};
// ---------------------------------------------------------------------------
@@ -227,9 +227,7 @@ impl Plugin for WinSummaryPlugin {
// the player's old personal-best values before `StatsPlugin` overwrites them.
.add_systems(
Update,
cache_win_data
.after(GameMutation)
.before(StatsUpdate),
cache_win_data.after(GameMutation).before(StatsUpdate),
)
.add_systems(
Update,
@@ -351,10 +349,17 @@ impl ScoreBreakdown {
} else {
scaled.clamp(i32::MIN as f32, i32::MAX as f32) as i32
};
let no_undo_bonus = if undo_count == 0 { SCORE_NO_UNDO_BONUS } else { 0 };
let no_undo_bonus = if undo_count == 0 {
SCORE_NO_UNDO_BONUS
} else {
0
};
let multiplier = match mode {
GameMode::Zen => 0.0,
GameMode::Classic | GameMode::Challenge | GameMode::TimeAttack | GameMode::Difficulty(_) => 1.0,
GameMode::Classic
| GameMode::Challenge
| GameMode::TimeAttack
| GameMode::Difficulty(_) => 1.0,
};
Self {
base,
@@ -549,10 +554,9 @@ fn spawn_win_summary_after_delay(
// intensity is left at its design-token value because amplitude
// does not benefit from "fast" / "instant" scaling — at Instant
// speed the duration is zero anyway, suppressing the shake.
let speed = settings.as_ref().map_or(
solitaire_data::AnimSpeed::Normal,
|s| s.0.animation_speed,
);
let speed = settings
.as_ref()
.map_or(solitaire_data::AnimSpeed::Normal, |s| s.0.animation_speed);
let scaled = scaled_duration(SHAKE_DURATION_SECS, speed);
shake.remaining = scaled;
shake.total = scaled;
@@ -632,25 +636,16 @@ fn handle_win_summary_buttons(
new_game.write(NewGameRequestEvent::default());
}
WinSummaryButton::WatchReplay => {
let latest = history
.as_ref()
.and_then(|h| h.0.replays.last())
.cloned();
let latest = history.as_ref().and_then(|h| h.0.replays.last()).cloned();
match (latest, playback.as_mut()) {
(Some(replay), Some(pb)) => {
for entity in &overlays {
commands.entity(entity).despawn();
}
crate::replay_playback::start_replay_playback(
&mut commands,
pb,
replay,
);
crate::replay_playback::start_replay_playback(&mut commands, pb, replay);
}
(Some(_), None) => {
toast.write(InfoToastEvent(
"Replay playback not available".to_string(),
));
toast.write(InfoToastEvent("Replay playback not available".to_string()));
}
(None, _) => {
toast.write(InfoToastEvent("No replay saved yet".to_string()));
@@ -710,7 +705,11 @@ fn apply_screen_shake(
// Decay factor: 1.0 at start, 0.0 at end. Falls back to the design-token
// duration if `total` is zero (older armings or test setups that bypass
// `spawn_win_summary_after_delay`) so we never divide by zero.
let total = if shake.total > 0.0 { shake.total } else { SHAKE_DURATION_SECS };
let total = if shake.total > 0.0 {
shake.total
} else {
SHAKE_DURATION_SECS
};
let decay = shake.remaining / total;
let elapsed = time.elapsed_secs();
let offset_x = (elapsed * 47.0).sin() * shake.intensity * decay;
@@ -792,7 +791,10 @@ fn spawn_overlay(
// Heading
card.spawn((
Text::new("You Won!"),
TextFont { font_size: TYPE_DISPLAY, ..default() },
TextFont {
font_size: TYPE_DISPLAY,
..default()
},
TextColor(ACCENT_PRIMARY),
));
@@ -800,7 +802,10 @@ fn spawn_overlay(
if let Some(level) = challenge_level {
card.spawn((
Text::new(format!("Challenge {level} complete!")),
TextFont { font_size: TYPE_HEADLINE, ..default() },
TextFont {
font_size: TYPE_HEADLINE,
..default()
},
TextColor(STATE_INFO),
));
}
@@ -810,7 +815,10 @@ fn spawn_overlay(
if pending.new_record {
card.spawn((
Text::new("New Record!"),
TextFont { font_size: TYPE_HEADLINE, ..default() },
TextFont {
font_size: TYPE_HEADLINE,
..default()
},
TextColor(STATE_WARNING),
));
}
@@ -822,14 +830,20 @@ fn spawn_overlay(
// Time
card.spawn((
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
TextFont { font_size: TYPE_HEADLINE, ..default() },
TextFont {
font_size: TYPE_HEADLINE,
..default()
},
TextColor(TEXT_PRIMARY),
));
// XP total
card.spawn((
Text::new(format!("XP earned: +{}", pending.xp)),
TextFont { font_size: TYPE_BODY_LG, ..default() },
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(STATE_SUCCESS),
));
@@ -837,7 +851,10 @@ fn spawn_overlay(
if !pending.xp_detail.is_empty() {
card.spawn((
Text::new(pending.xp_detail.clone()),
TextFont { font_size: 15.0, ..default() },
TextFont {
font_size: 15.0,
..default()
},
TextColor(TEXT_SECONDARY),
));
}
@@ -874,7 +891,10 @@ fn spawn_overlay(
.with_children(|b| {
b.spawn((
Text::new("Watch Replay"),
TextFont { font_size: TYPE_BODY_LG, ..default() },
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(ACCENT_PRIMARY),
));
});
@@ -894,7 +914,10 @@ fn spawn_overlay(
.with_children(|b| {
b.spawn((
Text::new("Play Again \u{21B5}"),
TextFont { font_size: TYPE_BODY_LG, ..default() },
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(BG_BASE),
));
});
@@ -915,7 +938,10 @@ const MAX_ACHIEVEMENTS_SHOWN: usize = 3;
fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String]) {
card.spawn((
Text::new("Achievements Unlocked"),
TextFont { font_size: TYPE_BODY_LG, ..default() },
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(ACCENT_PRIMARY),
));
@@ -923,7 +949,10 @@ fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String])
for name in &names[..shown] {
card.spawn((
Text::new(format!(" {name}")),
TextFont { font_size: 16.0, ..default() },
TextFont {
font_size: 16.0,
..default()
},
TextColor(TEXT_PRIMARY),
));
}
@@ -932,7 +961,10 @@ fn spawn_achievements_section(card: &mut ChildSpawnerCommands, names: &[String])
if overflow > 0 {
card.spawn((
Text::new(format!(" ...and {overflow} more")),
TextFont { font_size: 15.0, ..default() },
TextFont {
font_size: 15.0,
..default()
},
TextColor(TEXT_SECONDARY),
));
}
@@ -1091,13 +1123,19 @@ fn spawn_breakdown_row(
// Label — left-aligned.
row.spawn((
Text::new(label.to_string()),
TextFont { font_size: TYPE_BODY, ..default() },
TextFont {
font_size: TYPE_BODY,
..default()
},
TextColor(label_color_with_alpha),
));
// Value — right-aligned via the parent's JustifyContent::SpaceBetween.
row.spawn((
Text::new(value),
TextFont { font_size: TYPE_BODY, ..default() },
TextFont {
font_size: TYPE_BODY,
..default()
},
TextColor(value_color_with_alpha),
));
});
@@ -1170,7 +1208,10 @@ mod tests {
app.add_plugins(MinimalPlugins)
.add_plugins(WinSummaryPlugin)
.insert_resource(StatsResource(StatsSnapshot::default()))
.insert_resource(GameStateResource(GameState::new(0, solitaire_core::game_state::DrawMode::DrawOne)))
.insert_resource(GameStateResource(GameState::new(
0,
solitaire_core::game_state::DrawMode::DrawOne,
)))
.insert_resource(ProgressResource(PlayerProgress::default()));
app.update();
app
@@ -1282,17 +1323,18 @@ mod tests {
app.update();
// Confirm it was recorded.
assert_eq!(
app.world().resource::<SessionAchievements>().names.len(),
1
);
assert_eq!(app.world().resource::<SessionAchievements>().names.len(), 1);
// Fire NewGameRequestEvent — should clear the list.
app.world_mut().write_message(NewGameRequestEvent::default());
app.world_mut()
.write_message(NewGameRequestEvent::default());
app.update();
assert!(
app.world().resource::<SessionAchievements>().names.is_empty(),
app.world()
.resource::<SessionAchievements>()
.names
.is_empty(),
"session achievements must be cleared on NewGameRequestEvent"
);
}
@@ -1332,7 +1374,10 @@ mod tests {
app.update();
assert!(
app.world().resource::<SessionAchievements>().names.is_empty(),
app.world()
.resource::<SessionAchievements>()
.names
.is_empty(),
"session achievements must be cleared when a mode-switch NewGameRequestEvent fires"
);
}
@@ -1341,15 +1386,20 @@ mod tests {
fn cache_win_data_sets_score_and_time() {
let mut app = make_app();
app.world_mut()
.write_message(GameWonEvent { score: 1234, time_seconds: 90 });
app.world_mut().write_message(GameWonEvent {
score: 1234,
time_seconds: 90,
});
app.update();
let pending = app.world().resource::<WinSummaryPending>();
assert_eq!(pending.score, 1234);
assert_eq!(pending.time_seconds, 90);
// 90s < 120s → speed bonus present; default game has undo_count=0 → no-undo bonus present.
assert!(!pending.xp_detail.is_empty(), "xp_detail must be populated on GameWonEvent");
assert!(
!pending.xp_detail.is_empty(),
"xp_detail must be populated on GameWonEvent"
);
assert!(pending.xp_detail.contains("+50 base"));
}
@@ -1357,7 +1407,10 @@ mod tests {
fn cache_win_data_sets_xp_from_xp_awarded_event() {
let mut app = make_app();
app.world_mut().write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 0,
});
app.world_mut().write_message(XpAwardedEvent { amount: 75 });
app.update();
@@ -1369,12 +1422,17 @@ mod tests {
fn game_won_event_arms_screen_shake() {
let mut app = make_app();
app.world_mut()
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 0,
});
app.update();
let shake = app.world().resource::<ScreenShakeResource>();
assert!(shake.remaining > 0.0, "shake must be armed after GameWonEvent");
assert!(
shake.remaining > 0.0,
"shake must be armed after GameWonEvent"
);
}
// -----------------------------------------------------------------------
@@ -1387,8 +1445,10 @@ mod tests {
// Any positive-score win should be flagged as a new record.
let mut app = make_app();
app.world_mut()
.write_message(GameWonEvent { score: 500, time_seconds: 120 });
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 120,
});
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -1405,12 +1465,17 @@ mod tests {
}
// Score 500 beats previous best of 400.
app.world_mut()
.write_message(GameWonEvent { score: 500, time_seconds: 300 });
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 300,
});
app.update();
let pending = app.world().resource::<WinSummaryPending>();
assert!(pending.new_record, "beating best score should set new_record");
assert!(
pending.new_record,
"beating best score should set new_record"
);
}
#[test]
@@ -1423,12 +1488,17 @@ mod tests {
}
// Score 500 does not beat 800, but time 100 < 200.
app.world_mut()
.write_message(GameWonEvent { score: 500, time_seconds: 100 });
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 100,
});
app.update();
let pending = app.world().resource::<WinSummaryPending>();
assert!(pending.new_record, "beating fastest time should set new_record");
assert!(
pending.new_record,
"beating fastest time should set new_record"
);
}
#[test]
@@ -1441,8 +1511,10 @@ mod tests {
}
// Score 500 < 800 and time 120 > 60 — neither record broken.
app.world_mut()
.write_message(GameWonEvent { score: 500, time_seconds: 120 });
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 120,
});
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -1472,8 +1544,10 @@ mod tests {
GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::Challenge);
}
app.world_mut()
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 0,
});
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -1488,8 +1562,10 @@ mod tests {
fn classic_win_leaves_challenge_level_none() {
let mut app = make_app();
// Default game mode is Classic — challenge_level should stay None.
app.world_mut()
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 0,
});
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -1519,8 +1595,10 @@ mod tests {
game.0.undo_count = 2;
}
app.world_mut()
.write_message(GameWonEvent { score: 0, time_seconds: 0 });
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 0,
});
app.update();
let pending = app.world().resource::<WinSummaryPending>();
@@ -1552,7 +1630,10 @@ mod tests {
fn score_breakdown_zen_mode_zeros_total() {
let bd = ScoreBreakdown::compute(500, 60, 0, GameMode::Zen, 1.0);
assert!((bd.multiplier - 0.0).abs() < f32::EPSILON);
assert!(bd.shows_multiplier_row(), "Zen ×0 must display the multiplier row");
assert!(
bd.shows_multiplier_row(),
"Zen ×0 must display the multiplier row"
);
assert_eq!(bd.total(), 0);
}
@@ -1616,7 +1697,10 @@ mod tests {
#[test]
fn score_breakdown_applies_time_bonus_multiplier() {
let raw = compute_time_bonus(120);
assert_eq!(raw, 5833, "sanity-check raw bonus before testing the multiplier");
assert_eq!(
raw, 5833,
"sanity-check raw bonus before testing the multiplier"
);
let bd = ScoreBreakdown::compute(0, 120, 0, GameMode::Classic, 0.5);
let expected = ((raw as f32) * 0.5).round() as i32;
@@ -1712,9 +1796,27 @@ mod tests {
// Frame 1: `time.delta` is 0 (first frame), so only row0
// (delay = 0) should reveal.
app.update();
assert!(app.world().entity(row0).get::<ScoreBreakdownRow>().unwrap().revealed);
assert!(!app.world().entity(row1).get::<ScoreBreakdownRow>().unwrap().revealed);
assert!(!app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
assert!(
app.world()
.entity(row0)
.get::<ScoreBreakdownRow>()
.unwrap()
.revealed
);
assert!(
!app.world()
.entity(row1)
.get::<ScoreBreakdownRow>()
.unwrap()
.revealed
);
assert!(
!app.world()
.entity(row2)
.get::<ScoreBreakdownRow>()
.unwrap()
.revealed
);
// Advance time by one stagger interval — row1 should reveal.
{
@@ -1722,8 +1824,20 @@ mod tests {
time.advance_by(std::time::Duration::from_secs_f32(stagger + 0.001));
}
app.update();
assert!(app.world().entity(row1).get::<ScoreBreakdownRow>().unwrap().revealed);
assert!(!app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
assert!(
app.world()
.entity(row1)
.get::<ScoreBreakdownRow>()
.unwrap()
.revealed
);
assert!(
!app.world()
.entity(row2)
.get::<ScoreBreakdownRow>()
.unwrap()
.revealed
);
// Advance again — row2 should reveal.
{
@@ -1731,7 +1845,13 @@ mod tests {
time.advance_by(std::time::Duration::from_secs_f32(stagger + 0.001));
}
app.update();
assert!(app.world().entity(row2).get::<ScoreBreakdownRow>().unwrap().revealed);
assert!(
app.world()
.entity(row2)
.get::<ScoreBreakdownRow>()
.unwrap()
.revealed
);
}
/// Under `AnimSpeed::Instant`, breakdown rows must spawn already