Compare commits

...

5 Commits

Author SHA1 Message Date
funman300 db1cc58f3a Merge pull request 'refactor(core): align Spider with upstream card_game idioms' (#162) from refactor/spider-upstream-idioms into master
Build and Deploy / build-and-push (push) Successful in 6m17s
Web E2E / web-e2e (push) Successful in 6m11s
Web WASM Rebuild / rebuild (push) Successful in 10m7s
Android Release / build-apk (push) Successful in 6m21s
Test / test (push) Successful in 36m4s
2026-07-09 05:26:50 +00:00
funman300 255b781420 Merge pull request 'feat(engine): group HUD menu into Play/You/Community/System + Esc dismissal audit' (#165) from feat/menu-grouping into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m12s
Test / test (push) Successful in 36m2s
2026-07-09 05:10:02 +00:00
funman300 4700bd7912 fix(engine): close Settings, Help, Leaderboard, and theme store on Esc
Test / test (pull_request) Successful in 10m16s
Phase C dismissal audit: Esc / scrim-tap / Done must behave the same
on every modal. Stragglers found and fixed — none of these had any
Esc path (pause's toggle guard swallowed the key while they were
open):

- Settings: Esc clears SettingsScreen, gated on being the topmost
  modal so a stacked sync-setup / theme-store dialog owns Esc
- Help: Esc closes alongside F1/Done (the code comment already
  claimed an Esc path existed — now it does)
- Leaderboard: Esc closes when topmost; the display-name dialog
  stacked above it now Esc-cancels like sync-setup's dialog
- Theme store: Esc closes (always topmost when open)

Scrim-tap opt-ins are unchanged — ui_modal documents which modals
deliberately stay non-dismissible on outside clicks.

Tests: escape_closes_help_screen, escape_closes_settings_screen_flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 20:31:55 -07:00
funman300 d4448bf0cd feat(engine): group HUD menu popover into Play/You/Community/System sections
Phase C of docs/ui-redesign-2026-07.md. The Modes row is gone — Home
owns mode selection, so the popover's Play section carries a Home row
firing the new ToggleHomeRequestEvent (read by toggle_home_screen
alongside the existing M accelerator). Section headers are quiet
caption-size labels inside the existing panel widget, not a new
widget. The action-bar Modes button and its popover are untouched
(their removal is Phase B territory when Home gains hierarchy).

- MenuOption: Modes variant replaced by Home; rows grouped Play (Home)
  · You (Profile, Stats, Achievements) · Community (Leaderboard) ·
  System (Settings, Help)
- handle_menu_option_click no longer chains into spawn_modes_popover
- Tests: tooltip sweep updated (7 rows still), new
  toggle_home_event_opens_home_screen covers the popover's open path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 20:31:44 -07:00
funman300 1d2b6dc5de refactor(core): align Spider with upstream card_game idioms
Test / test (pull_request) Successful in 37m4s
Addresses upstream author review of the Spider core (PR #157):

- RNG: drop the hand-rolled SplitMix64 + Fisher-Yates; deals now use
  rand::rngs::StdRng seeded via seed_from_u64 + SliceRandom::shuffle,
  exactly like klondike::with_seed. solitaire_core gains the same
  pinned rand dep klondike uses (0.10.1, std_rng only — already in the
  lock, no new transitive deps). Deals for a given seed change; Spider
  has no persisted games yet, so nothing breaks.
- Enums over integers: pile indices and card counts in the instruction
  type are now SpiderTableau (Tableau1..Tableau10, with an ALL const)
  and RunLength (Run1..Run13); out-of-range piles and zero-card moves
  are unrepresentable. Rank comparisons use Rank::checked_add instead
  of u8 arithmetic.
- Fixed-size containers: build_deck returns Stack<104> instead of
  Vec<Card>; possible_instructions is a const-iterated SpiderIter +
  validity filter (klondike's KlondikeIter pattern) instead of
  collecting into a Vec.
- Upstream naming: SpiderGame -> Spider (matches Klondike),
  tableau_face_up/_down -> tableau_face_up_cards/_down_cards,
  stock_len -> stock() accessor, with_rng added alongside with_seed.
- Solver access: SpiderGameState now exposes session(), the same
  escape hatch GameState has — the wrapper no longer pretends to hide
  solve(), which SessionConfig budgets already gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:58:57 -07:00
14 changed files with 663 additions and 279 deletions
Generated
+1
View File
@@ -7331,6 +7331,7 @@ dependencies = [
"card_game", "card_game",
"klondike", "klondike",
"proptest", "proptest",
"rand 0.10.1",
"serde", "serde",
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
+5
View File
@@ -16,6 +16,11 @@ serde = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
klondike = { workspace = true } klondike = { workspace = true }
card_game = { workspace = true } card_game = { workspace = true }
# Deliberately NOT the workspace rand (0.9): this pins the exact dep the
# upstream `klondike` crate uses, so `SeedableRng`/`SliceRandom` resolve
# against the same crate version as `klondike::Rng` and Spider deals go
# through the identical shuffle stack as Klondike deals.
rand = { version = "0.10.1", default-features = false, features = ["std_rng"] }
[lints] [lints]
workspace = true workspace = true
+2 -2
View File
@@ -25,8 +25,8 @@ pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, So
// Spider rules (second `card_game::Game` implementation; engine UI is a // Spider rules (second `card_game::Game` implementation; engine UI is a
// later phase — nothing outside solitaire_core consumes these yet). // later phase — nothing outside solitaire_core consumes these yet).
pub use spider::{ pub use spider::{
SpiderConfig, SpiderGame, SpiderGameState, SpiderInstruction, SpiderScoring, SpiderStats, RunLength, Spider, SpiderConfig, SpiderGameState, SpiderInstruction, SpiderIter, SpiderMove,
SpiderSuits, SpiderScoring, SpiderStats, SpiderSuits, SpiderTableau,
}; };
/// All four foundation slots, in slot order. /// All four foundation slots, in slot order.
+396 -186
View File
@@ -22,11 +22,10 @@
//! //!
//! ## Determinism //! ## Determinism
//! //!
//! Deals are seeded with an inline SplitMix64 + FisherYates shuffle: //! Deals are seeded exactly like upstream Klondike: [`Rng`] is the same
//! `solitaire_core` has no `rand` dependency (and adding one needs //! `rand::rngs::StdRng` alias `klondike` exports, seeded through
//! explicit approval), so Spider's seed space is deliberately //! `SeedableRng::seed_from_u64` and applied with a `SliceRandom`
//! self-contained rather than shared with Klondike's `StdRng` seeds. //! shuffle. The same seed + suit count always produces the same deal.
//! The same seed + suit count always produces the same deal.
//! //!
//! ## Card identity caveat (engine integration, later phase) //! ## Card identity caveat (engine integration, later phase)
//! //!
@@ -43,6 +42,10 @@ use serde::{Deserialize, Serialize};
use crate::error::MoveError; use crate::error::MoveError;
/// The RNG Spider deals with — the same alias upstream `klondike`
/// exports, so both games share one shuffle stack.
pub type Rng = rand::rngs::StdRng;
/// Number of tableau piles. /// Number of tableau piles.
pub const SPIDER_TABLEAUS: usize = 10; pub const SPIDER_TABLEAUS: usize = 10;
/// Total cards in play (two decks). /// Total cards in play (two decks).
@@ -146,24 +149,212 @@ impl SpiderStats {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Instruction // Piles, run lengths, and instructions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// One of the ten Spider tableau piles, in layout order.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SpiderTableau {
/// Leftmost pile (opens with 6 cards).
Tableau1,
/// Second pile (opens with 6 cards).
Tableau2,
/// Third pile (opens with 6 cards).
Tableau3,
/// Fourth pile (opens with 6 cards).
Tableau4,
/// Fifth pile (opens with 5 cards).
Tableau5,
/// Sixth pile (opens with 5 cards).
Tableau6,
/// Seventh pile (opens with 5 cards).
Tableau7,
/// Eighth pile (opens with 5 cards).
Tableau8,
/// Ninth pile (opens with 5 cards).
Tableau9,
/// Rightmost pile (opens with 5 cards).
Tableau10,
}
impl SpiderTableau {
/// All ten piles, in layout order — the canonical iteration source,
/// following `Suit::SUITS` / `Rank::RANKS` upstream.
pub const ALL: [Self; SPIDER_TABLEAUS] = [
Self::Tableau1,
Self::Tableau2,
Self::Tableau3,
Self::Tableau4,
Self::Tableau5,
Self::Tableau6,
Self::Tableau7,
Self::Tableau8,
Self::Tableau9,
Self::Tableau10,
];
const ITER_BEGIN: Self = Self::Tableau1;
const fn next(self) -> Option<Self> {
use SpiderTableau::*;
Some(match self {
Tableau1 => Tableau2,
Tableau2 => Tableau3,
Tableau3 => Tableau4,
Tableau4 => Tableau5,
Tableau5 => Tableau6,
Tableau6 => Tableau7,
Tableau7 => Tableau8,
Tableau8 => Tableau9,
Tableau9 => Tableau10,
Tableau10 => return None,
})
}
/// Zero-based position in layout order.
const fn index(self) -> usize {
self as usize
}
}
/// How many cards a [`SpiderMove`] picks up — at most 13, since a
/// movable run is same-suit strictly-descending.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum RunLength {
/// A single card.
Run1 = 1,
/// The top 2 cards.
Run2 = 2,
/// The top 3 cards.
Run3 = 3,
/// The top 4 cards.
Run4 = 4,
/// The top 5 cards.
Run5 = 5,
/// The top 6 cards.
Run6 = 6,
/// The top 7 cards.
Run7 = 7,
/// The top 8 cards.
Run8 = 8,
/// The top 9 cards.
Run9 = 9,
/// The top 10 cards.
Run10 = 10,
/// The top 11 cards.
Run11 = 11,
/// The top 12 cards.
Run12 = 12,
/// A full K→A run.
Run13 = 13,
}
impl RunLength {
const ITER_BEGIN: Self = Self::Run1;
const fn next(self) -> Option<Self> {
use RunLength::*;
Some(match self {
Run1 => Run2,
Run2 => Run3,
Run3 => Run4,
Run4 => Run5,
Run5 => Run6,
Run6 => Run7,
Run7 => Run8,
Run8 => Run9,
Run9 => Run10,
Run10 => Run11,
Run11 => Run12,
Run12 => Run13,
Run13 => return None,
})
}
/// Number of cards in the run.
pub const fn len(self) -> usize {
self as usize
}
/// True only for [`Self::Run1`]; provided because clippy expects an
/// `is_empty` alongside `len`, and a run is never actually empty.
pub const fn is_empty(self) -> bool {
false
}
}
/// Move the top [`RunLength`] cards (a same-suit descending run) from
/// one pile to another.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SpiderMove {
/// Source pile.
pub from: SpiderTableau,
/// Number of cards picked up from the top of `from`.
pub run: RunLength,
/// Destination pile.
pub to: SpiderTableau,
}
impl SpiderMove {
const ITER_BEGIN: Self = Self {
from: SpiderTableau::ITER_BEGIN,
run: RunLength::ITER_BEGIN,
to: SpiderTableau::ITER_BEGIN,
};
const fn next(self) -> Option<Self> {
let Self { from, run, to } = self;
if let Some(to) = to.next() {
return Some(Self { from, run, to });
}
let to = SpiderTableau::ITER_BEGIN;
if let Some(run) = run.next() {
return Some(Self { from, run, to });
}
let run = RunLength::ITER_BEGIN;
if let Some(from) = from.next() {
return Some(Self { from, run, to });
}
None
}
}
/// One atomic Spider action (the `Game::Instruction` type). /// One atomic Spider action (the `Game::Instruction` type).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SpiderInstruction { pub enum SpiderInstruction {
/// Move a run between tableau piles.
Move(SpiderMove),
/// Deal one card from the stock onto every tableau pile. /// Deal one card from the stock onto every tableau pile.
Deal, Deal,
/// Move the top `count` face-up cards (a same-suit descending run) }
/// from pile `from` to pile `to`. Pile indices are `0..10`.
Move { impl SpiderInstruction {
/// Source pile index. const ITER_BEGIN: Self = Self::Move(SpiderMove::ITER_BEGIN);
from: u8, const fn next(self) -> Option<Self> {
/// Destination pile index. Some(match self {
to: u8, Self::Move(spider_move) => match spider_move.next() {
/// Number of cards in the moved run (≥ 1). Some(spider_move) => Self::Move(spider_move),
count: u8, None => Self::Deal,
}, },
Self::Deal => return None,
})
}
}
/// Exhaustive walk of the Spider instruction space, mirroring
/// `klondike::KlondikeIter`.
pub struct SpiderIter {
instruction: Option<SpiderInstruction>,
}
impl SpiderIter {
const fn new() -> Self {
Self {
instruction: Some(SpiderInstruction::ITER_BEGIN),
}
}
}
impl Iterator for SpiderIter {
type Item = SpiderInstruction;
fn next(&mut self) -> Option<Self::Item> {
let instruction = self.instruction;
self.instruction = instruction?.next();
instruction
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -174,45 +365,22 @@ pub enum SpiderInstruction {
/// of completed runs. Everything else (undo, score bookkeeping) lives /// of completed runs. Everything else (undo, score bookkeeping) lives
/// in the wrapping [`Session`]. /// in the wrapping [`Session`].
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SpiderGame { pub struct Spider {
tableaus: [Pile<MAX_FACE_DOWN, SPIDER_DECK_SIZE>; SPIDER_TABLEAUS], tableaus: [Pile<MAX_FACE_DOWN, SPIDER_DECK_SIZE>; SPIDER_TABLEAUS],
stock: Stack<STOCK_SIZE>, stock: Stack<STOCK_SIZE>,
completed_runs: u8, completed_runs: u8,
} }
/// SplitMix64 step — small, well-known, and dependency-free. Spider
/// only needs a deterministic shuffle, not cryptographic quality.
const fn splitmix64(state: u64) -> (u64, u64) {
let state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
(state, z ^ (z >> 31))
}
/// In-place FisherYates driven by SplitMix64. The modulo bias is
/// astronomically small for n ≤ 104 and irrelevant for gameplay.
fn shuffle(cards: &mut [Card], seed: u64) {
let mut state = seed;
for i in (1..cards.len()).rev() {
let (next_state, r) = splitmix64(state);
state = next_state;
#[allow(clippy::cast_possible_truncation)]
let j = (r % (i as u64 + 1)) as usize;
cards.swap(i, j);
}
}
/// Builds the 104-card Spider deck for a suit-count difficulty. /// Builds the 104-card Spider deck for a suit-count difficulty.
/// ///
/// Deck ids spread copies apart where possible (`Deck1..Deck4`), but /// Deck ids spread copies apart where possible (`Deck1..Deck4`), but
/// 1- and 2-suit games necessarily contain identical `Card` values — /// 1- and 2-suit games necessarily contain identical `Card` values —
/// see the module docs. /// see the module docs.
fn build_deck(suits: SpiderSuits) -> Vec<Card> { fn build_deck(suits: SpiderSuits) -> Stack<SPIDER_DECK_SIZE> {
let suit_set = suits.suits(); let suit_set = suits.suits();
let copies = SPIDER_DECK_SIZE / (suit_set.len() * RUN_LEN); let copies = SPIDER_DECK_SIZE / (suit_set.len() * RUN_LEN);
let decks = [Deck::Deck1, Deck::Deck2, Deck::Deck3, Deck::Deck4]; let decks = [Deck::Deck1, Deck::Deck2, Deck::Deck3, Deck::Deck4];
let mut cards = Vec::with_capacity(SPIDER_DECK_SIZE); let mut cards = Stack::new();
for copy in 0..copies { for copy in 0..copies {
for &suit in suit_set { for &suit in suit_set {
for rank in Rank::RANKS { for rank in Rank::RANKS {
@@ -223,15 +391,24 @@ fn build_deck(suits: SpiderSuits) -> Vec<Card> {
cards cards
} }
impl SpiderGame { impl Spider {
/// Deals a new seeded game at the given suit difficulty. /// Deals a new seeded game at the given suit difficulty.
pub fn with_seed(seed: u64, suits: SpiderSuits) -> Self { pub fn with_seed(seed: u64, suits: SpiderSuits) -> Self {
use rand::SeedableRng;
let mut rng = Rng::seed_from_u64(seed);
Self::with_rng(&mut rng, suits)
}
/// Deals a new game from a caller-supplied RNG.
pub fn with_rng(rng: &mut Rng, suits: SpiderSuits) -> Self {
// shuffle a new two-deck spread
let mut deck = build_deck(suits); let mut deck = build_deck(suits);
shuffle(&mut deck, seed); use rand::seq::SliceRandom;
deck.shuffle(rng);
let mut cards = deck.into_iter(); let mut cards = deck.into_iter();
let tableaus = core::array::from_fn(|index| { let tableaus = core::array::from_fn(|index| {
// Piles 03 open with 5 face-down cards, piles 49 with 4; // Piles 14 open with 5 face-down cards, piles 510 with 4;
// one face-up card lands on each afterwards. // one face-up card lands on each afterwards.
let down_count = if index < 4 { 5 } else { 4 }; let down_count = if index < 4 { 5 } else { 4 };
let stack: Stack<MAX_FACE_DOWN> = cards.by_ref().take(down_count).collect(); let stack: Stack<MAX_FACE_DOWN> = cards.by_ref().take(down_count).collect();
@@ -250,22 +427,24 @@ impl SpiderGame {
} }
} }
/// Face-up cards of pile `index` (bottom → top). Empty slice for /// Face-up cards of a pile (bottom → top).
/// out-of-range indices. pub fn tableau_face_up_cards(&self, tableau: SpiderTableau) -> &[Card] {
pub fn tableau_face_up(&self, index: usize) -> &[Card] { self.tableaus[tableau.index()].face_up()
self.tableaus.get(index).map_or(&[], |pile| pile.face_up())
} }
/// Face-down cards of pile `index` (bottom → top). /// Face-down cards of a pile (bottom → top).
pub fn tableau_face_down(&self, index: usize) -> &[Card] { pub fn tableau_face_down_cards(&self, tableau: SpiderTableau) -> &[Card] {
self.tableaus self.tableaus[tableau.index()].face_down()
.get(index)
.map_or(&[], |pile| pile.face_down())
} }
/// Cards remaining in the stock. /// Topmost face-up card of a pile.
pub fn stock_len(&self) -> usize { pub fn tableau_top_card(&self, tableau: SpiderTableau) -> Option<&Card> {
self.stock.len() self.tableaus[tableau.index()].face_up().last()
}
/// The stock (deals come off the top).
pub const fn stock(&self) -> &Stack<STOCK_SIZE> {
&self.stock
} }
/// Completed K→A runs removed from play so far. /// Completed K→A runs removed from play so far.
@@ -273,19 +452,16 @@ impl SpiderGame {
self.completed_runs self.completed_runs
} }
/// Length of the longest movable run on top of pile `index`: the /// Length of the longest movable run on top of a pile: the maximal
/// maximal same-suit, strictly-descending face-up suffix. /// same-suit, strictly-descending face-up suffix.
fn movable_run_len(&self, index: usize) -> usize { fn movable_run_len(&self, tableau: SpiderTableau) -> usize {
let Some(pile) = self.tableaus.get(index) else { let up = self.tableau_face_up_cards(tableau);
return 0;
};
let up = pile.face_up();
let mut len = usize::from(!up.is_empty()); let mut len = usize::from(!up.is_empty());
while len < up.len() { while len < up.len() {
let above = &up[up.len() - len]; let above = &up[up.len() - len];
let below = &up[up.len() - len - 1]; let below = &up[up.len() - len - 1];
let descends = let descends =
below.suit() == above.suit() && below.rank() as u8 == above.rank() as u8 + 1; below.suit() == above.suit() && above.rank().checked_add(1) == Some(below.rank());
if !descends { if !descends {
break; break;
} }
@@ -294,27 +470,27 @@ impl SpiderGame {
len len
} }
/// Whether `Move { from, to, count }` is legal in this position. /// Whether a run move is legal in this position.
fn is_move_valid(&self, from: u8, to: u8, count: u8) -> bool { fn is_move_valid(&self, spider_move: SpiderMove) -> bool {
let (from, to, count) = (from as usize, to as usize, count as usize); let SpiderMove { from, run, to } = spider_move;
if from == to || from >= SPIDER_TABLEAUS || to >= SPIDER_TABLEAUS || count == 0 { if from == to {
return false; return false;
} }
if count > self.movable_run_len(from) { if run.len() > self.movable_run_len(from) {
return false; return false;
} }
let src_up = self.tableaus[from].face_up(); let src_up = self.tableau_face_up_cards(from);
// Bottom card of the moved run; `count <= movable_run_len <= // Bottom card of the moved run; `run.len() <= movable_run_len
// src_up.len()` guarantees the index is in range. // <= src_up.len()` guarantees the index is in range.
let Some(moved_bottom) = src_up.get(src_up.len() - count) else { let Some(moved_bottom) = src_up.get(src_up.len() - run.len()) else {
return false; return false;
}; };
match self.tableaus[to].face_up().last() { match self.tableau_top_card(to) {
// Build down regardless of suit. // Build down regardless of suit.
Some(dest_top) => dest_top.rank() as u8 == moved_bottom.rank() as u8 + 1, Some(dest_top) => moved_bottom.rank().checked_add(1) == Some(dest_top.rank()),
// Empty pile accepts anything (face-down remnant can't // Empty pile accepts anything (face-down remnant can't
// exist without a face-up card — Pile flips eagerly). // exist without a face-up card — Pile flips eagerly).
None => self.tableaus[to].is_empty(), None => self.tableaus[to.index()].is_empty(),
} }
} }
@@ -323,22 +499,24 @@ impl SpiderGame {
!self.stock.is_empty() && self.tableaus.iter().all(|pile| !pile.is_empty()) !self.stock.is_empty() && self.tableaus.iter().all(|pile| !pile.is_empty())
} }
/// Removes a completed K→A same-suit run from the top of pile /// Removes a completed K→A same-suit run from the top of a pile,
/// `index`, if one is present. Returns `true` when a run was /// if one is present. Returns `true` when a run was removed (and
/// removed (and the next face-down card, if any, was flipped). /// the next face-down card, if any, was flipped).
fn sweep_completed_run(&mut self, index: usize) -> bool { fn sweep_completed_run(&mut self, tableau: SpiderTableau) -> bool {
let Some(pile) = self.tableaus.get_mut(index) else { let pile = &mut self.tableaus[tableau.index()];
return false;
};
let up = pile.face_up(); let up = pile.face_up();
if up.len() < RUN_LEN { if up.len() < RUN_LEN {
return false; return false;
} }
let run = &up[up.len() - RUN_LEN..]; let run = &up[up.len() - RUN_LEN..];
let suit = run[0].suit(); let suit = run[0].suit();
let is_complete = run.iter().enumerate().all(|(offset, card)| { // The run sits bottom-first (K → A); reversed, it must read
card.suit() == suit && card.rank() as u8 == (RUN_LEN - offset) as u8 // exactly Ace → King in one suit.
}); let is_complete = run
.iter()
.rev()
.zip(Rank::RANKS)
.all(|(card, rank)| card.suit() == suit && card.rank() == rank);
if !is_complete { if !is_complete {
return false; return false;
} }
@@ -348,7 +526,7 @@ impl SpiderGame {
} }
} }
impl Game for SpiderGame { impl Game for Spider {
type Score = i32; type Score = i32;
type Stats = SpiderStats; type Stats = SpiderStats;
type Config = SpiderConfig; type Config = SpiderConfig;
@@ -365,34 +543,16 @@ impl Game for SpiderGame {
&self, &self,
config: &Self::Config, config: &Self::Config,
) -> impl Iterator<Item = Self::Instruction> + use<> { ) -> impl Iterator<Item = Self::Instruction> + use<> {
let mut out = Vec::new(); let state = self.clone();
if self.is_deal_valid() { let config = config.clone();
out.push(SpiderInstruction::Deal); SpiderIter::new()
} .filter(move |&instruction| state.is_instruction_valid(&config, instruction))
for from in 0..SPIDER_TABLEAUS {
let max_run = self.movable_run_len(from);
for count in 1..=max_run {
for to in 0..SPIDER_TABLEAUS {
#[allow(clippy::cast_possible_truncation)]
let instruction = SpiderInstruction::Move {
from: from as u8,
to: to as u8,
count: count as u8,
};
if self.is_move_valid(from as u8, to as u8, count as u8) {
out.push(instruction);
}
}
}
}
let _ = config;
out.into_iter()
} }
fn is_instruction_valid(&self, _config: &Self::Config, instruction: Self::Instruction) -> bool { fn is_instruction_valid(&self, _config: &Self::Config, instruction: Self::Instruction) -> bool {
match instruction { match instruction {
SpiderInstruction::Deal => self.is_deal_valid(), SpiderInstruction::Deal => self.is_deal_valid(),
SpiderInstruction::Move { from, to, count } => self.is_move_valid(from, to, count), SpiderInstruction::Move(spider_move) => self.is_move_valid(spider_move),
} }
} }
@@ -413,24 +573,24 @@ impl Game for SpiderGame {
match instruction { match instruction {
SpiderInstruction::Deal => { SpiderInstruction::Deal => {
stats.deals += 1; stats.deals += 1;
for index in 0..SPIDER_TABLEAUS { for tableau in SpiderTableau::ALL {
match self.stock.pop() { match self.stock.pop() {
Some(card) => self.tableaus[index].push(card), Some(card) => self.tableaus[tableau.index()].push(card),
None => break, None => break,
} }
} }
for index in 0..SPIDER_TABLEAUS { for tableau in SpiderTableau::ALL {
if self.sweep_completed_run(index) { if self.sweep_completed_run(tableau) {
self.completed_runs += 1; self.completed_runs += 1;
stats.runs_completed += 1; stats.runs_completed += 1;
} }
} }
} }
SpiderInstruction::Move { from, to, count } => { SpiderInstruction::Move(SpiderMove { from, run, to }) => {
let (from, to, count) = (from as usize, to as usize, count as usize); let src_len = self.tableaus[from.index()].face_up().len();
let src_len = self.tableaus[from].face_up().len(); let (cards, _flipped) =
let (cards, _flipped) = self.tableaus[from].take_range_flip_up(src_len - count..); self.tableaus[from.index()].take_range_flip_up(src_len - run.len()..);
self.tableaus[to].extend(cards); self.tableaus[to.index()].extend(cards);
if self.sweep_completed_run(to) { if self.sweep_completed_run(to) {
self.completed_runs += 1; self.completed_runs += 1;
stats.runs_completed += 1; stats.runs_completed += 1;
@@ -455,7 +615,7 @@ impl Game for SpiderGame {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpiderGameState { pub struct SpiderGameState {
seed: u64, seed: u64,
session: Session<SpiderGame>, session: Session<Spider>,
} }
impl SpiderGameState { impl SpiderGameState {
@@ -478,7 +638,7 @@ impl SpiderGameState {
}; };
Self { Self {
seed, seed,
session: Session::new(SpiderGame::with_seed(seed, suits), config), session: Session::new(Spider::with_seed(seed, suits), config),
} }
} }
@@ -493,10 +653,18 @@ impl SpiderGameState {
} }
/// Current position (read-only). /// Current position (read-only).
pub fn game(&self) -> &SpiderGame { pub fn game(&self) -> &Spider {
self.session.state().state() self.session.state().state()
} }
/// The underlying upstream session, for solver access and replay —
/// the same escape hatch [`crate::game_state::GameState::session`]
/// provides (`session().solve()` honours the budgets configured in
/// [`SessionConfig`]).
pub const fn session(&self) -> &Session<Spider> {
&self.session
}
/// In-play score, clamped at 0 like the Klondike wrapper. /// In-play score, clamped at 0 like the Klondike wrapper.
pub fn score(&self) -> i32 { pub fn score(&self) -> i32 {
self.session self.session
@@ -563,7 +731,7 @@ impl SpiderGameState {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
impl SpiderGame { impl Spider {
/// Builds an arbitrary position for tests: per-pile /// Builds an arbitrary position for tests: per-pile
/// `(face_down, face_up)` card lists plus stock and completed-run /// `(face_down, face_up)` card lists plus stock and completed-run
/// count. No card-count invariants are enforced — stacked /// count. No card-count invariants are enforced — stacked
@@ -592,7 +760,7 @@ impl SpiderGame {
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
impl SpiderGameState { impl SpiderGameState {
/// Wraps an arbitrary position in a fresh session (empty history). /// Wraps an arbitrary position in a fresh session (empty history).
pub fn from_test_game(game: SpiderGame, suits: SpiderSuits) -> Self { pub fn from_test_game(game: Spider, suits: SpiderSuits) -> Self {
let config = SessionConfig { let config = SessionConfig {
inner: SpiderConfig { inner: SpiderConfig {
suits, suits,
@@ -614,6 +782,8 @@ impl SpiderGameState {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::RunLength::*;
use super::SpiderTableau::*;
use super::*; use super::*;
/// `Card::new` shorthand for stacked positions. /// `Card::new` shorthand for stacked positions.
@@ -621,6 +791,11 @@ mod tests {
Card::new(Deck::Deck1, suit, rank) Card::new(Deck::Deck1, suit, rank)
} }
/// `SpiderMove` shorthand for rule tests.
const fn mv(from: SpiderTableau, run: RunLength, to: SpiderTableau) -> SpiderMove {
SpiderMove { from, run, to }
}
/// K→A same-suit run, bottom (King) first — the face-up order a /// K→A same-suit run, bottom (King) first — the face-up order a
/// completed run occupies on a pile. /// completed run occupies on a pile.
fn full_run(suit: Suit) -> Vec<Card> { fn full_run(suit: Suit) -> Vec<Card> {
@@ -643,21 +818,25 @@ mod tests {
#[test] #[test]
fn opening_deal_shape_is_4x6_6x5_with_50_in_stock() { fn opening_deal_shape_is_4x6_6x5_with_50_in_stock() {
let game = SpiderGame::with_seed(42, SpiderSuits::Four); let game = Spider::with_seed(42, SpiderSuits::Four);
for index in 0..SPIDER_TABLEAUS { for (index, tableau) in SpiderTableau::ALL.into_iter().enumerate() {
let expected_down = if index < 4 { 5 } else { 4 }; let expected_down = if index < 4 { 5 } else { 4 };
assert_eq!(game.tableau_face_down(index).len(), expected_down); assert_eq!(game.tableau_face_down_cards(tableau).len(), expected_down);
assert_eq!(game.tableau_face_up(index).len(), 1, "one card face-up"); assert_eq!(
game.tableau_face_up_cards(tableau).len(),
1,
"one card face-up"
);
} }
assert_eq!(game.stock_len(), STOCK_SIZE); assert_eq!(game.stock().len(), STOCK_SIZE);
assert_eq!(game.completed_runs(), 0); assert_eq!(game.completed_runs(), 0);
} }
#[test] #[test]
fn same_seed_same_deal_different_seed_different_deal() { fn same_seed_same_deal_different_seed_different_deal() {
let a = SpiderGame::with_seed(7, SpiderSuits::Four); let a = Spider::with_seed(7, SpiderSuits::Four);
let b = SpiderGame::with_seed(7, SpiderSuits::Four); let b = Spider::with_seed(7, SpiderSuits::Four);
let c = SpiderGame::with_seed(8, SpiderSuits::Four); let c = Spider::with_seed(8, SpiderSuits::Four);
assert_eq!(a, b); assert_eq!(a, b);
assert_ne!(a, c); assert_ne!(a, c);
} }
@@ -691,15 +870,21 @@ mod tests {
let mut layout = empty_layout(); let mut layout = empty_layout();
layout[0].1 = vec![card(Suit::Hearts, Rank::Five)]; layout[0].1 = vec![card(Suit::Hearts, Rank::Five)];
layout[1].1 = vec![card(Suit::Spades, Rank::Six)]; layout[1].1 = vec![card(Suit::Spades, Rank::Six)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0); let game = Spider::from_test_layout(layout, Vec::new(), 0);
assert!(game.is_move_valid(0, 1, 1), "5♥ onto 6♠ is legal"); assert!(
assert!(!game.is_move_valid(1, 0, 1), "6♠ onto 5♥ is not"); game.is_move_valid(mv(Tableau1, Run1, Tableau2)),
"5♥ onto 6♠ is legal"
);
assert!(
!game.is_move_valid(mv(Tableau2, Run1, Tableau1)),
"6♠ onto 5♥ is not"
);
} }
#[test] #[test]
fn only_same_suit_runs_are_movable_as_a_group() { fn only_same_suit_runs_are_movable_as_a_group() {
let mut layout = empty_layout(); let mut layout = empty_layout();
// Pile 0: 7♠ 6♠ (movable run of 2). Pile 1: 7♥ 6♠ (mixed). // Pile 1: 7♠ 6♠ (movable run of 2). Pile 2: 7♥ 6♠ (mixed).
layout[0].1 = vec![ layout[0].1 = vec![
card(Suit::Spades, Rank::Seven), card(Suit::Spades, Rank::Seven),
card(Suit::Spades, Rank::Six), card(Suit::Spades, Rank::Six),
@@ -711,10 +896,19 @@ mod tests {
// Destinations: 8♣ (for the pair), 7♦ (for a lone six). // Destinations: 8♣ (for the pair), 7♦ (for a lone six).
layout[2].1 = vec![card(Suit::Clubs, Rank::Eight)]; layout[2].1 = vec![card(Suit::Clubs, Rank::Eight)];
layout[3].1 = vec![card(Suit::Diamonds, Rank::Seven)]; layout[3].1 = vec![card(Suit::Diamonds, Rank::Seven)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0); let game = Spider::from_test_layout(layout, Vec::new(), 0);
assert!(game.is_move_valid(0, 2, 2), "same-suit pair moves"); assert!(
assert!(!game.is_move_valid(1, 2, 2), "mixed-suit pair does not"); game.is_move_valid(mv(Tableau1, Run2, Tableau3)),
assert!(game.is_move_valid(1, 3, 1), "its top card alone does"); "same-suit pair moves"
);
assert!(
!game.is_move_valid(mv(Tableau2, Run2, Tableau3)),
"mixed-suit pair does not"
);
assert!(
game.is_move_valid(mv(Tableau2, Run1, Tableau4)),
"its top card alone does"
);
} }
#[test] #[test]
@@ -724,27 +918,40 @@ mod tests {
card(Suit::Spades, Rank::Nine), card(Suit::Spades, Rank::Nine),
card(Suit::Spades, Rank::Eight), card(Suit::Spades, Rank::Eight),
]; ];
// Pile 1 deliberately left empty. // Pile 2 deliberately left empty.
layout[2].1 = vec![card(Suit::Hearts, Rank::Nine)]; layout[2].1 = vec![card(Suit::Hearts, Rank::Nine)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0); let game = Spider::from_test_layout(layout, Vec::new(), 0);
assert!(game.is_move_valid(0, 1, 2), "run onto empty pile");
assert!(game.is_move_valid(2, 1, 1), "single onto empty pile");
assert!( assert!(
!game.is_move_valid(0, 2, 2), game.is_move_valid(mv(Tableau1, Run2, Tableau2)),
"run onto empty pile"
);
assert!(
game.is_move_valid(mv(Tableau3, Run1, Tableau2)),
"single onto empty pile"
);
assert!(
!game.is_move_valid(mv(Tableau1, Run2, Tableau3)),
"9♠8♠ cannot land on 9♥ (needs a 10)" "9♠8♠ cannot land on 9♥ (needs a 10)"
); );
} }
#[test] #[test]
fn invalid_moves_rejected_out_of_range_zero_count_self_move() { fn invalid_moves_rejected_self_move_and_overlong_run() {
// Out-of-range piles and zero-card runs are unrepresentable in
// `SpiderMove` — the enums only span legal values — so the only
// structurally invalid shapes left are self-moves and runs
// longer than the movable suffix.
let mut layout = empty_layout(); let mut layout = empty_layout();
layout[0].1 = vec![card(Suit::Spades, Rank::Five)]; layout[0].1 = vec![card(Suit::Spades, Rank::Five)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0); let game = Spider::from_test_layout(layout, Vec::new(), 0);
assert!(!game.is_move_valid(0, 0, 1), "self-move"); assert!(
assert!(!game.is_move_valid(0, 1, 0), "zero count"); !game.is_move_valid(mv(Tableau1, Run1, Tableau1)),
assert!(!game.is_move_valid(0, 10, 1), "destination out of range"); "self-move"
assert!(!game.is_move_valid(10, 0, 1), "source out of range"); );
assert!(!game.is_move_valid(0, 1, 2), "count exceeds run"); assert!(
!game.is_move_valid(mv(Tableau1, Run2, Tableau2)),
"count exceeds run"
);
} }
// -- dealing from stock ------------------------------------------------- // -- dealing from stock -------------------------------------------------
@@ -752,31 +959,35 @@ mod tests {
#[test] #[test]
fn deal_requires_stock_and_no_empty_pile() { fn deal_requires_stock_and_no_empty_pile() {
let stock = vec![card(Suit::Spades, Rank::Ace); 10]; let stock = vec![card(Suit::Spades, Rank::Ace); 10];
let game = SpiderGame::from_test_layout(junk_layout(), stock.clone(), 0); let game = Spider::from_test_layout(junk_layout(), stock.clone(), 0);
assert!(game.is_deal_valid()); assert!(game.is_deal_valid());
let mut with_gap = junk_layout(); let mut with_gap = junk_layout();
with_gap[3].1.clear(); with_gap[3].1.clear();
let game = SpiderGame::from_test_layout(with_gap, stock, 0); let game = Spider::from_test_layout(with_gap, stock, 0);
assert!(!game.is_deal_valid(), "empty pile blocks the deal"); assert!(!game.is_deal_valid(), "empty pile blocks the deal");
let game = SpiderGame::from_test_layout(junk_layout(), Vec::new(), 0); let game = Spider::from_test_layout(junk_layout(), Vec::new(), 0);
assert!(!game.is_deal_valid(), "empty stock blocks the deal"); assert!(!game.is_deal_valid(), "empty stock blocks the deal");
} }
#[test] #[test]
fn deal_puts_one_card_on_every_pile() { fn deal_puts_one_card_on_every_pile() {
let mut state = SpiderGameState::new_with_suits(3, SpiderSuits::Two); let mut state = SpiderGameState::new_with_suits(3, SpiderSuits::Two);
let before: Vec<usize> = (0..SPIDER_TABLEAUS) let before: Vec<usize> = SpiderTableau::ALL
.map(|i| state.game().tableau_face_up(i).len()) .into_iter()
.map(|tableau| state.game().tableau_face_up_cards(tableau).len())
.collect(); .collect();
state state
.apply_instruction(SpiderInstruction::Deal) .apply_instruction(SpiderInstruction::Deal)
.expect("deal is legal on a fresh game"); .expect("deal is legal on a fresh game");
for (index, previous) in before.iter().enumerate() { for (tableau, previous) in SpiderTableau::ALL.into_iter().zip(before) {
assert_eq!(state.game().tableau_face_up(index).len(), previous + 1); assert_eq!(
state.game().tableau_face_up_cards(tableau).len(),
previous + 1
);
} }
assert_eq!(state.game().stock_len(), STOCK_SIZE - SPIDER_TABLEAUS); assert_eq!(state.game().stock().len(), STOCK_SIZE - SPIDER_TABLEAUS);
} }
#[test] #[test]
@@ -787,7 +998,7 @@ mod tests {
.apply_instruction(SpiderInstruction::Deal) .apply_instruction(SpiderInstruction::Deal)
.expect("five deals must all be legal on untouched piles"); .expect("five deals must all be legal on untouched piles");
} }
assert_eq!(state.game().stock_len(), 0); assert_eq!(state.game().stock().len(), 0);
assert!(matches!( assert!(matches!(
state.apply_instruction(SpiderInstruction::Deal), state.apply_instruction(SpiderInstruction::Deal),
Err(MoveError::RuleViolation(_)) Err(MoveError::RuleViolation(_))
@@ -799,30 +1010,26 @@ mod tests {
#[test] #[test]
fn completing_a_run_removes_it_and_flips_the_card_beneath() { fn completing_a_run_removes_it_and_flips_the_card_beneath() {
let mut layout = empty_layout(); let mut layout = empty_layout();
// Pile 0: one face-down card under K..2 of spades; the ace // Pile 1: one face-down card under K..2 of spades; the ace
// arrives from pile 1. // arrives from pile 2.
let mut run = full_run(Suit::Spades); let mut run = full_run(Suit::Spades);
let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace)); let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
layout[0] = (vec![card(Suit::Hearts, Rank::Nine)], run); layout[0] = (vec![card(Suit::Hearts, Rank::Nine)], run);
layout[1].1 = vec![ace]; layout[1].1 = vec![ace];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0); let game = Spider::from_test_layout(layout, Vec::new(), 0);
let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One); let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);
state state
.apply_instruction(SpiderInstruction::Move { .apply_instruction(SpiderInstruction::Move(mv(Tableau2, Run1, Tableau1)))
from: 1,
to: 0,
count: 1,
})
.expect("ace onto two completes the run"); .expect("ace onto two completes the run");
assert_eq!(state.game().completed_runs(), 1); assert_eq!(state.game().completed_runs(), 1);
assert_eq!( assert_eq!(
state.game().tableau_face_up(0), state.game().tableau_face_up_cards(Tableau1),
&[card(Suit::Hearts, Rank::Nine)], &[card(Suit::Hearts, Rank::Nine)],
"run removed and the buried card flipped face-up" "run removed and the buried card flipped face-up"
); );
assert!(state.game().tableau_face_up(1).is_empty()); assert!(state.game().tableau_face_up_cards(Tableau2).is_empty());
} }
#[test] #[test]
@@ -832,16 +1039,12 @@ mod tests {
let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace)); let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
layout[0].1 = run; layout[0].1 = run;
layout[1].1 = vec![ace]; layout[1].1 = vec![ace];
let game = SpiderGame::from_test_layout(layout, Vec::new(), TOTAL_RUNS - 1); let game = Spider::from_test_layout(layout, Vec::new(), TOTAL_RUNS - 1);
let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One); let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);
assert!(!state.is_won()); assert!(!state.is_won());
state state
.apply_instruction(SpiderInstruction::Move { .apply_instruction(SpiderInstruction::Move(mv(Tableau2, Run1, Tableau1)))
from: 1,
to: 0,
count: 1,
})
.expect("winning move is legal"); .expect("winning move is legal");
assert!(state.is_won()); assert!(state.is_won());
assert!(matches!( assert!(matches!(
@@ -882,11 +1085,7 @@ mod tests {
#[test] #[test]
fn rule_violation_surfaces_move_error() { fn rule_violation_surfaces_move_error() {
let mut state = SpiderGameState::new_with_suits(9, SpiderSuits::One); let mut state = SpiderGameState::new_with_suits(9, SpiderSuits::One);
let result = state.apply_instruction(SpiderInstruction::Move { let result = state.apply_instruction(SpiderInstruction::Move(mv(Tableau1, Run1, Tableau1)));
from: 0,
to: 0,
count: 1,
});
assert!(matches!(result, Err(MoveError::RuleViolation(_)))); assert!(matches!(result, Err(MoveError::RuleViolation(_))));
} }
@@ -905,6 +1104,13 @@ mod tests {
assert!(state.game().is_instruction_valid(&config, instruction)); assert!(state.game().is_instruction_valid(&config, instruction));
} }
} }
#[test]
fn instruction_iter_walks_the_full_space_once() {
// 10 sources × 13 run lengths × 10 destinations, plus Deal.
let count = SpiderIter::new().count();
assert_eq!(count, SPIDER_TABLEAUS * RUN_LEN * SPIDER_TABLEAUS + 1);
}
} }
#[cfg(test)] #[cfg(test)]
@@ -915,11 +1121,15 @@ mod proptests {
/// Total cards across tableaus + stock + removed runs must always /// Total cards across tableaus + stock + removed runs must always
/// equal 104, and every generated instruction must validate — for /// equal 104, and every generated instruction must validate — for
/// any seed, difficulty, and random walk through legal moves. /// any seed, difficulty, and random walk through legal moves.
fn card_conservation(game: &SpiderGame) -> usize { fn card_conservation(game: &Spider) -> usize {
let on_piles: usize = (0..SPIDER_TABLEAUS) let on_piles: usize = SpiderTableau::ALL
.map(|i| game.tableau_face_up(i).len() + game.tableau_face_down(i).len()) .into_iter()
.map(|tableau| {
game.tableau_face_up_cards(tableau).len()
+ game.tableau_face_down_cards(tableau).len()
})
.sum(); .sum();
on_piles + game.stock_len() + usize::from(game.completed_runs()) * RUN_LEN on_piles + game.stock().len() + usize::from(game.completed_runs()) * RUN_LEN
} }
proptest! { proptest! {
+5
View File
@@ -243,6 +243,11 @@ pub struct ToggleSettingsRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)] #[derive(Message, Debug, Clone, Copy, Default)]
pub struct ToggleLeaderboardRequestEvent; pub struct ToggleLeaderboardRequestEvent;
/// Request to toggle the Home mode launcher. Fired by the HUD
/// Menu-popover "Home" row alongside the existing `M` accelerator.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ToggleHomeRequestEvent;
/// Fired by `SyncPlugin` after a pull task resolves and the merged result has /// Fired by `SyncPlugin` after a pull task resolves and the merged result has
/// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus /// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus
/// any `ConflictReport`s the merge produced. `Err(String)` carries a /// any `ConflictReport`s the merge produced. `Err(String)` carries a
+36 -2
View File
@@ -86,13 +86,18 @@ fn toggle_help_screen(
} }
/// Click handler for the modal's "Done" button. F1 toggles the overlay /// Click handler for the modal's "Done" button. F1 toggles the overlay
/// the same way; this just exposes the close action to mouse / touch. /// the same way; Esc closes too, so dismissal matches every other
/// modal (Phase C dismissal audit). Nothing ever stacks above Help,
/// so Esc needs no topmost gate.
fn handle_help_close_button( fn handle_help_close_button(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
close_buttons: Query<&Interaction, (With<HelpCloseButton>, Changed<Interaction>)>, close_buttons: Query<&Interaction, (With<HelpCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<HelpScreen>>, screens: Query<Entity, With<HelpScreen>>,
) { ) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) { let clicked = close_buttons.iter().any(|i| *i == Interaction::Pressed);
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
if !clicked && !esc {
return; return;
} }
for entity in &screens { for entity in &screens {
@@ -583,4 +588,33 @@ mod tests {
0 0
); );
} }
/// Esc must dismiss the Help modal like Done and F1 do (Phase C
/// dismissal audit).
#[test]
fn escape_closes_help_screen() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::F1);
app.update();
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(KeyCode::F1);
input.clear();
input.press(KeyCode::Escape);
}
app.update();
app.update();
assert_eq!(
app.world_mut()
.query::<&HelpScreen>()
.iter(app.world())
.count(),
0,
"Esc must close the Help modal"
);
}
} }
+44 -2
View File
@@ -24,7 +24,8 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{ use crate::events::{
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent, InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent, StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleProfileRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleHomeRequestEvent,
ToggleProfileRequestEvent,
}; };
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
@@ -264,6 +265,7 @@ impl Plugin for HomePlugin {
.add_message::<StartPlayBySeedRequestEvent>() .add_message::<StartPlayBySeedRequestEvent>()
.add_message::<StartDifficultyRequestEvent>() .add_message::<StartDifficultyRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<ToggleHomeRequestEvent>()
.add_message::<ToggleProfileRequestEvent>() .add_message::<ToggleProfileRequestEvent>()
.add_message::<SettingsChangedEvent>() .add_message::<SettingsChangedEvent>()
// Defensively register MouseWheel so `scroll_home_panel` // Defensively register MouseWheel so `scroll_home_panel`
@@ -371,6 +373,7 @@ fn spawn_home_on_launch(
fn toggle_home_screen( fn toggle_home_screen(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleHomeRequestEvent>,
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
stats: Option<Res<StatsResource>>, stats: Option<Res<StatsResource>>,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
@@ -380,7 +383,8 @@ fn toggle_home_screen(
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>, other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
diff_expanded: Res<DifficultyExpanded>, diff_expanded: Res<DifficultyExpanded>,
) { ) {
if !keys.just_pressed(KeyCode::KeyM) { let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyM) && !button_clicked {
return; return;
} }
if let Ok(entity) = screens.single() { if let Ok(entity) = screens.single() {
@@ -1621,6 +1625,44 @@ mod tests {
); );
} }
/// The HUD Menu popover's "Home" row fires
/// `ToggleHomeRequestEvent`; it must open Home exactly like the
/// `M` accelerator (Phase C: the popover's Play section replaces
/// the old Modes row).
#[test]
fn toggle_home_event_opens_home_screen() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<Messages<ToggleHomeRequestEvent>>()
.write(ToggleHomeRequestEvent);
app.update();
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
1,
"ToggleHomeRequestEvent must open the Home modal"
);
// A second request toggles it closed, matching the M key.
app.world_mut()
.resource_mut::<Messages<ToggleHomeRequestEvent>>()
.write(ToggleHomeRequestEvent);
app.update();
app.update();
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"second ToggleHomeRequestEvent must close the Home modal"
);
}
#[test] #[test]
fn pressing_m_twice_closes_home_screen() { fn pressing_m_twice_closes_home_screen() {
let mut app = headless_app(); let mut app = headless_app();
+64 -33
View File
@@ -311,19 +311,30 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font
..default() ..default()
}; };
// Each row carries a tooltip alongside its label so hover reveals // One popover row: destination, label, hover tooltip.
// a one-line description of what each overlay shows — mirroring type MenuRow = (MenuOption, &'static str, &'static str);
// the tooltips on the action-bar buttons that opened this popover. // Destinations grouped into labelled sections (Phase C of the menu
let rows: [(MenuOption, &'static str, &'static str); 7] = [ // redesign): Play · You · Community · System. Each row carries a
// tooltip alongside its label so hover reveals a one-line
// description of what each overlay shows — mirroring the tooltips
// on the action-bar buttons that opened this popover. Mode
// selection lives on Home now, so there is no Modes row.
let sections: [(&'static str, &'static [MenuRow]); 4] = [
( (
MenuOption::Help, "Play",
"Help", &[(
"Show controls, rules, and keyboard shortcuts.", MenuOption::Home,
"Home",
"Pick a mode, continue, or start a new game.",
)],
), ),
( (
MenuOption::Modes, "You",
"Game Modes", &[
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", (
MenuOption::Profile,
"Profile",
"Your level, XP progress, and sync status.",
), ),
( (
MenuOption::Stats, MenuOption::Stats,
@@ -335,20 +346,30 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font
"Achievements", "Achievements",
"Browse unlocked achievements and the rewards still ahead.", "Browse unlocked achievements and the rewards still ahead.",
), ),
( ],
MenuOption::Profile,
"Profile",
"Your level, XP progress, and sync status.",
), ),
(
"Community",
&[(
MenuOption::Leaderboard,
"Leaderboard",
"Top players from your sync server. Opt in from Profile.",
)],
),
(
"System",
&[
( (
MenuOption::Settings, MenuOption::Settings,
"Settings", "Settings",
"Audio, animations, theme, draw mode, and sync.", "Audio, animations, theme, draw mode, and sync.",
), ),
( (
MenuOption::Leaderboard, MenuOption::Help,
"Leaderboard", "Help",
"Top players from your sync server. Opt in from Profile.", "Show controls, rules, and keyboard shortcuts.",
),
],
), ),
]; ];
@@ -373,7 +394,27 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font
ZIndex(Z_HUD_POPOVER), ZIndex(Z_HUD_POPOVER),
)) ))
.with_children(|panel| { .with_children(|panel| {
for (option, label, tooltip) in rows { let section_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
for (section, rows) in sections {
// Non-interactive section header — a quiet divider
// inside the existing panel, not a new widget.
panel
.spawn(Node {
padding: UiRect::axes(VAL_SPACE_3, Val::Px(2.0)),
..default()
})
.with_children(|b| {
b.spawn((
Text::new(section),
section_font.clone(),
TextColor(TEXT_SECONDARY),
));
});
for &(option, label, tooltip) in rows {
panel panel
.spawn(( .spawn((
option, option,
@@ -395,6 +436,7 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY))); b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
}); });
} }
}
}); });
// Transparent fullscreen backdrop behind the popover — tapping anywhere // Transparent fullscreen backdrop behind the popover — tapping anywhere
@@ -422,31 +464,28 @@ pub(super) fn handle_menu_option_click(
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>, interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
popovers: Query<Entity, With<MenuPopover>>, popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>, backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
mut home: MessageWriter<ToggleHomeRequestEvent>,
mut stats: MessageWriter<ToggleStatsRequestEvent>, mut stats: MessageWriter<ToggleStatsRequestEvent>,
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>, mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
mut profile: MessageWriter<ToggleProfileRequestEvent>, mut profile: MessageWriter<ToggleProfileRequestEvent>,
mut settings: MessageWriter<ToggleSettingsRequestEvent>, mut settings: MessageWriter<ToggleSettingsRequestEvent>,
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>, mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
mut help: MessageWriter<HelpRequestEvent>, mut help: MessageWriter<HelpRequestEvent>,
progress: Option<Res<ProgressResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands, mut commands: Commands,
) { ) {
let mut clicked_any = false; let mut clicked_any = false;
let mut open_modes = false;
for (interaction, option) in &interaction_query { for (interaction, option) in &interaction_query {
if *interaction != Interaction::Pressed { if *interaction != Interaction::Pressed {
continue; continue;
} }
clicked_any = true; clicked_any = true;
match option { match option {
MenuOption::Home => {
home.write(ToggleHomeRequestEvent);
}
MenuOption::Help => { MenuOption::Help => {
help.write(HelpRequestEvent); help.write(HelpRequestEvent);
} }
MenuOption::Modes => {
open_modes = true;
}
MenuOption::Stats => { MenuOption::Stats => {
stats.write(ToggleStatsRequestEvent); stats.write(ToggleStatsRequestEvent);
} }
@@ -470,14 +509,6 @@ pub(super) fn handle_menu_option_click(
commands.entity(e).despawn(); commands.entity(e).despawn();
} }
} }
if open_modes {
spawn_modes_popover(
&mut commands,
progress.as_deref(),
daily.as_deref(),
font_res.as_deref(),
);
}
} }
/// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back /// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back
+14 -10
View File
@@ -30,9 +30,9 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{ use crate::events::{
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent, HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent,
ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
UndoRequestEvent, WinStreakMilestoneEvent, ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
}; };
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::game_plugin::{GameMutation, NewGameRequestWriters}; use crate::game_plugin::{GameMutation, NewGameRequestWriters};
@@ -386,8 +386,9 @@ pub enum ModeOption {
} }
/// Marker on the "Menu" action button. Click toggles the [`MenuPopover`] /// Marker on the "Menu" action button. Click toggles the [`MenuPopover`]
/// which exposes the Stats / Achievements / Profile / Settings / /// which exposes the Home / Profile / Stats / Achievements /
/// Leaderboard overlays without needing the S/A/P/O/L hotkeys. /// Leaderboard / Settings / Help overlays without needing the
/// M/P/S/A/L/O/F1 hotkeys.
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct MenuButton; pub struct MenuButton;
@@ -413,16 +414,18 @@ struct MenuPopoverBackdrop;
struct ModesPopoverBackdrop; struct ModesPopoverBackdrop;
/// One row inside the [`MenuPopover`]. The variant selects which /// One row inside the [`MenuPopover`]. The variant selects which
/// `Toggle*RequestEvent` the click handler fires. /// `Toggle*RequestEvent` the click handler fires. Rows render grouped
/// under section headers (Play · You · Community · System); mode
/// selection lives on Home, so there is no Modes row here.
#[derive(Component, Debug, Clone, Copy)] #[derive(Component, Debug, Clone, Copy)]
pub enum MenuOption { pub enum MenuOption {
Help, Home,
Modes, Profile,
Stats, Stats,
Achievements, Achievements,
Profile,
Settings,
Leaderboard, Leaderboard,
Settings,
Help,
} }
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens. /// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
@@ -459,6 +462,7 @@ impl Plugin for HudPlugin {
.add_message::<StartTimeAttackRequestEvent>() .add_message::<StartTimeAttackRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>() .add_message::<StartDailyChallengeRequestEvent>()
.add_message::<ToggleStatsRequestEvent>() .add_message::<ToggleStatsRequestEvent>()
.add_message::<ToggleHomeRequestEvent>()
.add_message::<ToggleAchievementsRequestEvent>() .add_message::<ToggleAchievementsRequestEvent>()
.add_message::<ToggleProfileRequestEvent>() .add_message::<ToggleProfileRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>() .add_message::<ToggleSettingsRequestEvent>()
+1 -1
View File
@@ -786,8 +786,8 @@ fn popover_rows_carry_tooltip_strings() {
menu_tooltips.len() menu_tooltips.len()
); );
for expected in [ for expected in [
"Pick a mode, continue, or start a new game.",
"Show controls, rules, and keyboard shortcuts.", "Show controls, rules, and keyboard shortcuts.",
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
"Lifetime totals: wins, streaks, fastest time, best score.", "Lifetime totals: wins, streaks, fastest time, best score.",
"Browse unlocked achievements and the rewards still ahead.", "Browse unlocked achievements and the rewards still ahead.",
"Your level, XP progress, and sync status.", "Your level, XP progress, and sync status.",
+16 -2
View File
@@ -343,13 +343,21 @@ fn scroll_leaderboard_panel(
} }
} }
/// Done click or Esc dismisses the leaderboard (Phase C dismissal
/// audit). Esc only fires when the leaderboard is the topmost modal —
/// with the display-name dialog stacked on top, that dialog owns Esc.
fn handle_leaderboard_close_button( fn handle_leaderboard_close_button(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>, close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<LeaderboardScreen>>, screens: Query<Entity, With<LeaderboardScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>,
mut closed_flag: ResMut<ClosedThisFrame>, mut closed_flag: ResMut<ClosedThisFrame>,
) { ) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) { let clicked = close_buttons.iter().any(|i| *i == Interaction::Pressed);
let esc =
keys.just_pressed(KeyCode::Escape) && !screens.is_empty() && other_modal_scrims.is_empty();
if !clicked && !esc {
return; return;
} }
for entity in &screens { for entity in &screens {
@@ -888,12 +896,18 @@ fn handle_display_name_confirm(
} }
/// Discards any typed text and closes the display-name editor modal. /// Discards any typed text and closes the display-name editor modal.
/// Cancel click or Esc dismisses the display-name dialog without
/// saving (Phase C dismissal audit — same contract as the sync-setup
/// dialog's Cancel/Esc pair).
fn handle_display_name_cancel( fn handle_display_name_cancel(
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameCancelButton>)>, button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameCancelButton>)>,
keys: Res<ButtonInput<KeyCode>>,
screens: Query<Entity, With<DisplayNameModal>>, screens: Query<Entity, With<DisplayNameModal>>,
mut commands: Commands, mut commands: Commands,
) { ) {
if !button_q.iter().any(|i| *i == Interaction::Pressed) { let clicked = button_q.iter().any(|i| *i == Interaction::Pressed);
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
if !clicked && !esc {
return; return;
} }
for entity in &screens { for entity in &screens {
@@ -50,15 +50,21 @@ pub(super) fn handle_volume_keys(
} }
/// Opens or closes the Settings panel — `O` keyboard accelerator or /// Opens or closes the Settings panel — `O` keyboard accelerator or
/// `ToggleSettingsRequestEvent` from the HUD Menu popover. /// `ToggleSettingsRequestEvent` from the HUD Menu popover. Esc closes
/// too (Phase C dismissal audit), but only when Settings is the
/// topmost modal — with sync-setup or the theme store stacked on top,
/// the stacked dialog owns Esc.
pub(super) fn toggle_settings_screen( pub(super) fn toggle_settings_screen(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleSettingsRequestEvent>, mut requests: MessageReader<ToggleSettingsRequestEvent>,
mut screen: ResMut<SettingsScreen>, mut screen: ResMut<SettingsScreen>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
) { ) {
let button_clicked = requests.read().count() > 0; let button_clicked = requests.read().count() > 0;
if keys.just_pressed(KeyCode::KeyO) || button_clicked { if keys.just_pressed(KeyCode::KeyO) || button_clicked {
screen.0 = !screen.0; screen.0 = !screen.0;
} else if keys.just_pressed(KeyCode::Escape) && screen.0 && other_modal_scrims.is_empty() {
screen.0 = false;
} }
} }
@@ -140,6 +140,31 @@ fn pressing_o_toggles_settings_screen_flag() {
); );
} }
/// Esc closes the Settings panel like O / Done do (Phase C dismissal
/// audit). Esc while the panel is closed must NOT open it.
#[test]
fn escape_closes_settings_screen_flag() {
let mut app = headless_app();
press(&mut app, KeyCode::Escape);
app.update();
assert!(
!app.world().resource::<SettingsScreen>().0,
"Esc on a closed panel stays closed"
);
press(&mut app, KeyCode::KeyO);
app.update();
assert!(app.world().resource::<SettingsScreen>().0, "O opens");
press(&mut app, KeyCode::Escape);
app.update();
assert!(
!app.world().resource::<SettingsScreen>().0,
"Esc closes settings"
);
}
// cycle_unlocked pure-function tests // cycle_unlocked pure-function tests
#[test] #[test]
fn cycle_unlocked_wraps_at_end() { fn cycle_unlocked_wraps_at_end() {
+12 -5
View File
@@ -118,6 +118,9 @@ impl Plugin for ThemeStorePlugin {
.init_resource::<CatalogTask>() .init_resource::<CatalogTask>()
.init_resource::<InstallTask>() .init_resource::<InstallTask>()
.init_resource::<StoreBaseUrl>() .init_resource::<StoreBaseUrl>()
// Esc-close reads keyboard input; register defensively so
// the plugin works under MinimalPlugins in tests.
.init_resource::<ButtonInput<KeyCode>>()
.add_message::<ThemeStoreOpenRequestEvent>() .add_message::<ThemeStoreOpenRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<WarningToastEvent>() .add_message::<WarningToastEvent>()
@@ -344,20 +347,24 @@ fn poll_install_task(
); );
} }
/// Despawns the store modal when Close is pressed. /// Despawns the store modal when Close is pressed or on Esc (Phase C
/// dismissal audit). The store only ever stacks over Settings and
/// nothing stacks over the store, so it owns Esc whenever it is open
/// (Settings' own Esc handler is gated on being topmost).
fn handle_close_button( fn handle_close_button(
interactions: Query<&Interaction, (Changed<Interaction>, With<ThemeStoreCloseButton>)>, interactions: Query<&Interaction, (Changed<Interaction>, With<ThemeStoreCloseButton>)>,
keys: Res<ButtonInput<KeyCode>>,
screens: Query<Entity, With<ThemeStoreScreen>>, screens: Query<Entity, With<ThemeStoreScreen>>,
mut commands: Commands, mut commands: Commands,
) { ) {
for interaction in &interactions { let clicked = interactions.iter().any(|i| *i == Interaction::Pressed);
if *interaction != Interaction::Pressed { let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
continue; if !clicked && !esc {
return;
} }
for entity in &screens { for entity in &screens {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------