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
+26 -24
View File
@@ -25,10 +25,10 @@
//! old state would be confusing.
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
use solitaire_core::solver::{try_solve_from_state, SolverConfig, SolverResult};
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve_from_state};
use crate::card_plugin::CardEntity;
use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent};
@@ -101,10 +101,7 @@ struct HintTask {
enum HintTaskOutput {
/// Solver verdict was `Winnable`; here is the first move on the
/// solution path.
SolverMove {
from: PileType,
to: PileType,
},
SolverMove { from: PileType, to: PileType },
/// Solver was `Unwinnable` or `Inconclusive`. The poll system
/// runs the legacy heuristic against the live `GameState` so the
/// H key always produces feedback while any legal move exists.
@@ -162,15 +159,13 @@ pub fn poll_pending_hint_task(
let (from, to) = match output {
HintTaskOutput::SolverMove { from, to } => (from, to),
HintTaskOutput::NeedsHeuristic => {
match find_heuristic_hint(&g.0, &mut hint_cycle) {
Some(pair) => pair,
None => {
info_toast.write(InfoToastEvent("No hints available".to_string()));
return;
}
HintTaskOutput::NeedsHeuristic => match find_heuristic_hint(&g.0, &mut hint_cycle) {
Some(pair) => pair,
None => {
info_toast.write(InfoToastEvent("No hints available".to_string()));
return;
}
}
},
};
emit_hint_visuals(
&g.0,
@@ -209,11 +204,7 @@ mod tests {
// poll fire before the drop.
app.add_systems(
Update,
(
drop_pending_hint_on_state_change,
poll_pending_hint_task,
)
.chain(),
(drop_pending_hint_on_state_change, poll_pending_hint_task).chain(),
);
app
}
@@ -241,9 +232,18 @@ mod tests {
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let ranks_below_king = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
Rank::Jack, Rank::Queen,
Rank::Ace,
Rank::Two,
Rank::Three,
Rank::Four,
Rank::Five,
Rank::Six,
Rank::Seven,
Rank::Eight,
Rank::Nine,
Rank::Ten,
Rank::Jack,
Rank::Queen,
];
for (slot, suit) in suits.iter().enumerate() {
let pile = game
@@ -304,7 +304,8 @@ mod tests {
let mut cursor = messages.get_cursor();
let collected: Vec<HintVisualEvent> = cursor.read(messages).cloned().collect();
assert_eq!(
collected.len(), 1,
collected.len(),
1,
"exactly one HintVisualEvent must fire when the solver returns Winnable",
);
assert!(
@@ -395,7 +396,8 @@ mod tests {
let mut cursor = messages.get_cursor();
let collected: Vec<HintVisualEvent> = cursor.read(messages).cloned().collect();
assert_eq!(
collected.len(), 1,
collected.len(),
1,
"cancel-on-replace: only the surviving task's result emits a visual",
);
}