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:
+187
-58
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user