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
+187 -58
View File
@@ -64,7 +64,7 @@ use std::collections::HashSet;
use std::hash::{Hash, Hasher};
use crate::card::{Card, Suit};
use crate::deck::{deal_klondike, Deck};
use crate::deck::{Deck, deal_klondike};
use crate::game_state::{DrawMode, GameState};
use crate::pile::{Pile, PileType};
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
@@ -212,7 +212,11 @@ pub fn try_solve_from_state(state: &GameState, config: &SolverConfig) -> SolveOu
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InternalMove {
/// Move `count` cards from a tableau column to another tableau column.
TableauToTableau { from: usize, to: usize, count: usize },
TableauToTableau {
from: usize,
to: usize,
count: usize,
},
/// Move the top of a tableau column to a foundation slot.
TableauToFoundation { from: usize, slot: u8 },
/// Move the top of the waste pile to a tableau column.
@@ -303,10 +307,9 @@ impl SolverState {
self.foundation.iter().all(|pile| {
pile.len() == 13
&& pile[0].rank == crate::card::Rank::Ace
&& pile.windows(2).all(|w| {
w[0].suit == w[1].suit
&& w[1].rank.value() == w[0].rank.value() + 1
})
&& pile
.windows(2)
.all(|w| w[0].suit == w[1].suit && w[1].rank.value() == w[0].rank.value() + 1)
})
}
@@ -350,10 +353,8 @@ impl SolverState {
&& top.face_up
&& let Some(slot) = self.target_foundation_slot(top.suit)
{
let foundation_pile = Self::pile_view(
PileType::Foundation(slot),
&self.foundation[slot as usize],
);
let foundation_pile =
Self::pile_view(PileType::Foundation(slot), &self.foundation[slot as usize]);
if can_place_on_foundation(top, &foundation_pile) {
moves.push(InternalMove::TableauToFoundation { from: i, slot });
}
@@ -364,10 +365,8 @@ impl SolverState {
if let Some(top) = self.waste.last()
&& let Some(slot) = self.target_foundation_slot(top.suit)
{
let foundation_pile = Self::pile_view(
PileType::Foundation(slot),
&self.foundation[slot as usize],
);
let foundation_pile =
Self::pile_view(PileType::Foundation(slot), &self.foundation[slot as usize]);
if can_place_on_foundation(top, &foundation_pile) {
moves.push(InternalMove::WasteToFoundation { slot });
}
@@ -401,13 +400,14 @@ impl SolverState {
// column onto another empty column".
let leaves_source_empty = start == 0;
let dest_empty = self.tableau[dst].is_empty();
if leaves_source_empty
&& dest_empty
&& bottom.rank == crate::card::Rank::King
{
if leaves_source_empty && dest_empty && bottom.rank == crate::card::Rank::King {
continue;
}
moves.push(InternalMove::TableauToTableau { from: src, to: dst, count });
moves.push(InternalMove::TableauToTableau {
from: src,
to: dst,
count,
});
}
}
}
@@ -432,8 +432,7 @@ impl SolverState {
// a single full cycle requires at most `ceil(stock_cycle_len / draw_count)`
// draws (Draw 1 → exactly stock_cycle_len; Draw 3 → fewer), so
// anything past that without intervening progress is wasteful.
let cycled_without_progress =
self.consecutive_draws > stock_cycle_len.saturating_add(1);
let cycled_without_progress = self.consecutive_draws > stock_cycle_len.saturating_add(1);
if can_draw && !cycled_without_progress {
moves.push(InternalMove::Draw);
}
@@ -578,9 +577,7 @@ impl SolverState {
while let Some(frame) = stack.last_mut() {
// Budget gates — checked before consuming the next move so
// the budget exhaustion is reflected in the verdict.
if *moves_consumed >= config.move_budget
|| visited.len() >= config.state_budget
{
if *moves_consumed >= config.move_budget || visited.len() >= config.state_budget {
*budget_exceeded = true;
return None;
}
@@ -622,7 +619,12 @@ impl SolverState {
let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false;
let already_won = self.is_won();
let first_move = self.search(config, &mut visited, &mut moves_consumed, &mut budget_exceeded);
let first_move = self.search(
config,
&mut visited,
&mut moves_consumed,
&mut budget_exceeded,
);
let result = if already_won || first_move.is_some() {
SolverResult::Winnable
} else if budget_exceeded {
@@ -800,18 +802,38 @@ mod tests {
}
fn ace(suit: Suit, id: u32) -> Card {
Card { id, suit, rank: Rank::Ace, face_up: true }
Card {
id,
suit,
rank: Rank::Ace,
face_up: true,
}
}
fn rank_card(suit: Suit, rank: Rank, id: u32) -> Card {
Card { id, suit, rank, face_up: true }
Card {
id,
suit,
rank,
face_up: true,
}
}
fn full_run(suit: Suit, base_id: u32) -> Vec<Card> {
let ranks = [
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::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::King,
];
ranks
.iter()
@@ -846,14 +868,28 @@ mod tests {
tableau[2] = vec![rank_card(Suit::Hearts, Rank::King, 102)];
tableau[3] = vec![rank_card(Suit::Spades, Rank::King, 103)];
let state = synthetic(tableau, foundations, Vec::new(), Vec::new(), DrawMode::DrawOne);
let state = synthetic(
tableau,
foundations,
Vec::new(),
Vec::new(),
DrawMode::DrawOne,
);
let mut visited: HashSet<u64> = HashSet::new();
let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false;
let cfg = SolverConfig::default();
let first_move = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
let first_move = state.search(
&cfg,
&mut visited,
&mut moves_consumed,
&mut budget_exceeded,
);
assert!(first_move.is_some(), "obviously-winnable position must be recognised as Winnable");
assert!(
first_move.is_some(),
"obviously-winnable position must be recognised as Winnable"
);
assert!(!budget_exceeded);
assert!(
moves_consumed < 1000,
@@ -872,8 +908,18 @@ mod tests {
// Column 0: bottom-to-top [A♠, 2♠]. The Ace is the bottom
// card; the Two on top of it has no valid destination.
tableau[0] = vec![
Card { id: 0, suit: Suit::Spades, rank: Rank::Ace, face_up: true },
Card { id: 1, suit: Suit::Spades, rank: Rank::Two, face_up: true },
Card {
id: 0,
suit: Suit::Spades,
rank: Rank::Ace,
face_up: true,
},
Card {
id: 1,
suit: Suit::Spades,
rank: Rank::Two,
face_up: true,
},
];
// Other six columns isolated. Put a face-up King with no
// matching Queen anywhere — it cannot move because every
@@ -894,9 +940,20 @@ mod tests {
let mut visited: HashSet<u64> = HashSet::new();
let mut moves_consumed: u64 = 0;
let mut budget_exceeded = false;
let first_move = state.search(&cfg, &mut visited, &mut moves_consumed, &mut budget_exceeded);
assert!(first_move.is_none(), "buried Ace under same-suit Two with no recovery must not solve");
assert!(!budget_exceeded, "small synthetic state must complete within budget");
let first_move = state.search(
&cfg,
&mut visited,
&mut moves_consumed,
&mut budget_exceeded,
);
assert!(
first_move.is_none(),
"buried Ace under same-suit Two with no recovery must not solve"
);
assert!(
!budget_exceeded,
"small synthetic state must complete within budget"
);
}
#[test]
@@ -960,9 +1017,12 @@ mod tests {
#[test]
fn longest_face_up_run_handles_face_down_at_top() {
let cards = vec![
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: false },
];
let cards = vec![Card {
id: 1,
suit: Suit::Spades,
rank: Rank::King,
face_up: false,
}];
assert_eq!(longest_face_up_run(&cards), 0);
}
@@ -970,10 +1030,30 @@ mod tests {
fn longest_face_up_run_extends_through_valid_run() {
let cards = vec![
// bottom: face-down filler
Card { id: 0, suit: Suit::Spades, rank: Rank::Two, face_up: false },
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true },
Card { id: 2, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
Card { id: 3, suit: Suit::Clubs, rank: Rank::Jack, face_up: true },
Card {
id: 0,
suit: Suit::Spades,
rank: Rank::Two,
face_up: false,
},
Card {
id: 1,
suit: Suit::Spades,
rank: Rank::King,
face_up: true,
},
Card {
id: 2,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: true,
},
Card {
id: 3,
suit: Suit::Clubs,
rank: Rank::Jack,
face_up: true,
},
];
assert_eq!(longest_face_up_run(&cards), 3);
}
@@ -983,9 +1063,24 @@ mod tests {
// K♠ Q♥ Q♣ — second pair fails the descending check, so the
// run is just the top single card (Q♣).
let cards = vec![
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true },
Card { id: 2, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
Card { id: 3, suit: Suit::Clubs, rank: Rank::Queen, face_up: true },
Card {
id: 1,
suit: Suit::Spades,
rank: Rank::King,
face_up: true,
},
Card {
id: 2,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: true,
},
Card {
id: 3,
suit: Suit::Clubs,
rank: Rank::Queen,
face_up: true,
},
];
assert_eq!(longest_face_up_run(&cards), 1);
}
@@ -1082,7 +1177,9 @@ mod tests {
println!(
"\nmedian: {median} ms mean: {} ms Winnable: {} Unwinnable: {} Inconclusive: {}",
total / samples_ms.len() as u128,
counts[0], counts[1], counts[2],
counts[0],
counts[1],
counts[2],
);
}
@@ -1122,9 +1219,18 @@ mod tests {
// `target_foundation_slot` ordering.
let suit_for_slot = [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 suit_for_slot.iter().enumerate() {
let pile = game
@@ -1166,7 +1272,9 @@ mod tests {
SolverResult::Winnable,
"near-finished state must solve as Winnable"
);
let mv = outcome.first_move.expect("Winnable must include a first_move");
let mv = outcome
.first_move
.expect("Winnable must include a first_move");
// The first move must be a King going from a tableau column to
// its matching foundation slot. Single-card move.
assert_eq!(mv.count, 1);
@@ -1200,15 +1308,30 @@ mod tests {
// Tableau 0: A♠ on the bottom, 2♠ on top — the 2♠ has no legal
// destination, so the Ace is buried forever.
let t0 = game.piles.get_mut(&PileType::Tableau(0)).unwrap();
t0.cards.push(Card { id: 0, suit: Suit::Spades, rank: Rank::Ace, face_up: true });
t0.cards.push(Card { id: 1, suit: Suit::Spades, rank: Rank::Two, face_up: true });
t0.cards.push(Card {
id: 0,
suit: Suit::Spades,
rank: Rank::Ace,
face_up: true,
});
t0.cards.push(Card {
id: 1,
suit: Suit::Spades,
rank: Rank::Two,
face_up: true,
});
// Tableau 1: a face-up King with nothing else — irrelevant; the
// pruning check elides "King → empty" no-ops.
game.piles
.get_mut(&PileType::Tableau(1))
.unwrap()
.cards
.push(Card { id: 2, suit: Suit::Clubs, rank: Rank::King, face_up: true });
.push(Card {
id: 2,
suit: Suit::Clubs,
rank: Rank::King,
face_up: true,
});
let cfg = SolverConfig::default();
let outcome = try_solve_from_state(&game, &cfg);
@@ -1248,7 +1371,13 @@ mod tests {
let a = try_solve_with_first_move(7, DrawMode::DrawOne, &cfg);
let game = GameState::new(7, DrawMode::DrawOne);
let b = try_solve_from_state(&game, &cfg);
assert_eq!(a.result, b.result, "verdicts must match across the two entry points");
assert_eq!(a.first_move, b.first_move, "first_move must match across the two entry points");
assert_eq!(
a.result, b.result,
"verdicts must match across the two entry points"
);
assert_eq!(
a.first_move, b.first_move,
"first_move must match across the two entry points"
);
}
}