Compare commits

...

11 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 5b5d587818 Merge pull request 'feat(engine): win-summary action hierarchy — Play Again, Watch, Share' (#164) from feat/win-flow into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m22s
Test / test (push) Successful in 36m55s
2026-07-09 05:09:35 +00:00
Gitea CI 2b2e7a7f2c chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 5m57s
Web E2E / web-e2e (push) Successful in 9m40s
2026-07-09 05:09:10 +00:00
funman300 5c4d440b31 Merge pull request 'feat(engine): You hub — Profile/Stats/Achievements/Replays in one tabbed modal (Phase E)' (#161) from feat/you-hub into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m18s
Test / test (push) Successful in 38m25s
2026-07-09 05:02:07 +00:00
funman300 38b82d4858 feat(engine): win-summary action hierarchy — Play Again, Watch, Share
Test / test (pull_request) Successful in 36m24s
Phase G of docs/ui-redesign-2026-07.md. The win modal now leads with
actions and reads the stats recap quietly below them:

- Play Again: primary, full-row, Enter accelerator unchanged; still
  fires NewGameRequestEvent::default() (same mode; deal options from
  Settings) so the rematch is one tap.
- Watch Replay / Share Replay: secondary pair reusing the global
  stats_plugin markers (WatchReplayButton / CopyShareLinkButton), so
  both act on the just-won replay — SelectedReplayIndex snaps to 0 on
  every win. This also fixes the old win-modal handler picking
  replays.last(), which after the newest-first history refactor was
  the OLDEST replay, not the newest.
- Watch closes the celebration overlay (new close_overlay_on_watch_-
  replay system); Share keeps it open and relies on the existing
  copy-feedback toasts.
- Stats recap (score breakdown reveal, time, XP, achievements) moves
  below the actions; the Time line drops from headline/primary to
  body/secondary styling.

Tests: 4 new (action presence, Play Again close+request, Watch closes,
Share keeps open) on a manual-clock fixture that steps the 0.5 s
celebration delay deterministically. Workspace suite green, workspace
clippy -D warnings clean, fmt applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 20:37:32 -07: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
funman300 9f038250d9 feat(engine): You hub — Profile/Stats/Achievements/Replays in one tabbed modal
Test / test (pull_request) Failing after 8m41s
Phase E of docs/ui-redesign-2026-07.md. New you_hub_plugin owns the
modal shell (header, shared tab chips, single Done); each tab's content
is a body builder extracted from its original plugin with every marker
component unchanged, so per-row update/scroll/selector systems keep
working. The replay selector gets its own Replays tab (Watch/Copy
buttons move into the tab body).

- Toggle*RequestEvents + P/S/A accelerators open the hub on the right
  tab, switch tabs in place, or toggle closed on a same-tab request;
  Esc/Done/scrim-click close
- Legacy ProfileScreen/StatsScreen/AchievementsScreen markers ride the
  hub scrim for the active tab — external queries and tests keep their
  meaning
- Standalone toggle/close systems and per-screen Done buttons removed
  (ProfileCloseButton, StatsCloseButton, AchievementsCloseButton)
- Tests: 2 new hub lifecycle tests; profile/stats/achievements modal
  tests adapted (fixtures add YouHubPlugin; selector tests target the
  Replays tab). Engine suite 916 green, clippy -D warnings, fmt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:10:56 -07:00
funman300 0c69d6859d refactor(engine): extract shared spawn_tab_chip widget into ui_modal
Settings' tab_chip becomes a thin wrapper; the You hub (Phase E) will
reuse the same widget so tabbed modals stay visually identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:52:31 -07:00
25 changed files with 1615 additions and 707 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.
+397 -187
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! {
+20 -69
View File
@@ -30,13 +30,9 @@ use crate::replay_playback::ReplayPlaybackState;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::stats_plugin::{StatsResource, StatsUpdate}; use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, Z_MODAL_PANEL, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1,
}; };
use crate::ui_tooltip::Tooltip; use crate::ui_tooltip::Tooltip;
@@ -137,8 +133,8 @@ impl Plugin for AchievementPlugin {
.after(GameMutation) .after(GameMutation)
.after(StatsUpdate), .after(StatsUpdate),
) )
.add_systems(Update, toggle_achievements_screen) // Open/close/tab handling moved to `you_hub_plugin`
.add_systems(Update, handle_achievements_close_button) // (Phase E) — this plugin now owns body content + scroll.
.add_systems(Update, scroll_achievements_panel) .add_systems(Update, scroll_achievements_panel)
.add_systems( .add_systems(
Update, Update,
@@ -385,47 +381,6 @@ pub fn display_name_for(id: &str) -> String {
achievement_by_id(id).map_or_else(|| id.to_string(), |d| d.name.to_string()) achievement_by_id(id).map_or_else(|| id.to_string(), |d| d.name.to_string())
} }
/// Marker on the "Done" button inside the Achievements modal.
#[derive(Component, Debug)]
pub struct AchievementsCloseButton;
/// Toggle the achievements overlay — `A` keyboard accelerator or
/// `ToggleAchievementsRequestEvent` from the HUD Menu popover.
fn toggle_achievements_screen(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleAchievementsRequestEvent>,
achievements: Res<AchievementsResource>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<AchievementsScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<AchievementsScreen>)>,
) {
let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyA) && !button_clicked {
return;
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else if other_modal_scrims.is_empty() {
spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref());
}
}
/// Click handler for the modal's "Done" button — despawns the overlay
/// the same way the `A` accelerator does.
fn handle_achievements_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<AchievementsCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<AchievementsScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
/// Routes mouse-wheel events into the Achievements modal's scrollable body /// Routes mouse-wheel events into the Achievements modal's scrollable body
/// while the panel is open. /// while the panel is open.
/// ///
@@ -458,14 +413,18 @@ fn scroll_achievements_panel(
} }
} }
fn spawn_achievements_screen( /// Builds the Achievements tab body inside the You hub's card. The
commands: &mut Commands, /// unlock-count line that used to live in the standalone modal's
/// header renders as the first body line instead (the hub owns the
/// header). All markers (`AchievementRow`, `AchievementsScrollable`)
/// are unchanged.
pub(crate) fn spawn_achievements_body(
card: &mut ChildSpawnerCommands,
records: &[AchievementRecord], records: &[AchievementRecord],
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
) { ) {
let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect(); let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect();
let total = ALL_ACHIEVEMENTS.len(); let total = ALL_ACHIEVEMENTS.len();
let header = format!("Achievements ({}/{})", unlocked.len(), total);
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_name = TextFont { let font_name = TextFont {
@@ -486,8 +445,13 @@ fn spawn_achievements_screen(
let any_unlocked = records.iter().any(|r| r.unlocked); let any_unlocked = records.iter().any(|r| r.unlocked);
let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| { {
spawn_modal_header(card, header, font_res); // Unlock progress — formerly the standalone modal's header.
card.spawn((
Text::new(format!("Unlocked {} / {}", unlocked.len(), total)),
font_name.clone(),
TextColor(TEXT_SECONDARY),
));
// First-time hint — shown until the player has unlocked anything. // First-time hint — shown until the player has unlocked anything.
// The list itself describes individual rewards, but a top-level // The list itself describes individual rewards, but a top-level
@@ -594,21 +558,7 @@ fn spawn_achievements_screen(
)); ));
} }
}); });
}
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
AchievementsCloseButton,
"Done",
Some("A"),
ButtonVariant::Primary,
font_res,
);
});
});
// Achievements is a read-only list — clicking the scrim outside
// the card dismisses alongside the existing A / Done paths.
commands.entity(scrim).insert(ScrimDismissible);
} }
fn format_reward(reward: Reward) -> String { fn format_reward(reward: Reward) -> String {
@@ -668,7 +618,8 @@ mod tests {
.add_plugins(TablePlugin) .add_plugins(TablePlugin)
.add_plugins(StatsPlugin::headless()) .add_plugins(StatsPlugin::headless())
.add_plugins(crate::progress_plugin::ProgressPlugin::headless()) .add_plugins(crate::progress_plugin::ProgressPlugin::headless())
.add_plugins(AchievementPlugin::headless()); .add_plugins(AchievementPlugin::headless())
.add_plugins(crate::you_hub_plugin::YouHubPlugin);
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under // StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
// MinimalPlugins it isn't auto-registered. // MinimalPlugins it isn't auto-registered.
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
+1
View File
@@ -111,6 +111,7 @@ impl Plugin for CoreGamePlugin {
.add_plugins(HelpPlugin) .add_plugins(HelpPlugin)
.add_plugins(HomePlugin::default()) .add_plugins(HomePlugin::default())
.add_plugins(ProfilePlugin) .add_plugins(ProfilePlugin)
.add_plugins(crate::you_hub_plugin::YouHubPlugin)
.add_plugins(PausePlugin) .add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default()) .add_plugins(SettingsPlugin::default())
.add_plugins(OnboardingPlugin) .add_plugins(OnboardingPlugin)
+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();
+95 -64
View File
@@ -311,44 +311,65 @@ 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,
"Stats",
"Lifetime totals: wins, streaks, fastest time, best score.",
),
(
MenuOption::Achievements,
"Achievements",
"Browse unlocked achievements and the rewards still ahead.",
),
],
), ),
( (
MenuOption::Stats, "Community",
"Stats", &[(
"Lifetime totals: wins, streaks, fastest time, best score.", MenuOption::Leaderboard,
"Leaderboard",
"Top players from your sync server. Opt in from Profile.",
)],
), ),
( (
MenuOption::Achievements, "System",
"Achievements", &[
"Browse unlocked achievements and the rewards still ahead.", (
), MenuOption::Settings,
( "Settings",
MenuOption::Profile, "Audio, animations, theme, draw mode, and sync.",
"Profile", ),
"Your level, XP progress, and sync status.", (
), MenuOption::Help,
( "Help",
MenuOption::Settings, "Show controls, rules, and keyboard shortcuts.",
"Settings", ),
"Audio, animations, theme, draw mode, and sync.", ],
),
(
MenuOption::Leaderboard,
"Leaderboard",
"Top players from your sync server. Opt in from Profile.",
), ),
]; ];
@@ -373,27 +394,48 @@ 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 panel
.spawn(( .spawn(Node {
option, padding: UiRect::axes(VAL_SPACE_3, Val::Px(2.0)),
ActionButton, ..default()
PopoverRow, })
Button,
Tooltip::new(tooltip),
Node {
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
min_width: Val::Px(150.0),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(ACTION_BTN_IDLE),
))
.with_children(|b| { .with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY))); b.spawn((
Text::new(section),
section_font.clone(),
TextColor(TEXT_SECONDARY),
));
}); });
for &(option, label, tooltip) in rows {
panel
.spawn((
option,
ActionButton,
PopoverRow,
Button,
Tooltip::new(tooltip),
Node {
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
min_width: Val::Px(150.0),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(ACTION_BTN_IDLE),
))
.with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
});
}
} }
}); });
@@ -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 {
+2
View File
@@ -65,6 +65,7 @@ pub mod ui_theme;
pub mod ui_tooltip; pub mod ui_tooltip;
pub mod weekly_goals_plugin; pub mod weekly_goals_plugin;
pub mod win_summary_plugin; pub mod win_summary_plugin;
pub mod you_hub_plugin;
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen}; pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@@ -197,3 +198,4 @@ pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
pub use win_summary_plugin::{ pub use win_summary_plugin::{
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time, ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
}; };
pub use you_hub_plugin::{ActiveYouTab, YouHubPlugin, YouHubScreen, YouTab};
+19 -102
View File
@@ -4,7 +4,6 @@
//! summary in a single scrollable panel. Spawned on the first `P` keypress and //! summary in a single scrollable panel. Spawned on the first `P` keypress and
//! despawned on the second. //! despawned on the second.
use bevy::input::ButtonInput;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*; use bevy::prelude::*;
use chrono::{Duration, Local, NaiveDate}; use chrono::{Duration, Local, NaiveDate};
@@ -13,23 +12,19 @@ use solitaire_data::SyncBackend;
use crate::achievement_plugin::AchievementsResource; use crate::achievement_plugin::AchievementsResource;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use crate::avatar_plugin::AvatarResource; pub(crate) use crate::avatar_plugin::AvatarResource;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
#[derive(bevy::prelude::Resource)] #[derive(bevy::prelude::Resource)]
struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>); pub(crate) struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
use crate::events::ToggleProfileRequestEvent; use crate::events::ToggleProfileRequestEvent;
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::{SyncStatus, SyncStatusResource}; use crate::resources::{SyncStatus, SyncStatusResource};
use crate::settings_plugin::SettingsResource; use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{StatsResource, format_fastest_win, format_win_rate}; use crate::stats_plugin::{StatsResource, format_fastest_win, format_win_rate};
use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY, ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2,
}; };
/// Number of days surfaced in the daily-challenge calendar row. /// Number of days surfaced in the daily-challenge calendar row.
@@ -60,13 +55,11 @@ pub struct DailyCalendarDot {
pub is_today: bool, pub is_today: bool,
} }
/// Registers the `P` key toggle for the profile overlay. /// Registers the Profile body's scroll handling. Opening/closing moved
/// to the You hub (`you_hub_plugin`), which calls
/// [`spawn_profile_body`] for this tab's content.
pub struct ProfilePlugin; pub struct ProfilePlugin;
/// Marker on the "Done" button inside the Profile modal.
#[derive(Component, Debug)]
pub struct ProfileCloseButton;
/// Marker on the scrollable body Node inside the Profile modal. /// Marker on the scrollable body Node inside the Profile modal.
/// ///
/// The Profile panel renders sync info, progression (incl. 14-day /// The Profile panel renders sync info, progression (incl. 14-day
@@ -87,14 +80,9 @@ impl Plugin for ProfilePlugin {
// profile-scroll system also runs cleanly under // profile-scroll system also runs cleanly under
// `MinimalPlugins` in tests. // `MinimalPlugins` in tests.
.add_message::<MouseWheel>() .add_message::<MouseWheel>()
.add_systems( // Open/close/tab handling moved to `you_hub_plugin`
Update, // (Phase E) — this plugin now owns body content + scroll.
( .add_systems(Update, scroll_profile_panel);
toggle_profile_screen,
handle_profile_close_button,
scroll_profile_panel,
),
);
} }
} }
@@ -124,70 +112,12 @@ fn scroll_profile_panel(
} }
} }
fn handle_profile_close_button( /// Builds the Profile tab body inside the You hub's card. All markers
mut commands: Commands, /// (`ProfileScrollable`, `DailyCalendarDot`, …) are unchanged, so the
close_buttons: Query<&Interaction, (With<ProfileCloseButton>, Changed<Interaction>)>, /// scroll and test queries keep working.
screens: Query<Entity, With<ProfileScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn toggle_profile_screen( pub(crate) fn spawn_profile_body(
mut commands: Commands, card: &mut ChildSpawnerCommands,
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleProfileRequestEvent>,
settings: Option<Res<SettingsResource>>,
sync_status: Option<Res<SyncStatusResource>>,
progress: Option<Res<ProgressResource>>,
achievements: Option<Res<AchievementsResource>>,
stats: Option<Res<StatsResource>>,
font_res: Option<Res<FontResource>>,
avatar: Option<Res<AvatarResource>>,
screens: Query<Entity, With<ProfileScreen>>,
scrims: Query<(), With<ModalScrim>>,
) {
let button_clicked = requests.read().count() > 0;
let p_pressed = keys.just_pressed(KeyCode::KeyP);
let esc_pressed = keys.just_pressed(KeyCode::Escape);
let already_open = !screens.is_empty();
// P / button toggles open-or-close. Esc only ever closes — when
// Profile is layered over Home (clicking the new Home stats chip
// opens this on top), Esc must dismiss the *topmost* modal.
// Without this branch, Esc fell through to Home's cancel handler
// and closed the wrong modal.
let want_open = !already_open && (p_pressed || button_clicked);
let want_close = already_open && (p_pressed || button_clicked || esc_pressed);
if !want_open && !want_close {
return;
}
if want_open && !scrims.is_empty() {
return;
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
spawn_profile_screen(
&mut commands,
settings.as_deref(),
sync_status.as_deref(),
progress.as_deref(),
achievements.as_deref(),
stats.as_deref(),
font_res.as_deref(),
avatar.as_deref(),
);
}
}
#[allow(clippy::too_many_arguments)]
fn spawn_profile_screen(
commands: &mut Commands,
settings: Option<&SettingsResource>, settings: Option<&SettingsResource>,
sync_status: Option<&SyncStatusResource>, sync_status: Option<&SyncStatusResource>,
progress: Option<&ProgressResource>, progress: Option<&ProgressResource>,
@@ -208,9 +138,7 @@ fn spawn_profile_screen(
..default() ..default()
}; };
let scrim = spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| { {
spawn_modal_header(card, "Profile", font_res);
// Scrollable body — the Profile panel renders sync info, // Scrollable body — the Profile panel renders sync info,
// progression (incl. a 14-day calendar), every unlocked // progression (incl. a 14-day calendar), every unlocked
// achievement (up to ~18), and a stats summary, which can // achievement (up to ~18), and a stats summary, which can
@@ -472,20 +400,7 @@ fn spawn_profile_screen(
)); ));
} }
}); });
}
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
ProfileCloseButton,
"Done",
Some("P"),
ButtonVariant::Primary,
font_res,
);
});
});
// Profile is read-only — opt into click-outside-to-dismiss.
commands.entity(scrim).insert(ScrimDismissible);
} }
/// Spawn a fixed-height vertical spacer node. /// Spawn a fixed-height vertical spacer node.
@@ -627,6 +542,7 @@ mod tests {
use crate::settings_plugin::SettingsPlugin; use crate::settings_plugin::SettingsPlugin;
use crate::stats_plugin::StatsPlugin; use crate::stats_plugin::StatsPlugin;
use crate::table_plugin::TablePlugin; use crate::table_plugin::TablePlugin;
use crate::ui_modal::ModalScrim;
fn headless_app() -> App { fn headless_app() -> App {
let mut app = App::new(); let mut app = App::new();
@@ -637,7 +553,8 @@ mod tests {
.add_plugins(ProgressPlugin::headless()) .add_plugins(ProgressPlugin::headless())
.add_plugins(AchievementPlugin::headless()) .add_plugins(AchievementPlugin::headless())
.add_plugins(SettingsPlugin::headless()) .add_plugins(SettingsPlugin::headless())
.add_plugins(ProfilePlugin); .add_plugins(ProfilePlugin)
.add_plugins(crate::you_hub_plugin::YouHubPlugin);
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.update(); app.update();
app app
@@ -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() {
+11 -29
View File
@@ -18,6 +18,7 @@ use crate::theme::{ImportError, import_theme, refresh_registry};
use crate::ui_focus::FocusRow; use crate::ui_focus::FocusRow;
use crate::ui_modal::{ use crate::ui_modal::{
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header,
spawn_tab_chip,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM,
@@ -117,41 +118,22 @@ pub(super) fn spawn_settings_panel(
}); });
} }
/// One Settings tab chip. The active chip is filled + bright; inactive /// One Settings tab chip — thin wrapper over the shared
/// chips are quiet outlines. /// [`spawn_tab_chip`] widget so Settings and the You hub stay visually
/// identical.
fn tab_chip( fn tab_chip(
parent: &mut ChildSpawnerCommands, parent: &mut ChildSpawnerCommands,
tab: SettingsTab, tab: SettingsTab,
active: bool, active: bool,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
) { ) {
let font = TextFont { spawn_tab_chip(
font: font_res.map(|f| f.0.clone()).unwrap_or_default(), parent,
font_size: TYPE_CAPTION, SettingsTabButton(tab),
..default() tab.label(),
}; active,
parent font_res,
.spawn(( );
SettingsTabButton(tab),
Button,
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(if active { BG_ELEVATED_HI } else { BG_BASE }),
BorderColor::all(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
HighContrastBorder::with_default(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
))
.with_children(|b| {
b.spawn((
Text::new(tab.label()),
font,
TextColor(if active { TEXT_PRIMARY } else { TEXT_SECONDARY }),
));
});
} }
/// Audio tab: the two volume rows. /// Audio tab: the two volume rows.
+90 -106
View File
@@ -8,7 +8,6 @@
use std::path::PathBuf; use std::path::PathBuf;
use bevy::input::ButtonInput;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_data::{ use solitaire_data::{
@@ -25,17 +24,13 @@ use crate::events::{
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::platform::ClipboardBackendResource; use crate::platform::ClipboardBackendResource;
use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::time_attack_plugin::TimeAttackResource; use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_modal::{ use crate::ui_modal::{ButtonVariant, ModalButton, spawn_modal_button};
ButtonVariant, ModalButton, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO, ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
}; };
/// Bevy resource wrapping the current stats. /// Bevy resource wrapping the current stats.
@@ -225,8 +220,9 @@ impl Plugin for StatsPlugin {
.before(GameMutation) .before(GameMutation)
.before(update_stats_on_new_game), .before(update_stats_on_new_game),
) )
.add_systems(Update, toggle_stats_screen.after(GameMutation)) // Open/close/tab handling moved to `you_hub_plugin`
.add_systems(Update, handle_stats_close_button) // (Phase E) — this plugin now owns body content, replay
// selector behavior, and scroll.
.add_systems(Update, refresh_replay_history_on_win.after(GameMutation)) .add_systems(Update, refresh_replay_history_on_win.after(GameMutation))
.add_systems(Update, handle_watch_replay_button) .add_systems(Update, handle_watch_replay_button)
.add_systems(Update, handle_copy_share_link_button) .add_systems(Update, handle_copy_share_link_button)
@@ -632,70 +628,17 @@ fn handle_forfeit(
} }
} }
/// Marker on the "Done" button inside the Stats modal. Click despawns /// Builds the Stats tab body inside the You hub's card: the 8-cell
/// the overlay; `S` keyboard shortcut toggles it the same way. /// grid plus per-mode bests, progression, weekly goals, unlocks, and
#[derive(Component, Debug)] /// the optional Time Attack line. The replay selector lives on its own
pub struct StatsCloseButton; /// hub tab now — see [`spawn_replays_body`]. All markers
/// (`StatsCell`, `PerModeBestsRow`, `StatsScrollable`) are unchanged.
#[allow(clippy::too_many_arguments)] pub(crate) fn spawn_stats_body(
fn toggle_stats_screen( card: &mut ChildSpawnerCommands,
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleStatsRequestEvent>,
stats: Res<StatsResource>,
progress: Option<Res<ProgressResource>>,
time_attack: Option<Res<TimeAttackResource>>,
font_res: Option<Res<FontResource>>,
latest_replay: Res<ReplayHistoryResource>,
selected_index: Res<SelectedReplayIndex>,
screens: Query<Entity, With<StatsScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<StatsScreen>)>,
) {
let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyS) && !button_clicked {
return;
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
if !other_modal_scrims.is_empty() {
return;
}
spawn_stats_screen(
&mut commands,
&stats.0,
progress.as_deref().map(|p| &p.0),
time_attack.as_deref(),
font_res.as_deref(),
&latest_replay.0.replays,
selected_index.0,
);
}
}
/// Click handler for the modal's "Done" button — despawns the overlay
/// the same way the `S` accelerator does.
fn handle_stats_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<StatsCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<StatsScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
fn spawn_stats_screen(
commands: &mut Commands,
stats: &StatsSnapshot, stats: &StatsSnapshot,
progress: Option<&PlayerProgress>, progress: Option<&PlayerProgress>,
time_attack: Option<&TimeAttackResource>, time_attack: Option<&TimeAttackResource>,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
replays: &[Replay],
selected_index: usize,
) { ) {
// --- primary stat cells --- // --- primary stat cells ---
// First-launch zero-state: when no games have been played yet, render // First-launch zero-state: when no games have been played yet, render
@@ -756,9 +699,7 @@ fn spawn_stats_screen(
..default() ..default()
}; };
let scrim = spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| { {
spawn_modal_header(card, "Statistics", font_res);
// Scrollable body — the Stats panel renders an 8-cell grid plus // Scrollable body — the Stats panel renders an 8-cell grid plus
// multiple sections (per-mode bests, progression, weekly goals, // multiple sections (per-mode bests, progression, weekly goals,
// unlocks, optional Time Attack, latest replay caption) and // unlocks, optional Time Attack, latest replay caption) and
@@ -935,7 +876,45 @@ fn spawn_stats_screen(
TextColor(STATE_WARNING), TextColor(STATE_WARNING),
)); ));
} }
});
}
}
/// Builds the Replays tab body inside the You hub's card: the
/// Prev/Next selector, detail line, and the Watch / Copy-share-link
/// actions (buttons live in the body now — the hub owns the single
/// Done in the action row). All markers (`ReplayPrevButton`,
/// `ReplayNextButton`, `ReplaySelectorCaption`, `ReplaySelectorDetail`,
/// `WatchReplayButton`, `CopyShareLinkButton`) are unchanged, so the
/// selector/watch/copy systems keep working.
pub(crate) fn spawn_replays_body(
card: &mut ChildSpawnerCommands,
replays: &[Replay],
selected_index: usize,
font_res: Option<&FontResource>,
) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_row = TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
};
{
// Scrollable body — mirrors the other hub tabs so short
// windows can still reach every control.
card.spawn((
StatsScrollable,
ScrollPosition::default(),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_3,
max_height: Val::Vh(70.0),
overflow: Overflow::scroll_y(),
..default()
},
))
.with_children(|body| {
// --- Replay selector --- // --- Replay selector ---
// Prev / Next chips step through the full replay history; // Prev / Next chips step through the full replay history;
// `repaint_replay_selector_caption` and // `repaint_replay_selector_caption` and
@@ -1019,12 +998,21 @@ fn spawn_stats_screen(
)); ));
}); });
spawn_modal_actions(card, |actions| { // Tab-local actions. The Watch Replay button is always
// The Watch Replay button is always rendered so the // rendered so the affordance is discoverable from a fresh
// affordance is discoverable from a fresh install. When no // install — with no replay, its click handler surfaces a
// replay exists, the click handler surfaces a clear // "No replay recorded yet" toast rather than silently doing
// "No replay recorded yet" toast rather than silently // nothing. Same policy for Copy share link (toast explains
// doing nothing. // when no shareable upload exists).
card.spawn(Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
column_gap: VAL_SPACE_2,
row_gap: VAL_SPACE_2,
margin: UiRect::top(VAL_SPACE_2),
..default()
})
.with_children(|actions| {
spawn_modal_button( spawn_modal_button(
actions, actions,
WatchReplayButton, WatchReplayButton,
@@ -1033,11 +1021,6 @@ fn spawn_stats_screen(
ButtonVariant::Secondary, ButtonVariant::Secondary,
font_res, font_res,
); );
// Copy share link only renders when a sharable URL is in
// hand. The button is intentionally absent (rather than
// disabled) when no upload has happened yet — keeps the
// action bar free of dead controls in the local-only and
// first-launch cases.
spawn_modal_button( spawn_modal_button(
actions, actions,
CopyShareLinkButton, CopyShareLinkButton,
@@ -1046,18 +1029,8 @@ fn spawn_stats_screen(
ButtonVariant::Secondary, ButtonVariant::Secondary,
font_res, font_res,
); );
spawn_modal_button(
actions,
StatsCloseButton,
"Done",
Some("S"),
ButtonVariant::Primary,
font_res,
);
}); });
}); }
// Stats is read-only — opt into click-outside-to-dismiss.
commands.entity(scrim).insert(ScrimDismissible);
} }
/// Spawn one row of the "Per-mode bests" section: the mode label on the /// Spawn one row of the "Per-mode bests" section: the mode label on the
@@ -1290,6 +1263,7 @@ mod tests {
// ProgressResource is an optional dependency for the stats screen; // ProgressResource is an optional dependency for the stats screen;
// include it so toggle tests exercise the progression panel. // include it so toggle tests exercise the progression panel.
app.add_plugins(crate::progress_plugin::ProgressPlugin::headless()); app.add_plugins(crate::progress_plugin::ProgressPlugin::headless());
app.add_plugins(crate::you_hub_plugin::YouHubPlugin);
app.update(); app.update();
app app
} }
@@ -1832,6 +1806,25 @@ mod tests {
// Prev/Next replay selector spawn-site tests // Prev/Next replay selector spawn-site tests
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// Opens the You hub via `S`, then switches to the Replays tab
/// (where the selector lives since Phase E) and settles the rebuild.
fn open_replays_tab(app: &mut App) {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
// MinimalPlugins never clears input, so the sticky `just_pressed`
// would keep re-requesting the Stats tab every frame.
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
app.world_mut()
.resource_mut::<crate::you_hub_plugin::ActiveYouTab>()
.0 = crate::you_hub_plugin::YouTab::Replays;
app.update();
app.update();
}
#[test] #[test]
fn selector_row_spawns_when_stats_screen_opens() { fn selector_row_spawns_when_stats_screen_opens() {
let mut app = headless_app(); let mut app = headless_app();
@@ -1840,10 +1833,7 @@ mod tests {
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>(); let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
hist.0.replays.push(make_test_replay(90, None)); hist.0.replays.push(make_test_replay(90, None));
} }
app.world_mut() open_replays_tab(&mut app);
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
let prev = app let prev = app
.world_mut() .world_mut()
@@ -1878,10 +1868,7 @@ mod tests {
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>(); let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
hist.0.replays.push(make_test_replay(120, None)); hist.0.replays.push(make_test_replay(120, None));
} }
app.world_mut() open_replays_tab(&mut app);
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
let mut q = app let mut q = app
.world_mut() .world_mut()
@@ -1901,10 +1888,7 @@ mod tests {
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>(); let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
hist.0.replays.push(make_test_replay(65, None)); // 65s → "1:05" hist.0.replays.push(make_test_replay(65, None)); // 65s → "1:05"
} }
app.world_mut() open_replays_tab(&mut app);
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
let mut q = app let mut q = app
.world_mut() .world_mut()
+15 -8
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,19 +347,23 @@ 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 { }
commands.entity(entity).despawn(); for entity in &screens {
} commands.entity(entity).despawn();
} }
} }
+43 -3
View File
@@ -60,9 +60,9 @@ use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI,
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder, BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_PRIMARY,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
scaled_duration, VAL_SPACE_4, VAL_SPACE_5, scaled_duration,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -402,6 +402,46 @@ pub fn spawn_modal_button<M: Component>(
}); });
} }
/// One tab chip for a tabbed modal (Settings, the You hub). The active
/// chip is filled + bright with a success-green border; inactive chips
/// are quiet outlines. `marker` is the plugin's click-target component
/// carrying which tab the chip selects.
pub fn spawn_tab_chip<M: Component>(
parent: &mut ChildSpawnerCommands,
marker: M,
label: &str,
active: bool,
font_res: Option<&FontResource>,
) {
let font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
parent
.spawn((
marker,
Button,
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(if active { BG_ELEVATED_HI } else { BG_BASE }),
BorderColor::all(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
HighContrastBorder::with_default(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
))
.with_children(|b| {
b.spawn((
Text::new(label),
font,
TextColor(if active { TEXT_PRIMARY } else { TEXT_SECONDARY }),
));
});
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Generic touch-scroll helper // Generic touch-scroll helper
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+268 -107
View File
@@ -5,6 +5,13 @@
//! started), a full-screen modal is spawned showing score, time, XP, and a //! started), a full-screen modal is spawned showing score, time, XP, and a
//! "Play Again" button that fires `NewGameRequestEvent` and closes the modal. //! "Play Again" button that fires `NewGameRequestEvent` and closes the modal.
//! //!
//! # Phase G (docs/ui-redesign-2026-07.md) — action hierarchy
//! The modal leads with actions: **Play Again** (primary, Enter
//! accelerator), then **Watch Replay** and **Share Replay** (shared
//! `stats_plugin` markers, so the global handlers there act on the
//! just-won replay), with the score/time/XP recap reading quietly
//! below.
//!
//! # Task #47 — Win fanfare screen-shake //! # Task #47 — Win fanfare screen-shake
//! When `GameWonEvent` fires, `ScreenShakeResource` is set. A system offsets //! When `GameWonEvent` fires, `ScreenShakeResource` is set. A system offsets
//! the `Camera2d` `Transform` each frame with a decaying oscillation until the //! the `Camera2d` `Transform` each frame with a decaying oscillation until the
@@ -23,7 +30,7 @@ use crate::game_plugin::GameMutation;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::settings_plugin::SettingsResource; use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{StatsResource, StatsUpdate}; use crate::stats_plugin::{CopyShareLinkButton, StatsResource, StatsUpdate, WatchReplayButton};
use crate::ui_modal::ModalScrim; use crate::ui_modal::ModalScrim;
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS, ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
@@ -163,12 +170,15 @@ pub struct SessionAchievements {
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct WinSummaryOverlay; pub struct WinSummaryOverlay;
/// Marker on the "Play Again" / "Watch Replay" buttons inside the win-summary modal. /// Marker on the "Play Again" button inside the win-summary modal.
///
/// Watch Replay and Share Replay carry the shared
/// [`WatchReplayButton`] / [`CopyShareLinkButton`] markers from
/// `stats_plugin`, so the global handlers there drive them (both act
/// on [`crate::stats_plugin::SelectedReplayIndex`], which snaps to the
/// just-won replay on every `GameWonEvent`).
#[derive(Component, Debug)] #[derive(Component, Debug)]
enum WinSummaryButton { struct WinSummaryPlayAgainButton;
PlayAgain,
WatchReplay,
}
/// Marker for one row of the win-modal score-breakdown reveal. /// Marker for one row of the win-modal score-breakdown reveal.
/// ///
@@ -230,6 +240,7 @@ impl Plugin for WinSummaryPlugin {
collect_session_achievements, collect_session_achievements,
spawn_win_summary_after_delay, spawn_win_summary_after_delay,
handle_win_summary_buttons, handle_win_summary_buttons,
close_overlay_on_watch_replay,
handle_win_summary_keyboard, handle_win_summary_keyboard,
apply_screen_shake, apply_screen_shake,
reveal_score_breakdown, reveal_score_breakdown,
@@ -604,50 +615,48 @@ fn spawn_win_summary_after_delay(
} }
} }
/// Handles "Play Again" and "Watch Replay" in the win-summary modal. /// Handles "Play Again" in the win-summary modal: collapses the
/// Handles "Play Again" and "Watch Replay" in the win-summary modal. /// overlay and requests a fresh deal. `NewGameRequestEvent::default()`
/// reuses the current game's `GameMode`, and `handle_new_game` reads
/// the deal options (draw mode, difficulty) from `Settings` — so the
/// rematch is "same mode + same options" in one tap.
fn handle_win_summary_buttons( fn handle_win_summary_buttons(
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>, interaction_query: Query<&Interaction, (Changed<Interaction>, With<WinSummaryPlayAgainButton>)>,
overlays: Query<Entity, With<WinSummaryOverlay>>, overlays: Query<Entity, With<WinSummaryOverlay>>,
mut commands: Commands, mut commands: Commands,
mut new_game: MessageWriter<NewGameRequestEvent>, mut new_game: MessageWriter<NewGameRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
mut playback: Option<ResMut<crate::replay_playback::ReplayPlaybackState>>,
) { ) {
// Collect all pressed buttons first to avoid moving `playback` inside the loop. if !interaction_query.iter().any(|i| *i == Interaction::Pressed) {
let pressed: Vec<&WinSummaryButton> = interaction_query return;
.iter() }
.filter(|(i, _)| **i == Interaction::Pressed) for entity in &overlays {
.map(|(_, b)| b) commands.entity(entity).despawn();
.collect(); }
new_game.write(NewGameRequestEvent::default());
}
for button in pressed { /// Collapses the win-summary overlay when its "Watch Replay" button is
match button { /// pressed, so playback (started by `stats_plugin`'s global
WinSummaryButton::PlayAgain => { /// [`WatchReplayButton`] handler reacting to the same press) is not
for entity in &overlays { /// hidden behind the celebration scrim. No-op while the overlay is
commands.entity(entity).despawn(); /// closed — the Replays-tab copy of the button manages its own modal.
} ///
new_game.write(NewGameRequestEvent::default()); /// "Share Replay" ([`CopyShareLinkButton`]) deliberately does NOT
} /// close the overlay: the player stays on the celebration while the
WinSummaryButton::WatchReplay => { /// copy-feedback toast confirms the link.
let latest = history.as_ref().and_then(|h| h.0.replays.last()).cloned(); fn close_overlay_on_watch_replay(
match (latest, playback.as_mut()) { buttons: Query<&Interaction, (Changed<Interaction>, With<WatchReplayButton>)>,
(Some(replay), Some(pb)) => { overlays: Query<Entity, With<WinSummaryOverlay>>,
for entity in &overlays { mut commands: Commands,
commands.entity(entity).despawn(); ) {
} if overlays.is_empty() {
crate::replay_playback::start_replay_playback(&mut commands, pb, replay); return;
} }
(Some(_), None) => { if !buttons.iter().any(|i| *i == Interaction::Pressed) {
toast.write(InfoToastEvent("Replay playback not available".to_string())); return;
} }
(None, _) => { for entity in &overlays {
toast.write(InfoToastEvent("No replay saved yet".to_string())); commands.entity(entity).despawn();
}
}
}
}
} }
} }
@@ -818,18 +827,68 @@ fn spawn_overlay(
)); ));
} }
// Score breakdown reveal — replaces the previous single // --- Action hierarchy (Phase G) ---
// "Score:" line with a per-component multi-row layout. // Play Again is the hero action; the two replay
// actions sit under it; the stats recap reads quietly
// below all three. Rematch is one tap.
// Play Again (primary, full row)
card.spawn((
WinSummaryPlayAgainButton,
Button,
Node {
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
justify_content: JustifyContent::Center,
align_self: AlignSelf::Stretch,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
margin: UiRect::top(VAL_SPACE_2),
..default()
},
BackgroundColor(ACCENT_PRIMARY),
))
.with_children(|b| {
b.spawn((
Text::new("Play Again \u{21B5}"),
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(BG_BASE),
));
});
// Watch Replay + Share Replay (secondary, side by side).
// Both reuse the global stats_plugin handlers, which
// target the just-won replay (`SelectedReplayIndex`
// snaps to 0 on every win). Each is always rendered —
// with no replay / no share URL the handler explains
// itself in a toast instead of silently doing nothing.
card.spawn(Node {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::Center,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|row| {
spawn_replay_action(row, WatchReplayButton, "Watch Replay");
spawn_replay_action(row, CopyShareLinkButton, "Share Replay");
});
// --- Quiet stats recap, below the actions ---
// Score breakdown reveal — per-component multi-row
// layout with the staggered fade-in.
spawn_score_breakdown(card, &breakdown, anim_speed); spawn_score_breakdown(card, &breakdown, anim_speed);
// Time // Time (demoted to body/secondary — part of the quiet
// recap, not the celebration headline)
card.spawn(( card.spawn((
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))), Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
TextFont { TextFont {
font_size: TYPE_HEADLINE, font_size: TYPE_BODY_LG,
..default() ..default()
}, },
TextColor(TEXT_PRIMARY), TextColor(TEXT_SECONDARY),
)); ));
// XP total // XP total
@@ -859,68 +918,40 @@ fn spawn_overlay(
if !session.names.is_empty() { if !session.names.is_empty() {
spawn_achievements_section(card, &session.names); spawn_achievements_section(card, &session.names);
} }
// Button row: Watch Replay + Play Again side by side.
card.spawn(Node {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::Center,
column_gap: VAL_SPACE_3,
margin: UiRect::top(VAL_SPACE_2),
..default()
})
.with_children(|row| {
// Watch Replay (secondary style)
row.spawn((
WinSummaryButton::WatchReplay,
Button,
Node {
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(Color::NONE),
BorderColor::all(ACCENT_PRIMARY),
))
.with_children(|b| {
b.spawn((
Text::new("Watch Replay"),
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(ACCENT_PRIMARY),
));
});
// Play Again (primary style)
row.spawn((
WinSummaryButton::PlayAgain,
Button,
Node {
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BackgroundColor(ACCENT_PRIMARY),
))
.with_children(|b| {
b.spawn((
Text::new("Play Again \u{21B5}"),
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(BG_BASE),
));
});
});
}); });
}); });
} }
/// Spawns one secondary (outline-style) replay action button in the
/// win modal's replay row. `marker` is the shared `stats_plugin`
/// click-target component (`WatchReplayButton` / `CopyShareLinkButton`)
/// whose global handler reacts to the press.
fn spawn_replay_action<M: Component>(row: &mut ChildSpawnerCommands, marker: M, label: &str) {
row.spawn((
marker,
Button,
Node {
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(Color::NONE),
BorderColor::all(ACCENT_PRIMARY),
))
.with_children(|b| {
b.spawn((
Text::new(label.to_string()),
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(ACCENT_PRIMARY),
));
});
}
/// Maximum number of achievement names shown explicitly in the win modal before /// Maximum number of achievement names shown explicitly in the win modal before
/// the overflow "...and N more" line is shown instead. /// the overflow "...and N more" line is shown instead.
const MAX_ACHIEVEMENTS_SHOWN: usize = 3; const MAX_ACHIEVEMENTS_SHOWN: usize = 3;
@@ -1863,4 +1894,134 @@ mod tests {
assert_eq!(stagger, 0.0); assert_eq!(stagger, 0.0);
assert_eq!(fade, 0.0); assert_eq!(fade, 0.0);
} }
// -----------------------------------------------------------------------
// Phase G — action hierarchy
// -----------------------------------------------------------------------
/// Like [`make_app`] but with `TimePlugin` disabled and a manual
/// `Time` resource, so tests can step the win-summary delay timer
/// deterministically via `Time::advance_by` (the real clock's
/// microsecond deltas would never reach the 0.5 s threshold).
fn make_app_manual_clock() -> App {
use bevy::time::TimePlugin;
let mut app = App::new();
app.add_plugins(MinimalPlugins.build().disable::<TimePlugin>())
.add_plugins(WinSummaryPlugin)
.insert_resource(StatsResource(StatsSnapshot::default()))
.insert_resource(GameStateResource(GameState::new(
0,
solitaire_core::DrawStockConfig::DrawOne,
)))
.insert_resource(ProgressResource(PlayerProgress::default()));
app.init_resource::<Time>();
app.update();
app
}
/// Drives the real spawn path: fire `GameWonEvent`, then advance
/// `Time` past the 0.5 s celebration delay so
/// `spawn_win_summary_after_delay` spawns the overlay.
fn open_win_overlay(app: &mut App) {
app.world_mut().write_message(GameWonEvent {
score: 1200,
time_seconds: 90,
});
app.update();
{
let mut time = app.world_mut().resource_mut::<Time>();
time.advance_by(std::time::Duration::from_secs_f32(
WIN_SUMMARY_DELAY_SECS + 0.1,
));
}
app.update();
// One more frame so the deferred spawn commands flush.
app.update();
}
fn overlay_count(app: &mut App) -> usize {
app.world_mut()
.query::<&WinSummaryOverlay>()
.iter(app.world())
.count()
}
fn button_entity<M: Component>(app: &mut App) -> Entity {
let entities: Vec<Entity> = app
.world_mut()
.query_filtered::<Entity, With<M>>()
.iter(app.world())
.collect();
assert_eq!(entities.len(), 1, "expected exactly one button");
entities[0]
}
/// The win modal must render all three Phase G actions: the
/// primary Play Again plus the shared Watch/Share replay buttons.
#[test]
fn win_modal_renders_play_again_watch_and_share_actions() {
let mut app = make_app_manual_clock();
open_win_overlay(&mut app);
assert_eq!(overlay_count(&mut app), 1, "overlay must spawn after delay");
button_entity::<WinSummaryPlayAgainButton>(&mut app);
button_entity::<WatchReplayButton>(&mut app);
button_entity::<CopyShareLinkButton>(&mut app);
}
/// Play Again collapses the overlay and requests a fresh deal.
#[test]
fn play_again_press_closes_overlay_and_requests_new_game() {
use bevy::ecs::message::Messages;
let mut app = make_app_manual_clock();
open_win_overlay(&mut app);
let button = button_entity::<WinSummaryPlayAgainButton>(&mut app);
app.world_mut()
.entity_mut(button)
.insert(Interaction::Pressed);
app.update();
assert_eq!(overlay_count(&mut app), 0, "Play Again must close overlay");
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
assert!(
!events.is_empty(),
"Play Again must write NewGameRequestEvent"
);
}
/// Watch Replay collapses the overlay so playback (driven by the
/// global `stats_plugin` handler on the same press) is visible.
#[test]
fn watch_replay_press_closes_the_overlay() {
let mut app = make_app_manual_clock();
open_win_overlay(&mut app);
let button = button_entity::<WatchReplayButton>(&mut app);
app.world_mut()
.entity_mut(button)
.insert(Interaction::Pressed);
app.update();
assert_eq!(
overlay_count(&mut app),
0,
"Watch Replay must close the celebration overlay"
);
}
/// Share Replay leaves the overlay open — the player stays on the
/// celebration while the copy-feedback toast confirms the link.
#[test]
fn share_replay_press_keeps_the_overlay_open() {
let mut app = make_app_manual_clock();
open_win_overlay(&mut app);
let button = button_entity::<CopyShareLinkButton>(&mut app);
app.world_mut()
.entity_mut(button)
.insert(Interaction::Pressed);
app.update();
assert_eq!(
overlay_count(&mut app),
1,
"Share Replay must not close the overlay"
);
}
} }
+486
View File
@@ -0,0 +1,486 @@
//! The "You" hub — Profile · Stats · Achievements · Replays folded
//! into one tabbed modal (Phase E of `docs/ui-redesign-2026-07.md`).
//!
//! The four screens were previously standalone modals reached one at a
//! time through the HUD popover. The hub owns the modal shell (header,
//! tab chips via the shared [`spawn_tab_chip`] widget, a single Done
//! button); each tab's content is a body builder that lives in its
//! original plugin (`spawn_profile_body`, `spawn_stats_body`,
//! `spawn_achievements_body`, `spawn_replays_body`) so every marker
//! component and per-row update system keeps working unchanged.
//!
//! Legacy screen markers (`ProfileScreen`, `StatsScreen`,
//! `AchievementsScreen`) are inserted on the hub scrim while their tab
//! is active, so existing queries and tests keep their meaning.
//!
//! Open paths: `ToggleProfileRequestEvent` / `ToggleStatsRequestEvent`
//! / `ToggleAchievementsRequestEvent` (HUD popover) and the P / S / A
//! accelerators — each opens the hub pre-selected to its tab, toggles
//! the hub closed when its tab is already showing, or switches tabs
//! when a different tab is showing. Esc, Done, and scrim-click close.
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use solitaire_data::StatsSnapshot;
use crate::achievement_plugin::{AchievementsResource, AchievementsScreen};
use crate::events::{
ToggleAchievementsRequestEvent, ToggleProfileRequestEvent, ToggleStatsRequestEvent,
};
use crate::font_plugin::FontResource;
use crate::profile_plugin::{AvatarResource, ProfileScreen};
use crate::progress_plugin::ProgressResource;
use crate::resources::SyncStatusResource;
use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{ReplayHistoryResource, SelectedReplayIndex, StatsResource, StatsScreen};
use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_focus::FocusRow;
use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header, spawn_tab_chip,
};
use crate::ui_theme::{VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL};
/// Which tab of the You hub is showing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum YouTab {
#[default]
Profile,
Stats,
Achievements,
Replays,
}
impl YouTab {
/// Every tab, in display order.
pub const ALL: [Self; 4] = [
Self::Profile,
Self::Stats,
Self::Achievements,
Self::Replays,
];
/// Chip label.
pub fn label(self) -> &'static str {
match self {
Self::Profile => "Profile",
Self::Stats => "Stats",
Self::Achievements => "Awards",
Self::Replays => "Replays",
}
}
}
/// The active hub tab. Session-only; reopening returns to the last tab
/// unless an open request names a different one.
#[derive(Resource, Debug, Default)]
pub struct ActiveYouTab(pub YouTab);
/// Marker on the hub modal's scrim root.
#[derive(Component)]
pub struct YouHubScreen;
/// Per-chip tab selector button.
#[derive(Component, Debug)]
struct YouHubTabButton(YouTab);
/// Marker on the hub's Done button.
#[derive(Component)]
struct YouHubCloseButton;
/// Read-only bundle of everything the tab bodies render from. Split
/// out as a [`SystemParam`] because the open + rebuild systems both
/// need the full set and Bevy caps systems at 16 parameters.
#[derive(SystemParam)]
struct YouHubContext<'w> {
settings: Option<Res<'w, SettingsResource>>,
sync_status: Option<Res<'w, SyncStatusResource>>,
progress: Option<Res<'w, ProgressResource>>,
achievements: Option<Res<'w, AchievementsResource>>,
stats: Option<Res<'w, StatsResource>>,
avatar: Option<Res<'w, AvatarResource>>,
time_attack: Option<Res<'w, TimeAttackResource>>,
replay_history: Option<Res<'w, ReplayHistoryResource>>,
selected_replay: Option<Res<'w, SelectedReplayIndex>>,
font_res: Option<Res<'w, FontResource>>,
}
/// Bevy plugin owning the You hub lifecycle. Requires the profile,
/// stats, and achievement plugins for live data; degrades to empty tab
/// bodies without them (headless tests).
pub struct YouHubPlugin;
impl Plugin for YouHubPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ActiveYouTab>()
.add_message::<ToggleProfileRequestEvent>()
.add_message::<ToggleStatsRequestEvent>()
.add_message::<ToggleAchievementsRequestEvent>()
.add_systems(
Update,
// Chained: a toggle/chip press must be observed before
// the rebuild, and the rebuild before close — total
// order prevents double-spawns within one frame.
(
open_or_toggle_you_hub,
handle_tab_buttons,
rebuild_on_tab_change,
handle_close_button,
)
.chain(),
);
}
}
/// Maps this frame's toggle events + accelerator keys to a requested
/// tab, mirroring the semantics the three standalone screens had.
fn requested_tab(
profile_events: &mut MessageReader<ToggleProfileRequestEvent>,
stats_events: &mut MessageReader<ToggleStatsRequestEvent>,
achievements_events: &mut MessageReader<ToggleAchievementsRequestEvent>,
keys: &ButtonInput<KeyCode>,
) -> Option<YouTab> {
let profile = profile_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyP);
let stats = stats_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyS);
let achievements = achievements_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyA);
if profile {
Some(YouTab::Profile)
} else if stats {
Some(YouTab::Stats)
} else if achievements {
Some(YouTab::Achievements)
} else {
None
}
}
/// Opens the hub on the requested tab, switches tabs when it's already
/// open on a different one, toggles it closed on a same-tab request or
/// Esc.
#[allow(clippy::too_many_arguments)]
fn open_or_toggle_you_hub(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut profile_events: MessageReader<ToggleProfileRequestEvent>,
mut stats_events: MessageReader<ToggleStatsRequestEvent>,
mut achievements_events: MessageReader<ToggleAchievementsRequestEvent>,
screens: Query<Entity, With<YouHubScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<YouHubScreen>)>,
mut active: ResMut<ActiveYouTab>,
ctx: YouHubContext,
) {
let requested = requested_tab(
&mut profile_events,
&mut stats_events,
&mut achievements_events,
&keys,
);
let open = !screens.is_empty();
if open {
// Esc closes the topmost modal — the hub, when it's showing.
if keys.just_pressed(KeyCode::Escape) {
for entity in &screens {
commands.entity(entity).despawn();
}
return;
}
match requested {
Some(tab) if tab == active.0 => {
// Same-tab request toggles closed (P opens Profile,
// P again closes — parity with the old screens).
for entity in &screens {
commands.entity(entity).despawn();
}
}
Some(tab) => {
// Different tab: switch in place. The rebuild system
// (next in the chain) observes the change.
active.0 = tab;
}
None => {}
}
return;
}
let Some(tab) = requested else { return };
if !other_modal_scrims.is_empty() {
return; // Another modal is already visible (§14.2).
}
if active.0 != tab {
// Written before the spawn; the rebuild system also runs this
// frame but sees no live hub (the spawn below is deferred), so
// no double-spawn.
active.0 = tab;
}
spawn_you_hub(&mut commands, tab, &ctx);
}
/// Switches the active tab when a chip is pressed.
fn handle_tab_buttons(
interactions: Query<(&Interaction, &YouHubTabButton), Changed<Interaction>>,
mut active: ResMut<ActiveYouTab>,
) {
for (interaction, chip) in &interactions {
if *interaction != Interaction::Pressed {
continue;
}
if active.0 != chip.0 {
active.0 = chip.0;
}
}
}
/// Rebuilds the open hub when [`ActiveYouTab`] changes.
fn rebuild_on_tab_change(
active: Res<ActiveYouTab>,
screens: Query<Entity, With<YouHubScreen>>,
mut commands: Commands,
ctx: YouHubContext,
) {
if !active.is_changed() || active.is_added() {
return;
}
if screens.is_empty() {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
spawn_you_hub(&mut commands, active.0, &ctx);
}
/// Despawns the hub when Done is pressed.
fn handle_close_button(
interactions: Query<&Interaction, (Changed<Interaction>, With<YouHubCloseButton>)>,
screens: Query<Entity, With<YouHubScreen>>,
mut commands: Commands,
) {
for interaction in &interactions {
if *interaction != Interaction::Pressed {
continue;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
}
/// Spawns the hub modal showing `tab`, and stamps the scrim with the
/// tab's legacy screen marker so pre-hub queries keep working.
fn spawn_you_hub(commands: &mut Commands, tab: YouTab, ctx: &YouHubContext) {
let font_res = ctx.font_res.as_deref();
let scrim = spawn_modal(commands, YouHubScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "You", font_res);
// Tab chips — shared widget with the Settings panel.
card.spawn((
FocusRow,
Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
column_gap: VAL_SPACE_2,
row_gap: VAL_SPACE_2,
margin: UiRect::bottom(VAL_SPACE_3),
..default()
},
))
.with_children(|row| {
for chip_tab in YouTab::ALL {
spawn_tab_chip(
row,
YouHubTabButton(chip_tab),
chip_tab.label(),
chip_tab == tab,
font_res,
);
}
});
match tab {
YouTab::Profile => crate::profile_plugin::spawn_profile_body(
card,
ctx.settings.as_deref(),
ctx.sync_status.as_deref(),
ctx.progress.as_deref(),
ctx.achievements.as_deref(),
ctx.stats.as_deref(),
font_res,
ctx.avatar.as_deref(),
),
YouTab::Stats => {
let default_stats = StatsSnapshot::default();
crate::stats_plugin::spawn_stats_body(
card,
ctx.stats.as_deref().map_or(&default_stats, |s| &s.0),
ctx.progress.as_deref().map(|p| &p.0),
ctx.time_attack.as_deref(),
font_res,
);
}
YouTab::Achievements => crate::achievement_plugin::spawn_achievements_body(
card,
ctx.achievements
.as_deref()
.map_or(&[][..], |a| a.0.as_slice()),
font_res,
),
YouTab::Replays => crate::stats_plugin::spawn_replays_body(
card,
ctx.replay_history
.as_deref()
.map_or(&[][..], |h| h.0.replays.as_slice()),
ctx.selected_replay.as_deref().map_or(0, |s| s.0),
font_res,
),
}
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
YouHubCloseButton,
"Done",
None,
ButtonVariant::Primary,
font_res,
);
});
});
let mut scrim_commands = commands.entity(scrim);
scrim_commands.insert(ScrimDismissible);
// Legacy markers: pre-hub code and tests query these.
match tab {
YouTab::Profile => {
scrim_commands.insert(ProfileScreen);
}
YouTab::Stats | YouTab::Replays => {
scrim_commands.insert(StatsScreen);
}
YouTab::Achievements => {
scrim_commands.insert(AchievementsScreen);
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(YouHubPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn hub_count(app: &mut App) -> usize {
app.world_mut()
.query::<&YouHubScreen>()
.iter(app.world())
.count()
}
/// Each Toggle*RequestEvent opens the hub pre-selected to its tab,
/// stamped with the tab's legacy screen marker.
#[test]
fn toggle_events_open_hub_on_their_tab() {
use bevy::ecs::message::Messages;
let mut app = headless_app();
app.world_mut()
.resource_mut::<Messages<ToggleStatsRequestEvent>>()
.write(ToggleStatsRequestEvent);
app.update();
app.update();
assert_eq!(hub_count(&mut app), 1, "stats request must open the hub");
assert_eq!(app.world().resource::<ActiveYouTab>().0, YouTab::Stats);
assert_eq!(
app.world_mut()
.query::<&StatsScreen>()
.iter(app.world())
.count(),
1,
"legacy StatsScreen marker must ride the hub scrim"
);
// A different tab's request switches in place — still one hub.
app.world_mut()
.resource_mut::<Messages<ToggleAchievementsRequestEvent>>()
.write(ToggleAchievementsRequestEvent);
app.update();
app.update();
assert_eq!(hub_count(&mut app), 1, "tab switch must not stack hubs");
assert_eq!(
app.world().resource::<ActiveYouTab>().0,
YouTab::Achievements
);
assert_eq!(
app.world_mut()
.query::<&AchievementsScreen>()
.iter(app.world())
.count(),
1
);
// Same-tab request toggles the hub closed.
app.world_mut()
.resource_mut::<Messages<ToggleAchievementsRequestEvent>>()
.write(ToggleAchievementsRequestEvent);
app.update();
app.update();
assert_eq!(hub_count(&mut app), 0, "same-tab request must close");
}
/// Chip-driven tab switches rebuild the single hub with the new
/// tab's body (Profile scrollable swaps for the Stats one).
#[test]
fn tab_switch_swaps_bodies_without_stacking() {
use crate::profile_plugin::ProfileScrollable;
use crate::stats_plugin::StatsScrollable;
use bevy::ecs::message::Messages;
let mut app = headless_app();
app.world_mut()
.resource_mut::<Messages<ToggleProfileRequestEvent>>()
.write(ToggleProfileRequestEvent);
app.update();
app.update();
assert_eq!(
app.world_mut()
.query::<&ProfileScrollable>()
.iter(app.world())
.count(),
1,
"profile body must spawn on the Profile tab"
);
app.world_mut().resource_mut::<ActiveYouTab>().0 = YouTab::Stats;
app.update();
app.update();
assert_eq!(hub_count(&mut app), 1, "rebuild must not stack scrims");
assert_eq!(
app.world_mut()
.query::<&ProfileScrollable>()
.iter(app.world())
.count(),
0,
"profile body must despawn when leaving the tab"
);
assert_eq!(
app.world_mut()
.query::<&StatsScrollable>()
.iter(app.world())
.count(),
1,
"stats body must spawn on the Stats tab"
);
}
}
+12 -12
View File
@@ -1649,62 +1649,62 @@ function __wbg_get_imports() {
return ret; return ret;
}, },
__wbindgen_cast_0000000000000001: function(arg0, arg1) { __wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 62000, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 62028, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000002: function(arg0, arg1) { __wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000003: function(arg0, arg1) { __wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7446, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000004: function(arg0, arg1) { __wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000005: function(arg0, arg1) { __wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000006: function(arg0, arg1) { __wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000007: function(arg0, arg1) { __wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000008: function(arg0, arg1) { __wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000009: function(arg0, arg1) { __wbindgen_cast_0000000000000009: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8);
return ret; return ret;
}, },
__wbindgen_cast_000000000000000a: function(arg0, arg1) { __wbindgen_cast_000000000000000a: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7471, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9);
return ret; return ret;
}, },
__wbindgen_cast_000000000000000b: function(arg0, arg1) { __wbindgen_cast_000000000000000b: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7444, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7472, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1);
return ret; return ret;
}, },
__wbindgen_cast_000000000000000c: function(arg0, arg1) { __wbindgen_cast_000000000000000c: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7445, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7473, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c);
return ret; return ret;
}, },
Binary file not shown.