Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3388169329 | |||
| 0e07e1d1ad | |||
| 83f37bada6 | |||
| d989d07fe7 | |||
| fa54c58bc4 | |||
| dd304913df | |||
| 48ad6b6618 | |||
| 81ac4a5383 | |||
| f51ee7b234 | |||
| d593b61af8 | |||
| fc87b13e5b | |||
| 560470b86b | |||
| 1fded5ff16 | |||
| 65585c61ad | |||
| 0583a8ffae | |||
| 4d9d710a02 | |||
| 3fbee9ce30 | |||
| fd98f46267 | |||
| 07044f439c |
@@ -2,6 +2,19 @@
|
|||||||
# locally, run on every master push and pull request. Until this workflow
|
# locally, run on every master push and pull request. Until this workflow
|
||||||
# existed, nothing in CI ran the test suite at all — a direct push to
|
# existed, nothing in CI ran the test suite at all — a direct push to
|
||||||
# master was entirely unguarded.
|
# master was entirely unguarded.
|
||||||
|
#
|
||||||
|
# Build caching (2026-07-13): the Gitea actions cache never restored on
|
||||||
|
# this instance — every run back through run 597 logged "No cache found"
|
||||||
|
# even for exact keys saved successfully ("Cache saved successfully") by
|
||||||
|
# a run an hour earlier, including master→master restores. Until the
|
||||||
|
# cache server on the runner host is fixed, Swatinem/rust-cache is pure
|
||||||
|
# overhead here. `rust-host` is a HOST executor (its filesystem persists
|
||||||
|
# between runs — ~/.cargo and the rustup toolchain already carry over),
|
||||||
|
# so we get warm builds by pointing CARGO_TARGET_DIR at a persistent
|
||||||
|
# path on the runner instead of tarring gigabytes through a cache API
|
||||||
|
# that never returns them. Concurrent runs are safe: cargo serialises
|
||||||
|
# on the target-dir lock.
|
||||||
|
|
||||||
name: Test
|
name: Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -28,8 +41,27 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Seconds-long formatting gate in its own job so a rustfmt slip fails
|
||||||
|
# here instead of after a 35-minute cold build (run 600 spent its
|
||||||
|
# whole build budget to report an unformatted file).
|
||||||
|
fmt:
|
||||||
|
runs-on: rust-host
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust 1.95.0
|
||||||
|
uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: 1.95.0
|
||||||
|
components: rustfmt
|
||||||
|
|
||||||
|
- name: Format check
|
||||||
|
run: cargo fmt --check
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: rust-host
|
runs-on: rust-host
|
||||||
|
needs: fmt
|
||||||
|
|
||||||
# Full debuginfo made the solitaire_engine test-binary link peak past the
|
# Full debuginfo made the solitaire_engine test-binary link peak past the
|
||||||
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
|
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
|
||||||
@@ -40,10 +72,19 @@ jobs:
|
|||||||
# test binaries concurrently; as the workspace grew (runs 514/516/519)
|
# test binaries concurrently; as the workspace grew (runs 514/516/519)
|
||||||
# two+ simultaneous ld processes OOM-killed the runner again even at
|
# two+ simultaneous ld processes OOM-killed the runner again even at
|
||||||
# line-tables-only. Two jobs keeps at most two links in flight — the
|
# line-tables-only. Two jobs keeps at most two links in flight — the
|
||||||
# compile-throughput cost is small next to the cache-warm build.
|
# compile-throughput cost is small next to the warm build.
|
||||||
|
#
|
||||||
|
# CARGO_INCREMENTAL=0: incremental artifacts bloat the persistent
|
||||||
|
# target dir for little benefit in CI (rust-cache used to set this
|
||||||
|
# for the same reason).
|
||||||
|
#
|
||||||
|
# CARGO_TARGET_DIR: persistent on the runner host — see the header
|
||||||
|
# comment. The prune step below keeps it from growing unbounded.
|
||||||
env:
|
env:
|
||||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||||
CARGO_BUILD_JOBS: '2'
|
CARGO_BUILD_JOBS: '2'
|
||||||
|
CARGO_INCREMENTAL: '0'
|
||||||
|
CARGO_TARGET_DIR: /home/runner/.cache/ferrous-solitaire/target
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -53,10 +94,21 @@ jobs:
|
|||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: 1.95.0
|
toolchain: 1.95.0
|
||||||
components: clippy, rustfmt
|
components: clippy
|
||||||
|
|
||||||
- name: Cache cargo build
|
# Toolchain or lockfile bumps strand stale artifacts nothing will
|
||||||
uses: Swatinem/rust-cache@v2
|
# ever reuse; reset the dir when it crosses 40 GiB rather than
|
||||||
|
# curating it (a cold rebuild every few weeks is cheaper than the
|
||||||
|
# bookkeeping).
|
||||||
|
- name: Prune persistent target dir when oversized
|
||||||
|
run: |
|
||||||
|
limit_kb=$((40 * 1024 * 1024))
|
||||||
|
used_kb=$(du -sk "$CARGO_TARGET_DIR" 2>/dev/null | cut -f1 || echo 0)
|
||||||
|
echo "persistent target dir: $((used_kb / 1024)) MiB (limit $((limit_kb / 1024)) MiB)"
|
||||||
|
if [ "${used_kb:-0}" -gt "$limit_kb" ]; then
|
||||||
|
echo "over limit — clearing for a fresh cold build"
|
||||||
|
rm -rf "$CARGO_TARGET_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
# Native link deps for the Bevy crates (engine/app/web) on a bare
|
# Native link deps for the Bevy crates (engine/app/web) on a bare
|
||||||
# ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit.
|
# ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit.
|
||||||
@@ -67,9 +119,6 @@ jobs:
|
|||||||
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
|
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
|
||||||
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev
|
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev
|
||||||
|
|
||||||
- name: Format check
|
|
||||||
run: cargo fmt --check
|
|
||||||
|
|
||||||
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
|
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
|
||||||
# same as the web-e2e workflow's server prebuild.
|
# same as the web-e2e workflow's server prebuild.
|
||||||
- name: Clippy (deny warnings)
|
- name: Clippy (deny warnings)
|
||||||
|
|||||||
@@ -6,6 +6,51 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.45.0] — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Thumb-reach action bar on touch (menu redesign Phase F).** The bottom
|
||||||
|
bar slims to five buttons: an enlarged **Undo · Draw · Hint** trio
|
||||||
|
(96×64 px targets) between compact Menu and Pause. **Draw is new** — it
|
||||||
|
draws from the stock exactly like tapping the deck, without the reach to
|
||||||
|
the top of a tall folded screen. Help, Modes, and New Game moved off the
|
||||||
|
touch bar (Menu → System, the Home grid, and Home's hero button cover
|
||||||
|
them). Desktop keeps its seven-button bar unchanged.
|
||||||
|
- **Hold-to-repeat Undo.** Press and hold Undo to step back repeatedly
|
||||||
|
(~5.5 undos/s after a short delay) instead of tap-tap-tap. Every step
|
||||||
|
goes through the normal undo path, so scoring penalties apply as usual.
|
||||||
|
|
||||||
|
## [0.44.0] — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Home is now a real home (menu redesign Phase B).** The mode picker became
|
||||||
|
a hierarchy: a **Continue** card (mode · elapsed · score) appears while a
|
||||||
|
game is in progress and returns to the table; a hero **New Game** button
|
||||||
|
replays your last mode with the current deal options in one tap; deal
|
||||||
|
options (draw 1/3, a new **winnable-only** toggle, difficulty tiers) moved
|
||||||
|
into a disclosure under the hero; the six modes sit in a compact symmetric
|
||||||
|
2×3 grid with descriptions on wide screens; and the stats strip moved to
|
||||||
|
the bottom. Wide viewports (desktop, unfolded foldables) get a two-pane
|
||||||
|
layout — launch surfaces left, Continue/daily/stats right. Cancel is now
|
||||||
|
**Back to table** and only appears while a live game exists. (#175)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Save files are self-contained (schema v6).** Like replays in 0.43.3, a
|
||||||
|
saved game now stores the dealt board via the upstream session serializers
|
||||||
|
instead of re-dealing from the seed on load; v4/v5 saves still load through
|
||||||
|
the legacy path and rewrite as v6 on next save. (#171)
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- **CI is ~9× faster.** Root-caused why the actions cache never restored on
|
||||||
|
the host-executor runner (per-run workdir paths poisoned the cache version
|
||||||
|
hash); the test workflow now uses a persistent on-runner target dir, and a
|
||||||
|
seconds-long `fmt` gate fails formatting mistakes before the build. Warm
|
||||||
|
full-gate runs: 4m29s, down from ~40 min. (#176)
|
||||||
|
|
||||||
## [0.43.3] — 2026-07-10
|
## [0.43.3] — 2026-07-10
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
# Menu UX Redesign — July 2026
|
# Menu UX Redesign — July 2026
|
||||||
|
|
||||||
Status: PLANNING. Visual identity (Terminal / base16-eighties) is settled and
|
Status: IN PROGRESS. A / E / C / G shipped in v0.43.0; B implemented on
|
||||||
out of scope — this is about **structure and interaction**, not colors or type.
|
`feat/home-hierarchy` (2026-07-13); F / H and the I–M backlog remain open.
|
||||||
|
Visual identity (Terminal / base16-eighties) is settled and out of
|
||||||
|
scope — this is about **structure and interaction**, not colors or type.
|
||||||
|
|
||||||
## Diagnosis (from code survey, 2026-07-07)
|
## Diagnosis (from code survey, 2026-07-07)
|
||||||
|
|
||||||
@@ -197,8 +199,13 @@ slimming), then **B**, then **F/G/H** as independent follow-ups.
|
|||||||
## Open decisions
|
## Open decisions
|
||||||
|
|
||||||
1. ~~Settings: tabs vs. sub-pages~~ — **DECIDED 2026-07-07: tabs.**
|
1. ~~Settings: tabs vs. sub-pages~~ — **DECIDED 2026-07-07: tabs.**
|
||||||
2. Deal options: disclosure on Classic card (proposed) vs. keep global row?
|
2. ~~Deal options: disclosure on Classic card vs. keep global row~~ —
|
||||||
3. Time Attack + Seed: top-level cards (proposed, grid stays symmetric) vs.
|
**DECIDED 2026-07-09: disclosure on the Classic card / New Game hero.**
|
||||||
tucked under a "More" card?
|
3. ~~Time Attack + Seed: top-level cards vs. "More" card~~ —
|
||||||
4. Stitch mockups for Phase B, or iterate directly in-engine?
|
**DECIDED 2026-07-09: top-level cards, symmetric 2×3 grid.**
|
||||||
5. Phase F bottom bar: touch-only (proposed) or also desktop?
|
4. ~~Stitch mockups for Phase B, or iterate directly in-engine~~ —
|
||||||
|
**DECIDED 2026-07-09: directly in-engine.**
|
||||||
|
5. ~~Phase F bottom bar: touch-only vs. also desktop~~ —
|
||||||
|
**DECIDED 2026-07-13: touch-only.** Desktop keeps the current top
|
||||||
|
band; the bar is additive per §3.3 so a later desktop rollout stays
|
||||||
|
a settings flag away.
|
||||||
|
|||||||
@@ -22,11 +22,17 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|||||||
/// indices for enum variants. No longer loadable — v3 files are discarded.
|
/// indices for enum variants. No longer loadable — v3 files are discarded.
|
||||||
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
|
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
|
||||||
/// variants (e.g. `"Foundation1"` instead of `0`).
|
/// variants (e.g. `"Foundation1"` instead of `0`).
|
||||||
/// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer
|
/// - v5: `score`, `undo_count`, and `recycle_count` are no longer
|
||||||
/// persisted. They are derived from the upstream `card_game`/`klondike` session
|
/// persisted. They are derived from the upstream `card_game`/`klondike` session
|
||||||
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
|
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
|
||||||
/// still carry those keys load fine — the extra fields are ignored.
|
/// still carry those keys load fine — the extra fields are ignored.
|
||||||
pub const GAME_STATE_SCHEMA_VERSION: u32 = 5;
|
/// - v6 (current): `saved_moves` replaced by `recording`, the upstream
|
||||||
|
/// `card_game` session serialisation carrying the dealt board explicitly.
|
||||||
|
/// Loading no longer re-deals from `seed`, so in-progress saves survive
|
||||||
|
/// RNG/upstream upgrades that change the seed→deal mapping (the failure
|
||||||
|
/// class that PR #170 fixed for replays). v4/v5 files still load through
|
||||||
|
/// the legacy seed path and are rewritten as v6 on next save.
|
||||||
|
pub const GAME_STATE_SCHEMA_VERSION: u32 = 6;
|
||||||
|
|
||||||
/// Default move budget for a solvability check. Matches the winnable-deal retry
|
/// Default move budget for a solvability check. Matches the winnable-deal retry
|
||||||
/// loop in the engine.
|
/// loop in the engine.
|
||||||
@@ -95,8 +101,8 @@ pub enum GameMode {
|
|||||||
Difficulty(DifficultyLevel),
|
Difficulty(DifficultyLevel),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Output struct for schema v4 serialisation. `saved_moves` uses upstream
|
/// Output struct for schema v6 serialisation. `recording` is the upstream
|
||||||
/// `KlondikeInstruction` serde, which produces named enum variants.
|
/// session serialisation (config + dealt board + instruction list).
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
struct PersistedGameState {
|
struct PersistedGameState {
|
||||||
pub draw_mode: DrawStockConfig,
|
pub draw_mode: DrawStockConfig,
|
||||||
@@ -105,15 +111,16 @@ struct PersistedGameState {
|
|||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub saved_moves: Vec<KlondikeInstruction>,
|
pub recording: SessionRecording,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Input struct that accepts schema v4 and v5 `saved_moves` formats.
|
/// Input struct that accepts schema v4, v5, and v6 save formats.
|
||||||
///
|
///
|
||||||
/// `saved_moves` is deserialised directly as upstream `KlondikeInstruction`
|
/// v6 files carry `recording` (the upstream session serialisation, deal
|
||||||
/// (named-variant serde). `score`, `undo_count`, and `recycle_count` are
|
/// included); v4/v5 files carry `saved_moves` and rebuild the deal from
|
||||||
/// intentionally absent: all three are rebuilt by replaying the instruction
|
/// `seed`. `score`, `undo_count`, and `recycle_count` are intentionally
|
||||||
/// history through the upstream session stats. Older v4 save files still carry
|
/// absent: all three are rebuilt by replaying the instruction history
|
||||||
|
/// through the upstream session stats. Older v4 save files still carry
|
||||||
/// those keys; serde ignores them.
|
/// those keys; serde ignores them.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
struct PersistedGameStateIn {
|
struct PersistedGameStateIn {
|
||||||
@@ -126,7 +133,12 @@ struct PersistedGameStateIn {
|
|||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
#[serde(default = "schema_v1")]
|
#[serde(default = "schema_v1")]
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
|
/// v4/v5 only. Replayed against a seed-dealt board.
|
||||||
|
#[serde(default)]
|
||||||
pub saved_moves: Vec<KlondikeInstruction>,
|
pub saved_moves: Vec<KlondikeInstruction>,
|
||||||
|
/// v6 only. Carries the dealt board explicitly.
|
||||||
|
#[serde(default)]
|
||||||
|
pub recording: Option<SessionRecording>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
@@ -222,7 +234,7 @@ impl Serialize for GameState {
|
|||||||
seed: self.seed,
|
seed: self.seed,
|
||||||
take_from_foundation: self.take_from_foundation,
|
take_from_foundation: self.take_from_foundation,
|
||||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||||
saved_moves: self.saved_moves(),
|
recording: self.recording(),
|
||||||
}
|
}
|
||||||
.serialize(serializer)
|
.serialize(serializer)
|
||||||
}
|
}
|
||||||
@@ -232,34 +244,51 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
||||||
|
|
||||||
// Accept v4 (upstream named-variant serde) and v5 (current, derived
|
// Accept v6 (current, recording-based), plus v4/v5 (legacy seed-dealt
|
||||||
// stats). v3 (legacy u8-index format) and all others are rejected.
|
// saves — loadable while the seed→deal mapping they were written
|
||||||
match persisted.schema_version {
|
// under still holds; rewritten as v6 on the next save). v3 (legacy
|
||||||
4 | 5 => {}
|
// u8-index format) and all others are rejected.
|
||||||
|
let (mut game, instructions) = match persisted.schema_version {
|
||||||
|
6 => {
|
||||||
|
let Some(recording) = persisted.recording else {
|
||||||
|
return Err(serde::de::Error::custom(
|
||||||
|
"v6 save file is missing the session recording",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
// The dealt board and session config come from the recording
|
||||||
|
// itself; `seed` is presentation metadata only.
|
||||||
|
Self::from_recording(&recording, persisted.seed, persisted.mode)
|
||||||
|
}
|
||||||
|
4 | 5 => (
|
||||||
|
Self {
|
||||||
|
mode: persisted.mode,
|
||||||
|
elapsed_seconds: 0,
|
||||||
|
seed: persisted.seed,
|
||||||
|
take_from_foundation: true,
|
||||||
|
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
test_pile_state: None,
|
||||||
|
},
|
||||||
|
persisted.saved_moves,
|
||||||
|
),
|
||||||
v => {
|
v => {
|
||||||
return Err(serde::de::Error::custom(format!(
|
return Err(serde::de::Error::custom(format!(
|
||||||
"unsupported GameState schema version {v}"
|
"unsupported GameState schema version {v}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let mut game = Self {
|
|
||||||
mode: persisted.mode,
|
|
||||||
elapsed_seconds: persisted.elapsed_seconds,
|
|
||||||
seed: persisted.seed,
|
|
||||||
take_from_foundation: persisted.take_from_foundation,
|
|
||||||
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
|
||||||
#[cfg(feature = "test-support")]
|
|
||||||
test_pile_state: None,
|
|
||||||
};
|
};
|
||||||
|
game.mode = persisted.mode;
|
||||||
|
game.elapsed_seconds = persisted.elapsed_seconds;
|
||||||
|
game.take_from_foundation = persisted.take_from_foundation;
|
||||||
|
|
||||||
// Replay the saved instruction history. The upstream session tracks
|
// Replay the saved instruction history with validation — a tampered
|
||||||
// score components and recycle_count as it processes each move, so the
|
// or corrupt file must surface an error, not a silently wrong board.
|
||||||
// derived stats are correct once replay completes. `undo_count()` resets
|
// The upstream session tracks score components and recycle_count as
|
||||||
// to 0 across save/load because undone moves are not part of the saved
|
// it processes each move, so the derived stats are correct once
|
||||||
// forward history.
|
// replay completes. `undo_count()` resets to 0 across save/load
|
||||||
|
// because undone moves are not part of the saved forward history.
|
||||||
let replay_config = Self::replay_config(persisted.draw_mode);
|
let replay_config = Self::replay_config(persisted.draw_mode);
|
||||||
for instruction in persisted.saved_moves {
|
for instruction in instructions {
|
||||||
if !game
|
if !game
|
||||||
.session
|
.session
|
||||||
.state()
|
.state()
|
||||||
@@ -461,32 +490,6 @@ impl GameState {
|
|||||||
self.session.stats().stats().recycle_count()
|
self.session.stats().stats().recycle_count()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Number of cards moved onto foundations this game, read from the
|
|
||||||
/// upstream session stats. Cumulative like [`Self::recycle_count`] —
|
|
||||||
/// not rolled back on undo.
|
|
||||||
pub fn move_to_foundation_count(&self) -> u32 {
|
|
||||||
self.session.stats().stats().move_to_foundation_count()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of stacks moved onto tableaus (from stock or another tableau)
|
|
||||||
/// this game, read from the upstream session stats. Cumulative — not
|
|
||||||
/// rolled back on undo.
|
|
||||||
pub fn move_to_tableau_count(&self) -> u32 {
|
|
||||||
self.session.stats().stats().move_to_tableau_count()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of cards taken back off a foundation this game, read from the
|
|
||||||
/// upstream session stats. Cumulative — not rolled back on undo.
|
|
||||||
pub fn move_from_foundation_count(&self) -> u32 {
|
|
||||||
self.session.stats().stats().move_from_foundation_count()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of face-down cards revealed (flipped up) this game, read from
|
|
||||||
/// the upstream session stats. Cumulative — not rolled back on undo.
|
|
||||||
pub fn flip_up_count(&self) -> u32 {
|
|
||||||
self.session.stats().stats().flip_up_bonus_count()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Total moves made this game (draws, recycles, and card moves), derived
|
/// Total moves made this game (draws, recycles, and card moves), derived
|
||||||
/// from the session's instruction history length.
|
/// from the session's instruction history length.
|
||||||
pub fn move_count(&self) -> u32 {
|
pub fn move_count(&self) -> u32 {
|
||||||
@@ -550,16 +553,6 @@ impl GameState {
|
|||||||
KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation)
|
KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collects the session instruction history as upstream types for schema v4
|
|
||||||
/// serialisation.
|
|
||||||
fn saved_moves(&self) -> Vec<KlondikeInstruction> {
|
|
||||||
self.session
|
|
||||||
.history()
|
|
||||||
.iter()
|
|
||||||
.map(|snapshot| *snapshot.instruction())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the deterministic instruction history for the current deal as
|
/// Returns the deterministic instruction history for the current deal as
|
||||||
/// upstream [`KlondikeInstruction`] values.
|
/// upstream [`KlondikeInstruction`] values.
|
||||||
///
|
///
|
||||||
@@ -1723,6 +1716,91 @@ mod tests {
|
|||||||
assert_eq!(replayed.move_count(), game.move_count());
|
assert_eq!(replayed.move_count(), game.move_count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v6 save files carry the deal in the recording; the `seed` field is
|
||||||
|
/// presentation metadata. Corrupting it must not change the loaded board.
|
||||||
|
#[test]
|
||||||
|
fn v6_save_load_ignores_seed_for_dealing() {
|
||||||
|
let game = game_with_some_moves(51);
|
||||||
|
let mut value = serde_json::to_value(&game).expect("serialise");
|
||||||
|
assert_eq!(value["schema_version"], 6);
|
||||||
|
value["seed"] = serde_json::Value::from(0xDEAD_BEEF_u64);
|
||||||
|
let loaded: GameState =
|
||||||
|
serde_json::from_value(value).expect("v6 save with wrong seed must still load");
|
||||||
|
assert_eq!(loaded.stock_cards(), game.stock_cards());
|
||||||
|
assert_eq!(loaded.waste_cards(), game.waste_cards());
|
||||||
|
for index in 0..7 {
|
||||||
|
let t = GameState::tableau_from_index(index).expect("tableau index");
|
||||||
|
assert_eq!(
|
||||||
|
loaded.pile(KlondikePile::Tableau(t)),
|
||||||
|
game.pile(KlondikePile::Tableau(t)),
|
||||||
|
"tableau {index} differs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(loaded.move_count(), game.move_count());
|
||||||
|
assert_eq!(loaded.score(), game.score());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy v5 files (seed + saved_moves, no recording) must still load
|
||||||
|
/// through the seed-dealt path until players resave as v6.
|
||||||
|
#[test]
|
||||||
|
fn v5_legacy_save_still_loads() {
|
||||||
|
let game = game_with_some_moves(145);
|
||||||
|
let v5 = serde_json::json!({
|
||||||
|
"draw_mode": game.draw_mode(),
|
||||||
|
"mode": game.mode,
|
||||||
|
"elapsed_seconds": 12,
|
||||||
|
"seed": game.seed,
|
||||||
|
"take_from_foundation": true,
|
||||||
|
"schema_version": 5,
|
||||||
|
"saved_moves": game.instruction_history(),
|
||||||
|
});
|
||||||
|
let loaded: GameState =
|
||||||
|
serde_json::from_value(v5).expect("v5 save must load via the legacy seed path");
|
||||||
|
assert_eq!(loaded.move_count(), game.move_count());
|
||||||
|
assert_eq!(loaded.stock_cards(), game.stock_cards());
|
||||||
|
assert_eq!(loaded.elapsed_seconds, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A v6 file without the recording payload is malformed and must be
|
||||||
|
/// rejected with an error rather than silently re-dealt from the seed.
|
||||||
|
#[test]
|
||||||
|
fn v6_save_without_recording_is_rejected() {
|
||||||
|
let game = GameState::new(7, DrawStockConfig::DrawOne);
|
||||||
|
let mut value = serde_json::to_value(&game).expect("serialise");
|
||||||
|
value.as_object_mut().expect("object").remove("recording");
|
||||||
|
let err = serde_json::from_value::<GameState>(value)
|
||||||
|
.expect_err("v6 save without recording must fail");
|
||||||
|
assert!(err.to_string().contains("recording"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A recording whose instruction list is invalid for its own deal must
|
||||||
|
/// be rejected on load — tampered or corrupt files surface an error,
|
||||||
|
/// never a silently wrong board.
|
||||||
|
#[test]
|
||||||
|
fn v6_save_with_invalid_history_is_rejected() {
|
||||||
|
use klondike::{DstFoundation, Foundation, KlondikePile};
|
||||||
|
// A foundation move from an empty tableau is never legal on a fresh
|
||||||
|
// deal's first instruction slot for this source shape.
|
||||||
|
let bogus = KlondikeInstruction::DstFoundation(DstFoundation {
|
||||||
|
src: KlondikePile::Foundation(Foundation::Foundation1),
|
||||||
|
foundation: Foundation::Foundation2,
|
||||||
|
});
|
||||||
|
let recording =
|
||||||
|
SessionRecording::from_instructions_unchecked(7, DrawStockConfig::DrawOne, [bogus]);
|
||||||
|
let v6 = serde_json::json!({
|
||||||
|
"draw_mode": DrawStockConfig::DrawOne,
|
||||||
|
"mode": GameMode::Classic,
|
||||||
|
"elapsed_seconds": 0,
|
||||||
|
"seed": 7,
|
||||||
|
"take_from_foundation": true,
|
||||||
|
"schema_version": 6,
|
||||||
|
"recording": recording,
|
||||||
|
});
|
||||||
|
let err =
|
||||||
|
serde_json::from_value::<GameState>(v6).expect_err("invalid history must be rejected");
|
||||||
|
assert!(err.to_string().contains("invalid"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_recording_of_fresh_deal_returns_empty_instructions() {
|
fn from_recording_of_fresh_deal_returns_empty_instructions() {
|
||||||
let game = GameState::new(7, DrawStockConfig::DrawThree);
|
let game = GameState::new(7, DrawStockConfig::DrawThree);
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ use std::io;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
|
use solitaire_core::{
|
||||||
|
DrawStockConfig,
|
||||||
|
game_state::{DifficultyLevel, GameMode},
|
||||||
|
};
|
||||||
|
|
||||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||||
|
|
||||||
@@ -248,6 +251,12 @@ pub struct Settings {
|
|||||||
/// cleanly to `None` via `#[serde(default)]`.
|
/// cleanly to `None` via `#[serde(default)]`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_difficulty: Option<DifficultyLevel>,
|
pub last_difficulty: Option<DifficultyLevel>,
|
||||||
|
/// Mode of the last game the player launched from the home overlay.
|
||||||
|
/// The home hero "New Game" button replays this mode with one tap.
|
||||||
|
/// Older `settings.json` files written before this field existed
|
||||||
|
/// deserialize cleanly to `GameMode::Classic` via `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_mode: GameMode,
|
||||||
/// Custom public name displayed on the leaderboard. When `None`, the
|
/// Custom public name displayed on the leaderboard. When `None`, the
|
||||||
/// player's server `username` is used instead. Trimmed to 32 characters
|
/// player's server `username` is used instead. Trimmed to 32 characters
|
||||||
/// before submission. Older `settings.json` files written before this
|
/// before submission. Older `settings.json` files written before this
|
||||||
@@ -415,6 +424,7 @@ impl Default for Settings {
|
|||||||
disable_smart_default_size: false,
|
disable_smart_default_size: false,
|
||||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||||
last_difficulty: None,
|
last_difficulty: None,
|
||||||
|
last_mode: GameMode::Classic,
|
||||||
leaderboard_display_name: None,
|
leaderboard_display_name: None,
|
||||||
leaderboard_opted_in: false,
|
leaderboard_opted_in: false,
|
||||||
take_from_foundation: true,
|
take_from_foundation: true,
|
||||||
|
|||||||
@@ -477,7 +477,7 @@ mod tests {
|
|||||||
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets
|
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets
|
||||||
/// to 0 on load because only the forward instruction history is persisted.
|
/// to 0 on load because only the forward instruction history is persisted.
|
||||||
#[test]
|
#[test]
|
||||||
fn game_state_v5_mid_game_round_trip() {
|
fn game_state_v6_mid_game_round_trip() {
|
||||||
use solitaire_core::KlondikeInstruction;
|
use solitaire_core::KlondikeInstruction;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
@@ -518,11 +518,16 @@ mod tests {
|
|||||||
|
|
||||||
save_game_state_to(&path, &gs).expect("save");
|
save_game_state_to(&path, &gs).expect("save");
|
||||||
|
|
||||||
// Verify the file carries the v5 schema marker.
|
// Verify the file carries the v6 schema marker and the recording.
|
||||||
let json = fs::read_to_string(&path).expect("read json");
|
let json = fs::read_to_string(&path).expect("read json");
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse saved json");
|
||||||
|
assert_eq!(
|
||||||
|
parsed["schema_version"], 6,
|
||||||
|
"saved file must use schema version 6",
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
json.contains("\"schema_version\"") && json.contains('5'),
|
parsed["recording"].is_object(),
|
||||||
"saved file must use schema version 5",
|
"saved file must embed the session recording",
|
||||||
);
|
);
|
||||||
|
|
||||||
let loaded =
|
let loaded =
|
||||||
@@ -530,7 +535,7 @@ mod tests {
|
|||||||
|
|
||||||
// The forward instruction history round-trips, so the reconstructed board
|
// The forward instruction history round-trips, so the reconstructed board
|
||||||
// re-serialises to byte-identical JSON.
|
// re-serialises to byte-identical JSON.
|
||||||
let path_reload = gs_path("v5_mid_game_reload");
|
let path_reload = gs_path("v6_mid_game_reload");
|
||||||
let _ = fs::remove_file(&path_reload);
|
let _ = fs::remove_file(&path_reload);
|
||||||
save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
|
save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ use crate::events::{
|
|||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
|
use crate::platform::USE_TOUCH_UI_LAYOUT;
|
||||||
use crate::progress_plugin::LevelUpEvent;
|
use crate::progress_plugin::LevelUpEvent;
|
||||||
|
use crate::safe_area::SafeAreaAnchoredBottom;
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
@@ -160,6 +162,68 @@ pub struct ActiveToast {
|
|||||||
/// Duration of each queued info-toast in seconds.
|
/// Duration of each queued info-toast in seconds.
|
||||||
const QUEUED_TOAST_SECS: f32 = 2.5;
|
const QUEUED_TOAST_SECS: f32 = 2.5;
|
||||||
|
|
||||||
|
/// Marker on the persistent bottom-anchored flex column every toast
|
||||||
|
/// spawns into (Phase H). Stacking through one container gives queued
|
||||||
|
/// and immediate toasts a single shared anchor — simultaneous toasts
|
||||||
|
/// stack upward instead of relying on the old staggered-percentage
|
||||||
|
/// anchors to dodge each other.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ToastStackRoot;
|
||||||
|
|
||||||
|
/// Marker on every toast card node (both paths). Freshly spawned toasts
|
||||||
|
/// start `Visibility::Hidden` and unparented; [`adopt_toasts_into_stack`]
|
||||||
|
/// re-parents them under [`ToastStackRoot`] and reveals them — one frame
|
||||||
|
/// of latency, imperceptible at toast timescales, in exchange for the 14
|
||||||
|
/// toast handlers keeping their `Commands`-only signatures.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ToastNode;
|
||||||
|
|
||||||
|
/// Logical-pixel gap between the screen bottom and the toast stack,
|
||||||
|
/// before safe-area insets. Clears the Phase F bottom action bar on
|
||||||
|
/// touch (compact 44px buttons + primary 64px trio + bar padding);
|
||||||
|
/// desktop's shorter bar needs less.
|
||||||
|
const TOAST_STACK_BASE_BOTTOM_PX: f32 = if USE_TOUCH_UI_LAYOUT { 112.0 } else { 72.0 };
|
||||||
|
|
||||||
|
/// Spawns the persistent [`ToastStackRoot`] container at startup.
|
||||||
|
fn spawn_toast_stack_root(mut commands: Commands) {
|
||||||
|
commands.spawn((
|
||||||
|
ToastStackRoot,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
bottom: Val::Px(TOAST_STACK_BASE_BOTTOM_PX),
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
// Newest toast sits nearest the bottom edge; older ones
|
||||||
|
// push upward.
|
||||||
|
flex_direction: FlexDirection::ColumnReverse,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
SafeAreaAnchoredBottom {
|
||||||
|
base_bottom: TOAST_STACK_BASE_BOTTOM_PX,
|
||||||
|
},
|
||||||
|
ZIndex(Z_TOAST),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-parents freshly spawned [`ToastNode`]s under the stack root and
|
||||||
|
/// reveals them. No-op when every toast is already adopted.
|
||||||
|
fn adopt_toasts_into_stack(
|
||||||
|
mut commands: Commands,
|
||||||
|
orphans: Query<Entity, (With<ToastNode>, Without<ChildOf>)>,
|
||||||
|
root: Query<Entity, With<ToastStackRoot>>,
|
||||||
|
) {
|
||||||
|
let Ok(root) = root.single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for toast in &orphans {
|
||||||
|
commands
|
||||||
|
.entity(toast)
|
||||||
|
.insert((ChildOf(root), Visibility::Inherited));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
|
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
|
||||||
pub struct AnimationPlugin;
|
pub struct AnimationPlugin;
|
||||||
|
|
||||||
@@ -185,7 +249,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
.init_resource::<EffectiveSlideDuration>()
|
.init_resource::<EffectiveSlideDuration>()
|
||||||
.init_resource::<ToastQueue>()
|
.init_resource::<ToastQueue>()
|
||||||
.init_resource::<ActiveToast>()
|
.init_resource::<ActiveToast>()
|
||||||
.add_systems(Startup, init_slide_duration)
|
.add_systems(Startup, (init_slide_duration, spawn_toast_stack_root))
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -206,6 +270,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
handle_warning_toast,
|
handle_warning_toast,
|
||||||
tick_toasts,
|
tick_toasts,
|
||||||
(enqueue_toasts, drive_toast_display).chain(),
|
(enqueue_toasts, drive_toast_display).chain(),
|
||||||
|
adopt_toasts_into_stack,
|
||||||
)
|
)
|
||||||
.after(GameMutation),
|
.after(GameMutation),
|
||||||
);
|
);
|
||||||
@@ -637,26 +702,14 @@ impl ToastVariant {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawns a bottom-anchored `ToastEntity` for the queued toast system.
|
/// Spawns a `ToastEntity` for the queued toast system.
|
||||||
///
|
///
|
||||||
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
|
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
|
||||||
/// by [`InfoToastEvent`] which is by definition neutral system info.
|
/// by [`InfoToastEvent`] which is by definition neutral system info.
|
||||||
/// Variants other than `Info` belong on the immediate-fire path
|
/// Variants other than `Info` belong on the immediate-fire path
|
||||||
/// ([`spawn_toast`]) where the call site knows the semantic intent.
|
/// ([`spawn_toast`]) where the call site knows the semantic intent.
|
||||||
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
||||||
spawn_toast_node(
|
spawn_toast_node(commands, ToastEntity, message, ToastVariant::Info)
|
||||||
commands,
|
|
||||||
ToastEntity,
|
|
||||||
message,
|
|
||||||
ToastVariant::Info,
|
|
||||||
// Slightly taller anchor than the immediate-fire path so a
|
|
||||||
// queued info banner doesn't collide with a celebration toast
|
|
||||||
// fired in the same frame.
|
|
||||||
Val::Percent(6.0),
|
|
||||||
Val::Percent(15.0),
|
|
||||||
Val::Percent(70.0),
|
|
||||||
UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
|
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
|
||||||
@@ -744,12 +797,6 @@ fn spawn_toast(
|
|||||||
(ToastOverlay, ToastTimer(duration_secs)),
|
(ToastOverlay, ToastTimer(duration_secs)),
|
||||||
message,
|
message,
|
||||||
variant,
|
variant,
|
||||||
// Sits above the queued banner so a celebration toast spawned
|
|
||||||
// alongside a queued info message remains readable.
|
|
||||||
Val::Percent(14.0),
|
|
||||||
Val::Percent(25.0),
|
|
||||||
Val::Percent(50.0),
|
|
||||||
UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,31 +813,25 @@ fn spawn_toast(
|
|||||||
/// rungs; 18 is the closest rung that preserves the scale invariants
|
/// rungs; 18 is the closest rung that preserves the scale invariants
|
||||||
/// tested in `ui_theme::tests`.
|
/// tested in `ui_theme::tests`.
|
||||||
/// - [`RADIUS_MD`] corners.
|
/// - [`RADIUS_MD`] corners.
|
||||||
/// - Bottom-anchored absolute position; `bottom_pct` differs between
|
///
|
||||||
/// queued and immediate paths so they layer instead of overlap.
|
/// Layout is owned by [`ToastStackRoot`] (Phase H): the node spawns
|
||||||
// The 8-argument signature is intentional — these are the per-toast
|
/// hidden and unpositioned, and [`adopt_toasts_into_stack`] slots it
|
||||||
// layout values that genuinely differ between the queued and fire-and-
|
/// into the shared bottom-anchored column — one anchor for every toast,
|
||||||
// forget call sites. A struct wrapper would just rename the same data.
|
/// simultaneous toasts stack instead of overlapping.
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn spawn_toast_node<B: Bundle>(
|
fn spawn_toast_node<B: Bundle>(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
bundle: B,
|
bundle: B,
|
||||||
message: String,
|
message: String,
|
||||||
variant: ToastVariant,
|
variant: ToastVariant,
|
||||||
bottom_pct: Val,
|
|
||||||
left_pct: Val,
|
|
||||||
width_pct: Val,
|
|
||||||
padding: UiRect,
|
|
||||||
) -> Entity {
|
) -> Entity {
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
bundle,
|
bundle,
|
||||||
|
ToastNode,
|
||||||
|
Visibility::Hidden,
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
max_width: Val::Percent(70.0),
|
||||||
left: left_pct,
|
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
||||||
bottom: bottom_pct,
|
|
||||||
width: width_pct,
|
|
||||||
padding,
|
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
@@ -799,7 +840,6 @@ fn spawn_toast_node<B: Bundle>(
|
|||||||
},
|
},
|
||||||
BackgroundColor(BG_ELEVATED),
|
BackgroundColor(BG_ELEVATED),
|
||||||
BorderColor::all(variant.border_color()),
|
BorderColor::all(variant.border_color()),
|
||||||
ZIndex(Z_TOAST),
|
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
@@ -1315,4 +1355,85 @@ mod tests {
|
|||||||
fn cascade_duration_instant_is_zero() {
|
fn cascade_duration_instant_is_zero() {
|
||||||
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0);
|
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase H: unified toast stack
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Both toast paths must end up as visible children of the single
|
||||||
|
/// [`ToastStackRoot`] — the Phase H "one anchor" contract.
|
||||||
|
#[test]
|
||||||
|
fn queued_and_immediate_toasts_stack_under_one_root() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
app.update(); // Startup: spawns the stack root.
|
||||||
|
|
||||||
|
// One immediate (error) toast + one queued (info) toast in the
|
||||||
|
// same frame — the exact collision case the old staggered
|
||||||
|
// anchors existed to dodge.
|
||||||
|
use solitaire_core::{KlondikePile, Tableau};
|
||||||
|
app.world_mut().write_message(MoveRejectedEvent {
|
||||||
|
from: KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
|
to: KlondikePile::Tableau(Tableau::Tableau2),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(InfoToastEvent("stacked info".to_string()));
|
||||||
|
app.update(); // handlers spawn both toasts (hidden, unparented)
|
||||||
|
app.update(); // adopt_toasts_into_stack re-parents + reveals
|
||||||
|
|
||||||
|
let root = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<Entity, With<ToastStackRoot>>()
|
||||||
|
.single(app.world())
|
||||||
|
.expect("exactly one ToastStackRoot must exist");
|
||||||
|
|
||||||
|
let toasts: Vec<(Entity, &ChildOf, &Visibility)> = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<(Entity, &ChildOf, &Visibility), With<ToastNode>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
toasts.len(),
|
||||||
|
2,
|
||||||
|
"both the immediate and the queued toast must be adopted"
|
||||||
|
);
|
||||||
|
for (entity, child_of, visibility) in toasts {
|
||||||
|
assert_eq!(
|
||||||
|
child_of.parent(),
|
||||||
|
root,
|
||||||
|
"toast {entity} must be a child of the shared stack root"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*visibility,
|
||||||
|
Visibility::Inherited,
|
||||||
|
"adopted toast {entity} must be revealed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toast nodes must not carry their own absolute positioning — the
|
||||||
|
/// stack root owns layout (regression guard against reintroducing
|
||||||
|
/// per-path anchors).
|
||||||
|
#[test]
|
||||||
|
fn toast_nodes_have_no_absolute_position() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(WarningToastEvent("layout check".to_string()));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let node = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Node, With<ToastNode>>()
|
||||||
|
.single(app.world())
|
||||||
|
.expect("warning toast must spawn a ToastNode");
|
||||||
|
assert_eq!(
|
||||||
|
node.position_type,
|
||||||
|
PositionType::Relative,
|
||||||
|
"toast nodes are flex children of the stack, not absolute overlays"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,14 @@ pub struct NewGameRequestWriters;
|
|||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct UndoRequestWriters;
|
pub struct UndoRequestWriters;
|
||||||
|
|
||||||
|
/// Self-ambiguous set for writers of `DrawRequestEvent` — same rationale as
|
||||||
|
/// [`NewGameRequestWriters`]: consumers drain the queue, append order is
|
||||||
|
/// meaningless (#143). Members: keyboard `D`/`Space`, stock click, touch
|
||||||
|
/// stock tap (input_plugin), and the touch action bar's Draw button
|
||||||
|
/// (hud_plugin, Phase F).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct DrawRequestWriters;
|
||||||
|
|
||||||
/// Self-ambiguous set for writers of `InfoToastEvent` — toasts queue in
|
/// Self-ambiguous set for writers of `InfoToastEvent` — toasts queue in
|
||||||
/// arrival order and any same-frame order is fine (#143).
|
/// arrival order and any same-frame order is fine (#143).
|
||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
|||||||
+1130
-307
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,86 @@ pub(super) fn handle_undo_button(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Seconds the Undo button must be continuously held before hold-to-repeat
|
||||||
|
/// kicks in. Long enough that a normal tap (press + release inside one or
|
||||||
|
/// two frames) never triggers a second undo.
|
||||||
|
const UNDO_HOLD_INITIAL_DELAY_SECS: f32 = 0.45;
|
||||||
|
|
||||||
|
/// Interval between repeated undos while the hold continues. ~5.5 undos/s —
|
||||||
|
/// fast enough to unwind a long line, slow enough to release in time when
|
||||||
|
/// the board reaches the state the player wants.
|
||||||
|
const UNDO_HOLD_REPEAT_INTERVAL_SECS: f32 = 0.18;
|
||||||
|
|
||||||
|
/// Hold-to-repeat undo (Phase F): while the Undo button stays pressed,
|
||||||
|
/// fire additional [`UndoRequestEvent`]s after an initial delay, one per
|
||||||
|
/// repeat interval. The plain tap path stays in [`handle_undo_button`]
|
||||||
|
/// (its `Changed<Interaction>` filter fires exactly once per press);
|
||||||
|
/// this system only adds events once the hold outlives the delay, so a
|
||||||
|
/// tap never double-undoes.
|
||||||
|
///
|
||||||
|
/// Each repeat goes through the normal request queue — the scoring
|
||||||
|
/// penalty and No-Undo-mode gating in the consumer apply to every step.
|
||||||
|
pub(super) fn repeat_undo_on_hold(
|
||||||
|
time: Res<Time>,
|
||||||
|
buttons: Query<&Interaction, With<UndoButton>>,
|
||||||
|
mut state: ResMut<UndoHoldState>,
|
||||||
|
mut undo: MessageWriter<UndoRequestEvent>,
|
||||||
|
) {
|
||||||
|
let held = buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
if !held {
|
||||||
|
if state.held_secs != 0.0 {
|
||||||
|
state.held_secs = 0.0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let before = state.held_secs;
|
||||||
|
state.held_secs += time.delta_secs();
|
||||||
|
if undo_hold_crossed_fire_boundary(before, state.held_secs) {
|
||||||
|
undo.write(UndoRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` when a hold that lasted `before` seconds at the previous frame
|
||||||
|
/// and `now` seconds this frame should fire a repeat undo: once when the
|
||||||
|
/// hold first outlives the initial delay, then once per repeat-interval
|
||||||
|
/// boundary. Pure so the timing contract is unit-testable without
|
||||||
|
/// fighting `Time`.
|
||||||
|
pub(super) fn undo_hold_crossed_fire_boundary(before: f32, now: f32) -> bool {
|
||||||
|
if now < UNDO_HOLD_INITIAL_DELAY_SECS {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if before < UNDO_HOLD_INITIAL_DELAY_SECS {
|
||||||
|
// Crossed the initial-delay threshold this frame — first repeat.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fire once each time the hold crosses another repeat-interval boundary.
|
||||||
|
let intervals_before =
|
||||||
|
((before - UNDO_HOLD_INITIAL_DELAY_SECS) / UNDO_HOLD_REPEAT_INTERVAL_SECS).floor();
|
||||||
|
let intervals_now =
|
||||||
|
((now - UNDO_HOLD_INITIAL_DELAY_SECS) / UNDO_HOLD_REPEAT_INTERVAL_SECS).floor();
|
||||||
|
intervals_now > intervals_before
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Click on the touch action bar's Draw button — same
|
||||||
|
/// [`DrawRequestEvent`] the stock-pile tap writes, so the consumer's
|
||||||
|
/// rules (recycle, won-game rejection) apply identically. Skipped while
|
||||||
|
/// paused, mirroring `handle_stock_click`'s guard.
|
||||||
|
pub(super) fn handle_draw_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<DrawButton>, Changed<Interaction>)>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
mut draw: MessageWriter<DrawRequestEvent>,
|
||||||
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
draw.write(DrawRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn handle_pause_button(
|
pub(super) fn handle_pause_button(
|
||||||
interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>,
|
interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>,
|
||||||
mut pause: MessageWriter<PauseRequestEvent>,
|
mut pause: MessageWriter<PauseRequestEvent>,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
|
|||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
DrawRequestEvent, HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
||||||
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
||||||
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent,
|
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent,
|
||||||
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
||||||
@@ -313,6 +313,18 @@ pub struct UndoButton;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct PauseButton;
|
pub struct PauseButton;
|
||||||
|
|
||||||
|
/// Accumulated hold time on the Undo button, driving hold-to-repeat undo
|
||||||
|
/// (Phase F). `handle_undo_button` owns the instant first undo on press;
|
||||||
|
/// `repeat_undo_on_hold` starts firing additional [`UndoRequestEvent`]s
|
||||||
|
/// once the hold passes `UNDO_HOLD_INITIAL_DELAY_SECS` and then every
|
||||||
|
/// `UNDO_HOLD_REPEAT_INTERVAL_SECS`. Each repeated undo goes through the
|
||||||
|
/// normal request path, so the existing scoring penalty applies per step.
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct UndoHoldState {
|
||||||
|
/// Seconds the Undo button has been continuously held.
|
||||||
|
held_secs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
/// Marker on the "Help" action button. Click fires [`HelpRequestEvent`],
|
/// Marker on the "Help" action button. Click fires [`HelpRequestEvent`],
|
||||||
/// mirroring the `F1` keyboard accelerator.
|
/// mirroring the `F1` keyboard accelerator.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
@@ -323,31 +335,27 @@ pub struct HelpButton;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct HintButton;
|
pub struct HintButton;
|
||||||
|
|
||||||
|
/// Marker on the touch action bar's "Draw" button (Phase F). Click fires
|
||||||
|
/// [`DrawRequestEvent`] — same queue as tapping the stock pile — so the
|
||||||
|
/// draw action is within thumb reach without stretching to the top of a
|
||||||
|
/// tall folded screen. Touch layout only; desktop draws via stock
|
||||||
|
/// click / `D` / `Space`.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct DrawButton;
|
||||||
|
|
||||||
/// Android HUD label for the Hint button — shared with the help screen's
|
/// Android HUD label for the Hint button — shared with the help screen's
|
||||||
/// controls reference so both always agree.
|
/// controls reference so both always agree.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub(crate) const ANDROID_HINT_LABEL: &str = "Hint";
|
pub(crate) const ANDROID_HINT_LABEL: &str = "Hint";
|
||||||
|
|
||||||
|
/// Hint label used by the touch action bar. Aliases [`ANDROID_HINT_LABEL`]
|
||||||
|
/// on Android so the bar and the help screen's controls reference can
|
||||||
|
/// never drift; the non-Android value only exists so the touch spawn
|
||||||
|
/// path stays host-compilable for tests.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
const ACTION_BAR_LABELS: [&str; 7] = [
|
const TOUCH_HINT_LABEL: &str = ANDROID_HINT_LABEL;
|
||||||
"Menu",
|
|
||||||
"Undo",
|
|
||||||
"Pause",
|
|
||||||
"Help",
|
|
||||||
ANDROID_HINT_LABEL,
|
|
||||||
"Mode",
|
|
||||||
"New",
|
|
||||||
];
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
const ACTION_BAR_LABELS: [&str; 7] = [
|
const TOUCH_HINT_LABEL: &str = "Hint";
|
||||||
"Menu \u{2193}",
|
|
||||||
"Undo",
|
|
||||||
"Pause",
|
|
||||||
"Help",
|
|
||||||
"Hint",
|
|
||||||
"Modes \u{2193}",
|
|
||||||
"New Game",
|
|
||||||
];
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0);
|
const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0);
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
@@ -455,6 +463,7 @@ impl Plugin for HudPlugin {
|
|||||||
// idempotent.
|
// idempotent.
|
||||||
app.add_message::<NewGameRequestEvent>()
|
app.add_message::<NewGameRequestEvent>()
|
||||||
.add_message::<UndoRequestEvent>()
|
.add_message::<UndoRequestEvent>()
|
||||||
|
.add_message::<DrawRequestEvent>()
|
||||||
.add_message::<PauseRequestEvent>()
|
.add_message::<PauseRequestEvent>()
|
||||||
.add_message::<HelpRequestEvent>()
|
.add_message::<HelpRequestEvent>()
|
||||||
.add_message::<StartZenRequestEvent>()
|
.add_message::<StartZenRequestEvent>()
|
||||||
@@ -471,6 +480,7 @@ impl Plugin for HudPlugin {
|
|||||||
.init_resource::<PreviousScore>()
|
.init_resource::<PreviousScore>()
|
||||||
.init_resource::<HudActionFade>()
|
.init_resource::<HudActionFade>()
|
||||||
.init_resource::<HudVisibility>()
|
.init_resource::<HudVisibility>()
|
||||||
|
.init_resource::<UndoHoldState>()
|
||||||
// Escape-close handlers for popovers read this; init defensively
|
// Escape-close handlers for popovers read this; init defensively
|
||||||
// so HudPlugin works under MinimalPlugins in tests.
|
// so HudPlugin works under MinimalPlugins in tests.
|
||||||
.init_resource::<ButtonInput<KeyCode>>()
|
.init_resource::<ButtonInput<KeyCode>>()
|
||||||
@@ -560,6 +570,14 @@ impl Plugin for HudPlugin {
|
|||||||
.in_set(crate::game_plugin::UndoRequestWriters)
|
.in_set(crate::game_plugin::UndoRequestWriters)
|
||||||
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
|
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
|
||||||
.before(GameMutation),
|
.before(GameMutation),
|
||||||
|
repeat_undo_on_hold
|
||||||
|
.in_set(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.before(GameMutation),
|
||||||
|
handle_draw_button
|
||||||
|
.in_set(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.before(GameMutation),
|
||||||
handle_pause_button,
|
handle_pause_button,
|
||||||
handle_help_button,
|
handle_help_button,
|
||||||
handle_hint_button
|
handle_hint_button
|
||||||
|
|||||||
@@ -400,83 +400,152 @@ pub(super) fn spawn_action_buttons(
|
|||||||
HudActionBar,
|
HudActionBar,
|
||||||
))
|
))
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
// so Tab cycles the action bar in visual reading order.
|
spawn_touch_action_bar(row, &font);
|
||||||
// Undo and Pause are the primary gameplay actions — full brightness.
|
} else {
|
||||||
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
|
spawn_desktop_action_bar(row, &font);
|
||||||
spawn_action_button(
|
}
|
||||||
row,
|
|
||||||
MenuButton,
|
|
||||||
ACTION_BAR_LABELS[0],
|
|
||||||
None,
|
|
||||||
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
|
|
||||||
&font,
|
|
||||||
0,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
UndoButton,
|
|
||||||
ACTION_BAR_LABELS[1],
|
|
||||||
Some("U"),
|
|
||||||
"Take back your last move. Costs points and blocks No Undo.",
|
|
||||||
&font,
|
|
||||||
1,
|
|
||||||
TEXT_PRIMARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
PauseButton,
|
|
||||||
ACTION_BAR_LABELS[2],
|
|
||||||
Some("Esc"),
|
|
||||||
"Pause the game and freeze the timer.",
|
|
||||||
&font,
|
|
||||||
2,
|
|
||||||
TEXT_PRIMARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
HelpButton,
|
|
||||||
ACTION_BAR_LABELS[3],
|
|
||||||
Some("F1"),
|
|
||||||
"Show controls, rules, and keyboard shortcuts.",
|
|
||||||
&font,
|
|
||||||
3,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
HintButton,
|
|
||||||
ACTION_BAR_LABELS[4],
|
|
||||||
Some("H"),
|
|
||||||
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
|
||||||
&font,
|
|
||||||
4,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
ModesButton,
|
|
||||||
ACTION_BAR_LABELS[5],
|
|
||||||
None,
|
|
||||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
|
||||||
&font,
|
|
||||||
5,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
NewGameButton,
|
|
||||||
ACTION_BAR_LABELS[6],
|
|
||||||
Some("N"),
|
|
||||||
"Start a fresh deal. Confirms first if a game is in progress.",
|
|
||||||
&font,
|
|
||||||
6,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase F touch action bar — five buttons, three of them big.
|
||||||
|
///
|
||||||
|
/// The core gameplay trio (**Undo · Draw · Hint**) gets enlarged
|
||||||
|
/// thumb-reach targets; Menu and Pause stay compact at the edges. The
|
||||||
|
/// utility actions the desktop bar carries are reachable elsewhere on
|
||||||
|
/// touch and are deliberately absent here: Help lives in Menu → System,
|
||||||
|
/// mode switching lives on the Home screen (Phase B), and New Game is
|
||||||
|
/// Home's hero button. Draw duplicates the stock-pile tap so the most
|
||||||
|
/// frequent action of all no longer requires reaching the top half of a
|
||||||
|
/// tall folded screen.
|
||||||
|
pub(super) fn spawn_touch_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
|
||||||
|
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
|
||||||
|
// so Tab (external keyboard) cycles the bar in visual reading order.
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
MenuButton,
|
||||||
|
"Menu",
|
||||||
|
None,
|
||||||
|
"Open Home, Stats, Achievements, Profile, Settings, or Leaderboard.",
|
||||||
|
font,
|
||||||
|
0,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_primary_action_button(
|
||||||
|
row,
|
||||||
|
UndoButton,
|
||||||
|
"Undo",
|
||||||
|
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
|
||||||
|
font,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
spawn_primary_action_button(
|
||||||
|
row,
|
||||||
|
DrawButton,
|
||||||
|
"Draw",
|
||||||
|
"Draw from the stock — same as tapping the deck.",
|
||||||
|
font,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
spawn_primary_action_button(
|
||||||
|
row,
|
||||||
|
HintButton,
|
||||||
|
TOUCH_HINT_LABEL,
|
||||||
|
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
||||||
|
font,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
PauseButton,
|
||||||
|
"Pause",
|
||||||
|
None,
|
||||||
|
"Pause the game and freeze the timer.",
|
||||||
|
font,
|
||||||
|
4,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Desktop action bar — unchanged by Phase F (decision 5: the touch bar
|
||||||
|
/// is touch-only). All seven actions, uniform sizing.
|
||||||
|
pub(super) fn spawn_desktop_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
|
||||||
|
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
|
||||||
|
// so Tab cycles the action bar in visual reading order.
|
||||||
|
// Undo and Pause are the primary gameplay actions — full brightness.
|
||||||
|
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
MenuButton,
|
||||||
|
"Menu \u{2193}",
|
||||||
|
None,
|
||||||
|
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
|
||||||
|
font,
|
||||||
|
0,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
UndoButton,
|
||||||
|
"Undo",
|
||||||
|
Some("U"),
|
||||||
|
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
|
||||||
|
font,
|
||||||
|
1,
|
||||||
|
TEXT_PRIMARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
PauseButton,
|
||||||
|
"Pause",
|
||||||
|
Some("Esc"),
|
||||||
|
"Pause the game and freeze the timer.",
|
||||||
|
font,
|
||||||
|
2,
|
||||||
|
TEXT_PRIMARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
HelpButton,
|
||||||
|
"Help",
|
||||||
|
Some("F1"),
|
||||||
|
"Show controls, rules, and keyboard shortcuts.",
|
||||||
|
font,
|
||||||
|
3,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
HintButton,
|
||||||
|
"Hint",
|
||||||
|
Some("H"),
|
||||||
|
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
||||||
|
font,
|
||||||
|
4,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
ModesButton,
|
||||||
|
"Modes \u{2193}",
|
||||||
|
None,
|
||||||
|
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||||
|
font,
|
||||||
|
5,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
NewGameButton,
|
||||||
|
"New Game",
|
||||||
|
Some("N"),
|
||||||
|
"Start a fresh deal. Confirms first if a game is in progress.",
|
||||||
|
font,
|
||||||
|
6,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawns a single action button as a child of `row`. Each button shares
|
/// Spawns a single action button as a child of `row`. Each button shares
|
||||||
/// the same node geometry, idle colour, and `ActionButton` marker so
|
/// the same node geometry, idle colour, and `ActionButton` marker so
|
||||||
/// `paint_action_buttons` can recolour all of them with one query.
|
/// `paint_action_buttons` can recolour all of them with one query.
|
||||||
@@ -510,13 +579,69 @@ pub(super) fn spawn_action_button<M: Component>(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let (pad, min_w, min_h) = action_button_metrics();
|
||||||
|
spawn_action_button_sized(
|
||||||
|
row, marker, label, hotkey, tooltip, font, order, text_color, pad, min_w, min_h,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enlarged variant for the touch bar's core gameplay trio (Phase F):
|
||||||
|
/// bigger padding / minimum target and a scaled-up label so Undo, Draw,
|
||||||
|
/// and Hint read (and hit) as the primary actions. Hotkey chips are
|
||||||
|
/// irrelevant on touch, so the variant takes none.
|
||||||
|
pub(super) fn spawn_primary_action_button<M: Component>(
|
||||||
|
row: &mut ChildSpawnerCommands,
|
||||||
|
marker: M,
|
||||||
|
label: &str,
|
||||||
|
tooltip: &'static str,
|
||||||
|
font: &TextFont,
|
||||||
|
order: i32,
|
||||||
|
) {
|
||||||
|
let (pad, min_w, min_h) = primary_action_button_metrics();
|
||||||
|
let primary_font = TextFont {
|
||||||
|
font: font.font.clone(),
|
||||||
|
// 1.35× the bar's responsive size, capped so landscape tablets
|
||||||
|
// don't blow the row height out.
|
||||||
|
font_size: (font.font_size * 1.35).min(24.0),
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
spawn_action_button_sized(
|
||||||
|
row,
|
||||||
|
marker,
|
||||||
|
label,
|
||||||
|
None,
|
||||||
|
tooltip,
|
||||||
|
&primary_font,
|
||||||
|
order,
|
||||||
|
TEXT_PRIMARY,
|
||||||
|
pad,
|
||||||
|
min_w,
|
||||||
|
min_h,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared body of [`spawn_action_button`] / [`spawn_primary_action_button`]
|
||||||
|
/// — one place owns the component set so `paint_action_buttons`, tooltips,
|
||||||
|
/// and the focus ring treat every bar button identically.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn spawn_action_button_sized<M: Component>(
|
||||||
|
row: &mut ChildSpawnerCommands,
|
||||||
|
marker: M,
|
||||||
|
label: &str,
|
||||||
|
hotkey: Option<&'static str>,
|
||||||
|
tooltip: &'static str,
|
||||||
|
font: &TextFont,
|
||||||
|
order: i32,
|
||||||
|
text_color: Color,
|
||||||
|
pad: UiRect,
|
||||||
|
min_w: Val,
|
||||||
|
min_h: Val,
|
||||||
|
) {
|
||||||
let hotkey_font = TextFont {
|
let hotkey_font = TextFont {
|
||||||
font: font.font.clone(),
|
font: font.font.clone(),
|
||||||
font_size: TYPE_CAPTION,
|
font_size: TYPE_CAPTION,
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
let (pad, min_w, min_h) = action_button_metrics();
|
|
||||||
|
|
||||||
row.spawn((
|
row.spawn((
|
||||||
marker,
|
marker,
|
||||||
ActionButton,
|
ActionButton,
|
||||||
|
|||||||
@@ -670,7 +670,7 @@ fn hud_elements_carry_expected_tooltip_strings() {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tooltip_for::<UndoButton>(&mut app),
|
tooltip_for::<UndoButton>(&mut app),
|
||||||
"Take back your last move. Costs points and blocks No Undo."
|
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo."
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tooltip_for::<PauseButton>(&mut app),
|
tooltip_for::<PauseButton>(&mut app),
|
||||||
@@ -899,3 +899,157 @@ fn hud_focus_only_engages_when_button_hovered() {
|
|||||||
"Hud-engaged Tab must focus a Hud-grouped entity"
|
"Hud-engaged Tab must focus a Hud-grouped entity"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase F: touch action bar + hold-to-repeat undo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Bare app (no `HudPlugin`) so bar-content assertions count only the
|
||||||
|
/// buttons the tested spawn function creates — `headless_app` would
|
||||||
|
/// pre-spawn the platform-default bar and pollute the counts.
|
||||||
|
fn bar_only_app(touch: bool) -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
app.update();
|
||||||
|
let font = TextFont::default();
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut commands = world.commands();
|
||||||
|
commands.spawn(Node::default()).with_children(|row| {
|
||||||
|
if touch {
|
||||||
|
spawn_touch_action_bar(row, &font);
|
||||||
|
} else {
|
||||||
|
spawn_desktop_action_bar(row, &font);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_buttons<C: Component>(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query_filtered::<(), With<C>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn touch_action_bar_has_thumb_trio_plus_menu_and_pause() {
|
||||||
|
let mut app = bar_only_app(true);
|
||||||
|
|
||||||
|
assert_eq!(count_buttons::<UndoButton>(&mut app), 1);
|
||||||
|
assert_eq!(count_buttons::<DrawButton>(&mut app), 1);
|
||||||
|
assert_eq!(count_buttons::<HintButton>(&mut app), 1);
|
||||||
|
assert_eq!(count_buttons::<MenuButton>(&mut app), 1);
|
||||||
|
assert_eq!(count_buttons::<PauseButton>(&mut app), 1);
|
||||||
|
|
||||||
|
// Utility actions live elsewhere on touch: Help in Menu → System,
|
||||||
|
// modes on the Home screen, New Game on Home's hero button.
|
||||||
|
assert_eq!(count_buttons::<HelpButton>(&mut app), 0);
|
||||||
|
assert_eq!(count_buttons::<ModesButton>(&mut app), 0);
|
||||||
|
assert_eq!(count_buttons::<NewGameButton>(&mut app), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn desktop_action_bar_keeps_all_seven_and_no_draw() {
|
||||||
|
let mut app = bar_only_app(false);
|
||||||
|
|
||||||
|
for (count, name) in [
|
||||||
|
(count_buttons::<MenuButton>(&mut app), "Menu"),
|
||||||
|
(count_buttons::<UndoButton>(&mut app), "Undo"),
|
||||||
|
(count_buttons::<PauseButton>(&mut app), "Pause"),
|
||||||
|
(count_buttons::<HelpButton>(&mut app), "Help"),
|
||||||
|
(count_buttons::<HintButton>(&mut app), "Hint"),
|
||||||
|
(count_buttons::<ModesButton>(&mut app), "Modes"),
|
||||||
|
(count_buttons::<NewGameButton>(&mut app), "New Game"),
|
||||||
|
] {
|
||||||
|
assert_eq!(count, 1, "desktop bar must keep its {name} button");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
count_buttons::<DrawButton>(&mut app),
|
||||||
|
0,
|
||||||
|
"Draw is touch-only (decision 5); desktop draws via stock click / D / Space"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_button_press_fires_draw_request() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<DrawRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
|
||||||
|
app.world_mut().spawn((DrawButton, Interaction::Pressed));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Messages<DrawRequestEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
assert_eq!(
|
||||||
|
cursor.read(events).count(),
|
||||||
|
1,
|
||||||
|
"pressing the Draw button must fire exactly one DrawRequestEvent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_button_press_is_noop_while_paused() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(PausedResource(true));
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<DrawRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
|
||||||
|
app.world_mut().spawn((DrawButton, Interaction::Pressed));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Messages<DrawRequestEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
assert_eq!(
|
||||||
|
cursor.read(events).count(),
|
||||||
|
0,
|
||||||
|
"the Draw button must be inert while paused (mirrors the stock-click guard)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The timing contract for hold-to-repeat undo, exercised through the
|
||||||
|
/// pure boundary function so no wall-clock is involved. Delay = 0.45 s,
|
||||||
|
/// interval = 0.18 s → boundaries at 0.45, 0.63, 0.81, …
|
||||||
|
#[test]
|
||||||
|
fn undo_hold_boundary_math() {
|
||||||
|
// A tap never repeats.
|
||||||
|
assert!(!undo_hold_crossed_fire_boundary(0.0, 0.05));
|
||||||
|
assert!(!undo_hold_crossed_fire_boundary(0.2, 0.44));
|
||||||
|
// Crossing the initial delay fires the first repeat.
|
||||||
|
assert!(undo_hold_crossed_fire_boundary(0.44, 0.46));
|
||||||
|
// Inside the first repeat interval: quiet.
|
||||||
|
assert!(!undo_hold_crossed_fire_boundary(0.46, 0.62));
|
||||||
|
// Each interval boundary fires exactly once.
|
||||||
|
assert!(undo_hold_crossed_fire_boundary(0.62, 0.64));
|
||||||
|
assert!(!undo_hold_crossed_fire_boundary(0.64, 0.80));
|
||||||
|
assert!(undo_hold_crossed_fire_boundary(0.80, 0.82));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undo_hold_state_accumulates_and_resets_on_release() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
let button = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((UndoButton, Interaction::Pressed))
|
||||||
|
.id();
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<UndoHoldState>().held_secs > 0.0,
|
||||||
|
"held time must accumulate while the Undo button stays pressed"
|
||||||
|
);
|
||||||
|
|
||||||
|
app.world_mut().entity_mut(button).insert(Interaction::None);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.world().resource::<UndoHoldState>().held_secs,
|
||||||
|
0.0,
|
||||||
|
"releasing the Undo button must reset the hold state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -554,11 +554,11 @@ pub(super) fn action_bar_font_size(window_width: f32) -> f32 {
|
|||||||
|
|
||||||
pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
|
pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
|
||||||
if USE_TOUCH_UI_LAYOUT {
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
// Tight 3 px horizontal padding (down from 4) trims 14 px off the row
|
// Tight 3 px horizontal padding keeps the compact buttons narrow;
|
||||||
// total across 7 buttons, and a 44 px min_width (down from 52) lets the
|
// Phase F trimmed the touch bar to 5 buttons (2 compact + 3
|
||||||
// shortest labels ("New", "Help") shrink to their text rather than
|
// primary), so width pressure is lower than the old 7-button row.
|
||||||
// padding the row out past the 900 logical-px viewport. min_height
|
// min_height stays at 44 px to preserve the comfortable touch
|
||||||
// stays at 44 px to preserve the comfortable touch target.
|
// target on the compact buttons.
|
||||||
(
|
(
|
||||||
UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
|
UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
|
||||||
Val::Px(44.0),
|
Val::Px(44.0),
|
||||||
@@ -573,6 +573,20 @@ pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Metrics for the touch bar's enlarged Undo / Draw / Hint trio (Phase F).
|
||||||
|
/// 96×64 px targets — comfortably past the 44 px accessibility floor and
|
||||||
|
/// big enough to hit one-handed without looking. Only the touch layout
|
||||||
|
/// spawns primary buttons, so no desktop branch is needed; the desktop
|
||||||
|
/// values exist purely so host-side tests can exercise the touch spawn
|
||||||
|
/// path with sensible numbers.
|
||||||
|
pub(super) fn primary_action_button_metrics() -> (UiRect, Val, Val) {
|
||||||
|
(
|
||||||
|
UiRect::axes(Val::Px(10.0), Val::Px(8.0)),
|
||||||
|
Val::Px(96.0),
|
||||||
|
Val::Px(64.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn spawn_action_button_label(
|
pub(super) fn spawn_action_button_label(
|
||||||
parent: &mut ChildSpawnerCommands,
|
parent: &mut ChildSpawnerCommands,
|
||||||
label: &str,
|
label: &str,
|
||||||
|
|||||||
@@ -136,11 +136,22 @@ impl Plugin for InputPlugin {
|
|||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
handle_keyboard_core,
|
// The three `DrawRequestEvent` writers join the
|
||||||
|
// self-ambiguous `DrawRequestWriters` set so the HUD's
|
||||||
|
// touch Draw button (hud_plugin, Phase F) can write the
|
||||||
|
// same queue without tripping the ambiguity gate. The
|
||||||
|
// `.chain()` still orders them relative to each other.
|
||||||
|
handle_keyboard_core
|
||||||
|
.in_set(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
|
||||||
handle_keyboard_hint,
|
handle_keyboard_hint,
|
||||||
handle_keyboard_forfeit,
|
handle_keyboard_forfeit,
|
||||||
handle_stock_click,
|
handle_stock_click
|
||||||
handle_touch_stock_tap,
|
.in_set(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
|
||||||
|
handle_touch_stock_tap
|
||||||
|
.in_set(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
|
||||||
handle_double_click,
|
handle_double_click,
|
||||||
// Mouse drag pipeline.
|
// Mouse drag pipeline.
|
||||||
start_drag,
|
start_drag,
|
||||||
|
|||||||
@@ -382,20 +382,27 @@ fn spawn_slide_welcome(commands: &mut Commands, font_res: Option<&FontResource>)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How-to-play body copy, phrased for the platform's input vocabulary —
|
||||||
|
/// a touch player never left-clicks (Phase H polish; spotted in the
|
||||||
|
/// emulator smoke of v0.44.0).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
const HOW_TO_PLAY_BODY: &str = "Drag any face-up card to move it between piles. \
|
||||||
|
You can drag a whole column at once by grabbing the topmost card \
|
||||||
|
you want to move. Double-tap a face-up card to send it to a \
|
||||||
|
foundation pile automatically (when the move is legal). \
|
||||||
|
Tap Hint in the bottom bar for a suggested move.";
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const HOW_TO_PLAY_BODY: &str = "Left-click and drag any face-up card to move it between piles. \
|
||||||
|
You can drag a whole column at once by grabbing the topmost card \
|
||||||
|
you want to move. Double-click a face-up card to send it to a \
|
||||||
|
foundation pile automatically (when the move is legal). \
|
||||||
|
Right-click a card for a hint — valid destinations will highlight.";
|
||||||
|
|
||||||
/// Slide 2 — How to play.
|
/// Slide 2 — How to play.
|
||||||
fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) {
|
fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||||
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
||||||
spawn_modal_header(card, "Drag cards to play", font_res);
|
spawn_modal_header(card, "Drag cards to play", font_res);
|
||||||
spawn_modal_body_text(
|
spawn_modal_body_text(card, HOW_TO_PLAY_BODY, TEXT_SECONDARY, font_res);
|
||||||
card,
|
|
||||||
"Left-click and drag any face-up card to move it between piles. \
|
|
||||||
You can drag a whole column at once by grabbing the topmost card \
|
|
||||||
you want to move. Double-click a face-up card to send it to a \
|
|
||||||
foundation pile automatically (when the move is legal). \
|
|
||||||
Right-click a card for a hint — valid destinations will highlight.",
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
spawn_modal_actions(card, |actions| {
|
spawn_modal_actions(card, |actions| {
|
||||||
spawn_modal_button(
|
spawn_modal_button(
|
||||||
actions,
|
actions,
|
||||||
|
|||||||
@@ -178,6 +178,32 @@ pub fn spawn_modal<M: Component, F>(
|
|||||||
z_panel: i32,
|
z_panel: i32,
|
||||||
build_card: F,
|
build_card: F,
|
||||||
) -> Entity
|
) -> Entity
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut ChildSpawnerCommands),
|
||||||
|
{
|
||||||
|
spawn_modal_sized(
|
||||||
|
commands,
|
||||||
|
plugin_marker,
|
||||||
|
z_panel,
|
||||||
|
MODAL_CARD_MAX_WIDTH,
|
||||||
|
build_card,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default maximum card width in logical pixels — every [`spawn_modal`]
|
||||||
|
/// caller gets this. Surfaces that lay out side-by-side panes (e.g. the
|
||||||
|
/// two-pane Home on wide viewports) can raise it via [`spawn_modal_sized`].
|
||||||
|
pub const MODAL_CARD_MAX_WIDTH: f32 = 720.0;
|
||||||
|
|
||||||
|
/// [`spawn_modal`] with an explicit card `max_width`. Behaviour is
|
||||||
|
/// otherwise identical — same scrim, enter animation, and card chrome.
|
||||||
|
pub fn spawn_modal_sized<M: Component, F>(
|
||||||
|
commands: &mut Commands,
|
||||||
|
plugin_marker: M,
|
||||||
|
z_panel: i32,
|
||||||
|
max_width: f32,
|
||||||
|
build_card: F,
|
||||||
|
) -> Entity
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut ChildSpawnerCommands),
|
F: FnOnce(&mut ChildSpawnerCommands),
|
||||||
{
|
{
|
||||||
@@ -235,7 +261,7 @@ where
|
|||||||
padding: UiRect::all(VAL_SPACE_5),
|
padding: UiRect::all(VAL_SPACE_5),
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_LG)),
|
border_radius: BorderRadius::all(Val::Px(RADIUS_LG)),
|
||||||
max_width: Val::Px(720.0),
|
max_width: Val::Px(max_width),
|
||||||
align_items: AlignItems::Stretch,
|
align_items: AlignItems::Stretch,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
//! shake duration elapses.
|
//! shake duration elapses.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::game_state::{GameMode, GameState};
|
use solitaire_core::game_state::GameMode;
|
||||||
use solitaire_core::scoring::compute_time_bonus;
|
use solitaire_core::scoring::compute_time_bonus;
|
||||||
use solitaire_data::AnimSpeed;
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
@@ -93,40 +93,6 @@ pub struct WinSummaryPending {
|
|||||||
/// score-breakdown reveal can format the mode-multiplier row
|
/// score-breakdown reveal can format the mode-multiplier row
|
||||||
/// (e.g. `Zen ×0.0`, `Classic ×1.0`).
|
/// (e.g. `Zen ×0.0`, `Classic ×1.0`).
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
/// Per-move-type recap of the winning game, e.g.
|
|
||||||
/// `"21 to foundation · 14 tableau moves · 9 flips · 1 recycle"`.
|
|
||||||
/// Built from the upstream session counters at win time; empty when
|
|
||||||
/// every counter is zero (synthesised test wins).
|
|
||||||
pub move_detail: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Formats the per-move-type recap line for the win modal from the
|
|
||||||
/// upstream session counters. Zero-valued components are omitted so the
|
|
||||||
/// line stays short; returns an empty string when nothing was counted
|
|
||||||
/// (only possible for synthesised test wins).
|
|
||||||
fn build_move_detail(game: &GameState) -> String {
|
|
||||||
let mut parts: Vec<String> = Vec::new();
|
|
||||||
let foundation = game.move_to_foundation_count();
|
|
||||||
if foundation > 0 {
|
|
||||||
parts.push(format!("{foundation} to foundation"));
|
|
||||||
}
|
|
||||||
let tableau = game.move_to_tableau_count();
|
|
||||||
if tableau > 0 {
|
|
||||||
parts.push(format!("{tableau} tableau moves"));
|
|
||||||
}
|
|
||||||
let flips = game.flip_up_count();
|
|
||||||
if flips > 0 {
|
|
||||||
parts.push(format!("{flips} flips"));
|
|
||||||
}
|
|
||||||
let recycles = game.recycle_count();
|
|
||||||
if recycles > 0 {
|
|
||||||
parts.push(format!("{recycles} recycles"));
|
|
||||||
}
|
|
||||||
let returns = game.move_from_foundation_count();
|
|
||||||
if returns > 0 {
|
|
||||||
parts.push(format!("{returns} foundation returns"));
|
|
||||||
}
|
|
||||||
parts.join(" \u{00B7} ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds a human-readable XP breakdown string for the win modal.
|
/// Builds a human-readable XP breakdown string for the win modal.
|
||||||
@@ -526,7 +492,6 @@ fn cache_win_data(
|
|||||||
pending.challenge_level = challenge_level;
|
pending.challenge_level = challenge_level;
|
||||||
pending.undo_count = game.0.undo_count();
|
pending.undo_count = game.0.undo_count();
|
||||||
pending.mode = game.0.mode;
|
pending.mode = game.0.mode;
|
||||||
pending.move_detail = build_move_detail(&game.0);
|
|
||||||
|
|
||||||
if is_new_record {
|
if is_new_record {
|
||||||
toast.write(InfoToastEvent("New Record!".to_string()));
|
toast.write(InfoToastEvent("New Record!".to_string()));
|
||||||
@@ -948,18 +913,6 @@ fn spawn_overlay(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move-type recap (same quiet styling as the XP breakdown)
|
|
||||||
if !pending.move_detail.is_empty() {
|
|
||||||
card.spawn((
|
|
||||||
Text::new(pending.move_detail.clone()),
|
|
||||||
TextFont {
|
|
||||||
font_size: 15.0,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Achievements unlocked this game — at most 3 shown explicitly;
|
// Achievements unlocked this game — at most 3 shown explicitly;
|
||||||
// excess is summarised with "...and N more".
|
// excess is summarised with "...and N more".
|
||||||
if !session.names.is_empty() {
|
if !session.names.is_empty() {
|
||||||
@@ -1335,49 +1288,6 @@ mod tests {
|
|||||||
assert_eq!(p.mode, GameMode::Classic);
|
assert_eq!(p.mode, GameMode::Classic);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_move_detail_fresh_game_is_empty() {
|
|
||||||
let game = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
|
|
||||||
assert!(
|
|
||||||
build_move_detail(&game).is_empty(),
|
|
||||||
"no moves yet, so the recap line must be empty"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_move_detail_reports_played_move_types() {
|
|
||||||
use solitaire_core::KlondikeInstruction;
|
|
||||||
// Drive a real deal forward so the upstream counters accumulate:
|
|
||||||
// prefer foundation moves, then anything else, drawing as needed.
|
|
||||||
let mut game = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
|
|
||||||
for _ in 0..80 {
|
|
||||||
let instructions = game.possible_instructions();
|
|
||||||
let next = instructions
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.find(|i| matches!(i, KlondikeInstruction::DstFoundation(_)))
|
|
||||||
.or_else(|| instructions.into_iter().next());
|
|
||||||
match next {
|
|
||||||
Some(i) => {
|
|
||||||
let _ = game.apply_instruction(i);
|
|
||||||
}
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
if game.move_to_foundation_count() > 0 && game.move_to_tableau_count() > 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let detail = build_move_detail(&game);
|
|
||||||
assert!(
|
|
||||||
detail.contains("to foundation"),
|
|
||||||
"seed 42 reaches a foundation move within 80 plies; got: {detail}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!detail.contains("0 "),
|
|
||||||
"zero-valued components must be omitted; got: {detail}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_xp_detail_slow_win_with_undo() {
|
fn build_xp_detail_slow_win_with_undo() {
|
||||||
// 300s >= 120s → no speed bonus; undo used → no no-undo bonus.
|
// 300s >= 120s → no speed bonus; undo used → no no-undo bonus.
|
||||||
|
|||||||
@@ -206,7 +206,9 @@ async function main() {
|
|||||||
invariant_ok: !!snap?.invariants?.state_ok,
|
invariant_ok: !!snap?.invariants?.state_ok,
|
||||||
history_len: Array.isArray(snap?.move_history) ? snap.move_history.length : null,
|
history_len: Array.isArray(snap?.move_history) ? snap.move_history.length : null,
|
||||||
replay_payload_present: payload !== null,
|
replay_payload_present: payload !== null,
|
||||||
replay_moves_len: Array.isArray(payload?.moves) ? payload.moves.length : 0,
|
replay_moves_len: Array.isArray(payload?.recording?.instructions)
|
||||||
|
? payload.recording.instructions.length
|
||||||
|
: 0,
|
||||||
};
|
};
|
||||||
}, { stepCap: maxSteps, policyName: policy, maxVisits: maxVisitsPerState });
|
}, { stepCap: maxSteps, policyName: policy, maxVisits: maxVisitsPerState });
|
||||||
|
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ test("draw-mode toggle affects replay payload draw_mode", async ({ page }) => {
|
|||||||
|
|
||||||
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
|
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
|
||||||
expect(payload.draw_mode).toBe("DrawThree");
|
expect(payload.draw_mode).toBe("DrawThree");
|
||||||
expect(payload.schema_version).toBe(2);
|
expect(payload.schema_version).toBe(4);
|
||||||
expect(Array.isArray(payload.moves)).toBeTruthy();
|
expect(Array.isArray(payload.recording?.instructions)).toBeTruthy();
|
||||||
expect(payload.moves.length).toBeGreaterThan(0);
|
expect(payload.recording.instructions.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("autonomous play keeps invariants stable across seed batch", async ({ page }) => {
|
test("autonomous play keeps invariants stable across seed batch", async ({ page }) => {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ test("debug failure report contains replay diagnostics", async ({ page }) => {
|
|||||||
expect(report.invariants).toBeTruthy();
|
expect(report.invariants).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("replay payload builder exports schema-v2 moves", async ({ page }) => {
|
test("replay payload builder exports a schema-v4 recording", async ({ page }) => {
|
||||||
await page.goto("/play-classic?seed=42");
|
await page.goto("/play-classic?seed=42");
|
||||||
await page.waitForFunction(() => typeof window.__FERROUS_DEBUG__ === "object");
|
await page.waitForFunction(() => typeof window.__FERROUS_DEBUG__ === "object");
|
||||||
|
|
||||||
@@ -57,10 +57,14 @@ test("replay payload builder exports schema-v2 moves", async ({ page }) => {
|
|||||||
.poll(async () => await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload() !== null))
|
.poll(async () => await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload() !== null))
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
|
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
|
||||||
expect(payload.schema_version).toBe(2);
|
// Schema v4: the wasm layer assembles the whole payload; the deal is
|
||||||
|
// embedded in `recording` and moves live at recording.instructions.
|
||||||
|
expect(payload.schema_version).toBe(4);
|
||||||
expect(payload.draw_mode).toMatch(/Draw(One|Three)/);
|
expect(payload.draw_mode).toMatch(/Draw(One|Three)/);
|
||||||
expect(payload.mode).toBe("Classic");
|
expect(payload.mode).toBe("Classic");
|
||||||
expect(Array.isArray(payload.moves)).toBeTruthy();
|
expect(payload.recording).toBeTruthy();
|
||||||
expect(payload.moves.length).toBeGreaterThan(0);
|
expect(payload.recording.initial_state).toBeTruthy();
|
||||||
expect(payload.win_move_index).toBe(payload.moves.length - 1);
|
expect(Array.isArray(payload.recording.instructions)).toBeTruthy();
|
||||||
|
expect(payload.recording.instructions.length).toBeGreaterThan(0);
|
||||||
|
expect(payload.win_move_index).toBe(payload.recording.instructions.length - 1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -315,9 +315,10 @@ btnPlay.addEventListener("click", () => {
|
|||||||
}, STEP_INTERVAL_MS);
|
}, STEP_INTERVAL_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Step the player back one move via the wasm-side seek (rewinds to the
|
/// Step the player back one move. Re-creates the ReplayPlayer and fast-
|
||||||
/// recorded deal and fast-forwards internally), then renders once so the
|
/// forwards to (step_idx - 1) without rendering intermediate frames, then
|
||||||
/// CSS transition animates each card to its previous position.
|
/// renders once so the CSS transition animates each card to its previous
|
||||||
|
/// position.
|
||||||
function stepBack() {
|
function stepBack() {
|
||||||
if (!player || player.step_idx() === 0) return;
|
if (!player || player.step_idx() === 0) return;
|
||||||
if (playInterval) {
|
if (playInterval) {
|
||||||
@@ -325,7 +326,12 @@ function stepBack() {
|
|||||||
playInterval = null;
|
playInterval = null;
|
||||||
btnPlay.textContent = "▶ Play";
|
btnPlay.textContent = "▶ Play";
|
||||||
}
|
}
|
||||||
render(player.seek(player.step_idx() - 1));
|
const target = player.step_idx() - 1;
|
||||||
|
player = new ReplayPlayer(replayJson);
|
||||||
|
for (let i = 0; i < target; i++) {
|
||||||
|
player.step();
|
||||||
|
}
|
||||||
|
render(player.state());
|
||||||
btnPrev.disabled = player.step_idx() === 0;
|
btnPrev.disabled = player.step_idx() === 0;
|
||||||
btnRestart.disabled = player.step_idx() === 0;
|
btnRestart.disabled = player.step_idx() === 0;
|
||||||
btnStep.disabled = false;
|
btnStep.disabled = false;
|
||||||
|
|||||||
@@ -110,9 +110,6 @@ impl From<&(Card, bool)> for CardSnapshot {
|
|||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub struct ReplayPlayer {
|
pub struct ReplayPlayer {
|
||||||
game: GameState,
|
game: GameState,
|
||||||
/// The recorded deal before any instruction, kept so [`Self::seek_native`]
|
|
||||||
/// can rewind without reparsing the replay JSON.
|
|
||||||
initial: GameState,
|
|
||||||
moves: Vec<KlondikeInstruction>,
|
moves: Vec<KlondikeInstruction>,
|
||||||
step_idx: usize,
|
step_idx: usize,
|
||||||
}
|
}
|
||||||
@@ -147,30 +144,12 @@ impl ReplayPlayer {
|
|||||||
// the current build maps seeds to deals.
|
// the current build maps seeds to deals.
|
||||||
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
|
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
initial: game.clone(),
|
|
||||||
game,
|
game,
|
||||||
moves,
|
moves,
|
||||||
step_idx: 0,
|
step_idx: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Jump to `step` (clamped to the move count): the board state after
|
|
||||||
/// `step` moves have been applied. Rewinds by resetting to the stored
|
|
||||||
/// initial deal, then fast-forwards — a few hundred instruction
|
|
||||||
/// applications, microseconds in practice.
|
|
||||||
pub fn seek_native(&mut self, step: usize) -> Result<StateSnapshot, MoveError> {
|
|
||||||
let target = step.min(self.moves.len());
|
|
||||||
if target < self.step_idx {
|
|
||||||
self.game = self.initial.clone();
|
|
||||||
self.step_idx = 0;
|
|
||||||
}
|
|
||||||
while self.step_idx < target {
|
|
||||||
self.game.apply_instruction(self.moves[self.step_idx])?;
|
|
||||||
self.step_idx += 1;
|
|
||||||
}
|
|
||||||
Ok(self.snapshot())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply the next move. Returns `Ok(None)` once the list is exhausted.
|
/// Apply the next move. Returns `Ok(None)` once the list is exhausted.
|
||||||
pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> {
|
pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> {
|
||||||
if self.step_idx >= self.moves.len() {
|
if self.step_idx >= self.moves.len() {
|
||||||
@@ -257,25 +236,6 @@ impl ReplayPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Jump directly to `step` moves applied (clamped to the move count)
|
|
||||||
/// and return the snapshot there. Backwards seeks rewind to the
|
|
||||||
/// recorded deal and fast-forward, so any position is O(replay length)
|
|
||||||
/// at worst — no JSON reparse, no intermediate renders.
|
|
||||||
///
|
|
||||||
/// Throws `"replay_desync"` if a recorded move is illegal during the
|
|
||||||
/// fast-forward (corrupt recording).
|
|
||||||
pub fn seek(&mut self, step: usize) -> Result<JsValue, JsValue> {
|
|
||||||
match self.seek_native(step) {
|
|
||||||
Ok(snap) => {
|
|
||||||
serde_wasm_bindgen::to_value(&snap).map_err(|e| JsValue::from_str(&e.to_string()))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log_replay_move_error(&e);
|
|
||||||
Err(JsValue::from_str("replay_desync"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Total number of moves the replay contains.
|
/// Total number of moves the replay contains.
|
||||||
pub fn total_steps(&self) -> usize {
|
pub fn total_steps(&self) -> usize {
|
||||||
self.moves.len()
|
self.moves.len()
|
||||||
@@ -1102,57 +1062,6 @@ mod tests {
|
|||||||
assert_eq!(orig["stock"], repl["stock"], "stock deal must match");
|
assert_eq!(orig["stock"], repl["stock"], "stock deal must match");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `seek` must land on exactly the state produced by stepping — both
|
|
||||||
/// forwards (fast-forward from the current position) and backwards
|
|
||||||
/// (rewind to the recorded deal, then fast-forward).
|
|
||||||
#[test]
|
|
||||||
fn seek_matches_stepping_in_both_directions() {
|
|
||||||
let mut game = SolitaireGame {
|
|
||||||
game: GameState::new_with_mode(51, DrawStockConfig::DrawOne, GameMode::Classic),
|
|
||||||
};
|
|
||||||
for _ in 0..24 {
|
|
||||||
let legal_moves = game.legal_moves_native();
|
|
||||||
if legal_moves.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let idx = pick_move_index(&legal_moves).unwrap_or_default();
|
|
||||||
game.apply_legal_move_native(idx).expect("advance game");
|
|
||||||
}
|
|
||||||
let replay_json = game
|
|
||||||
.replay_export_native(60, "2026-07-10")
|
|
||||||
.expect("export replay");
|
|
||||||
|
|
||||||
let mut stepped = ReplayPlayer::from_json(&replay_json).expect("player A");
|
|
||||||
let mut seeker = ReplayPlayer::from_json(&replay_json).expect("player B");
|
|
||||||
let total = stepped.total_steps();
|
|
||||||
assert!(total >= 4, "test needs a few moves, got {total}");
|
|
||||||
|
|
||||||
// Forward: step A to k, seek B to k, compare snapshots.
|
|
||||||
let k = total / 2;
|
|
||||||
for _ in 0..k {
|
|
||||||
stepped.step_native().expect("step").expect("mid-replay");
|
|
||||||
}
|
|
||||||
let sought = seeker.seek_native(k).expect("seek forward");
|
|
||||||
assert_eq!(sought, stepped.snapshot(), "forward seek diverged at {k}");
|
|
||||||
|
|
||||||
// Backward: seek B to k - 2 and compare against a fresh stepper.
|
|
||||||
let back = k - 2;
|
|
||||||
let mut fresh = ReplayPlayer::from_json(&replay_json).expect("player C");
|
|
||||||
for _ in 0..back {
|
|
||||||
fresh.step_native().expect("step").expect("mid-replay");
|
|
||||||
}
|
|
||||||
let sought_back = seeker.seek_native(back).expect("seek backward");
|
|
||||||
assert_eq!(
|
|
||||||
sought_back,
|
|
||||||
fresh.snapshot(),
|
|
||||||
"backward seek diverged at {back}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clamping: past-the-end seeks stop at the final state.
|
|
||||||
let end = seeker.seek_native(usize::MAX).expect("seek to end");
|
|
||||||
assert_eq!(end.step_idx, total);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn debug_api_autonomous_seed_batch_smoke() {
|
fn debug_api_autonomous_seed_batch_smoke() {
|
||||||
for seed in 0_u64..128_u64 {
|
for seed in 0_u64..128_u64 {
|
||||||
|
|||||||
Reference in New Issue
Block a user