Compare commits

...

17 Commits

Author SHA1 Message Date
funman300 fa54c58bc4 feat(engine): Phase F touch action bar — thumb-reach Undo/Draw/Hint + hold-to-repeat undo
Test / fmt (pull_request) Successful in 5s
Test / test (pull_request) Successful in 4m25s
Touch layout (USE_TOUCH_UI_LAYOUT) restructures the bottom action bar
to five buttons: an enlarged Undo / Draw / Hint trio (96x64px targets,
1.35x labels) between compact Menu and Pause. Draw is new — it fires
the same DrawRequestEvent as tapping the stock, so the most frequent
action no longer needs a reach to the top of a tall folded screen.
Help, Modes, and New Game leave the touch bar (they live in Menu ->
System, the Home grid, and Home's hero respectively). Desktop keeps
the seven-button bar unchanged (decision 5: touch-only).

Holding Undo now steps back repeatedly after a 0.45s delay (5.5/s),
each step through the normal request path so the scoring penalty
applies. New self-ambiguous DrawRequestWriters set keeps the ambiguity
gate at zero with the fourth DrawRequestEvent writer.

6 new hud_plugin tests; workspace suite + clippy green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:07:10 -07:00
funman300 dd304913df docs(ui): record Phase F decision 5 — bottom action bar is touch-only
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:49:15 -07:00
funman300 48ad6b6618 docs(changelog): cut 0.44.0 — Phase B home hierarchy
Android Release / build-apk (push) Successful in 7m48s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:16:27 -07:00
funman300 81ac4a5383 Merge pull request 'ci(test): host-persistent target dir; fast fmt gate; drop dead rust-cache' (#176) from ci/host-persistent-target into master
Test / fmt (push) Successful in 4s
Test / test (push) Successful in 4m27s
2026-07-13 22:14:39 +00:00
funman300 f51ee7b234 Merge pull request 'feat(engine): Phase B home hierarchy — Continue card, hero New Game, deal-options disclosure' (#175) from feat/home-hierarchy into master
Build and Deploy / build-and-push (push) Successful in 9m11s
Web E2E / web-e2e (push) Successful in 8m30s
Test / test (push) Successful in 37m46s
2026-07-13 20:55:37 +00:00
funman300 d593b61af8 ci(test): host-persistent target dir; fast fmt gate; drop dead rust-cache
Test / fmt (pull_request) Successful in 5s
Test / test (pull_request) Successful in 33m27s
The Gitea actions cache on this instance stores caches (1.85 GB saves
confirmed in run 597) but never restores them — every run back through
run 584+ logs 'No cache found' even for exact keys saved an hour
earlier, including master-to-master. Every CI run has therefore been a
full cold build (~37 min), plus ~4 min tarring a cache nobody reads.

- Point CARGO_TARGET_DIR at a persistent path on the rust-host runner
  (host executor — filesystem carries over between runs), with a 40 GiB
  prune guard. Warm runs drop to minutes without touching the broken
  cache API.
- Drop Swatinem/rust-cache (pure overhead until the server is fixed).
- Split cargo fmt --check into a seconds-long fmt job gating the heavy
  test job, so a formatting slip can't burn a 35-minute build again
  (run 600).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:53:34 -07:00
funman300 fc87b13e5b style: cargo fmt
Test / test (pull_request) Successful in 37m46s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:11:54 -07:00
funman300 560470b86b feat(engine): Phase B home hierarchy — Continue card, hero New Game, deal-options disclosure
Test / test (pull_request) Failing after 10s
Restructures the Home overlay per docs/ui-redesign-2026-07.md Phase B:

- Continue card (mode / elapsed / score) when a game is in progress;
  clicking returns to the table
- Hero New Game replays the persisted Settings::last_mode (new field,
  serde-default Classic) with one tap; locked modes fall back to Classic
- Deal options (draw 1/3, winnable-only, difficulty tiers) move into a
  disclosure under the hero — off the Home top level (decision 2)
- Compact symmetric 2x3 mode grid; descriptions on wide viewports only
- Stats strip moves to the bottom (right pane on wide)
- Two-pane body on wide viewports (>= 1000 logical px) via new
  ui_modal::spawn_modal_sized (default card stays 720 px)
- Cancel becomes "Back to table" and only renders while a live
  (un-won) game exists

10 new home_plugin tests; all 936 engine tests + clippy green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:08:07 -07:00
funman300 1fded5ff16 docs(ui): record Phase B decisions 2-4 (deal-options disclosure, top-level Time Attack/Seed cards, in-engine iteration)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 12:40:17 -07:00
funman300 65585c61ad Merge pull request 'test(e2e): read v4 recording.instructions in cycle regression gate' (#174) from fix/cycle-metrics-v4-recording into master
Build and Deploy / build-and-push (push) Successful in 2m42s
Web E2E / web-e2e (push) Successful in 9m33s
2026-07-10 21:56:13 +00:00
funman300 0583a8ffae test(e2e): read v4 recording.instructions in cycle regression gate
Test / test (pull_request) Successful in 36m28s
The cycle regression gate still read payload.moves, which schema v4
removed in favour of recording.instructions, so every game reported
replay_history_mismatch:0/N and games_with_issues tripped the
--require-zero-issues gate on master (runs 587/594/596).

Verified locally: 12-game gate run reports 0 issues.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:18:35 -07:00
funman300 4d9d710a02 Merge pull request 'test(e2e): update replay payload specs to schema v4' (#172) from fix/web-e2e-replay-v4-shape into master
Build and Deploy / build-and-push (push) Successful in 2m32s
Web E2E / web-e2e (push) Failing after 9m10s
2026-07-10 21:01:27 +00:00
funman300 3fbee9ce30 Merge pull request 'fix(core): save files store the deal via upstream serializers (schema v6)' (#171) from fix/save-schema-v6-session-recording into master
Build and Deploy / build-and-push (push) Successful in 10m35s
Web E2E / web-e2e (push) Failing after 9m21s
Test / test (push) Successful in 36m35s
2026-07-10 18:12:28 +00:00
funman300 fd98f46267 test(e2e): update replay payload specs to schema v4
Test / test (pull_request) Successful in 37m24s
PR #170 changed the web replay payload (moves list -> embedded session
recording) but missed these Playwright specs, which only run on master
pushes and so failed post-merge. Assertions now match the v4 shape:
schema_version 4, recording.initial_state present, moves at
recording.instructions.

Verified locally against a real server with freshly built wasm bundles:
full suite 18/18 green, including the five play_canvas specs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:07:08 -07:00
funman300 07044f439c fix(core): save files store the deal via upstream serializers (schema v6)
Test / test (pull_request) Successful in 36m38s
GameState's save serde had the same latent flaw PR #170 removed from
replays: v5 persisted seed + saved_moves and re-dealt the board from the
seed on load, so any RNG or upstream upgrade that shifts the seed->deal
mapping would invalidate every in-progress save (graceful error, but the
player loses their game). v6 persists the existing SessionRecording
payload (upstream card_game session serde: config + dealt board +
instructions) instead; seed stays as presentation metadata only.

- v4/v5 files still load through the legacy seed path and are rewritten
  as v6 on the next save; v6 files load from the recording and replay
  with per-instruction validation, so tampered or corrupt files surface
  an error rather than a silently wrong board
- saved_moves() removed (dead once serialisation reads recording())
- tests: v6 round-trip via storage, seed-corruption immunity, v5 legacy
  load, missing-recording rejection, invalid-history rejection

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:21:01 -07:00
funman300 c60d465711 docs(changelog): cut 0.43.3 — replay schema v4
Android Release / build-apk (push) Successful in 7m6s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:57:04 -07:00
funman300 ce2b29f5df Merge pull request 'fix(replay): store the deal via upstream card_game serializers (schema v4)' (#170) from fix/replay-schema-v4-session-recording into master
Build and Deploy / build-and-push (push) Successful in 11m32s
Web E2E / web-e2e (push) Failing after 8m58s
Test / test (push) Successful in 38m3s
2026-07-10 16:56:33 +00:00
18 changed files with 1958 additions and 482 deletions
+56 -7
View File
@@ -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)
+47
View File
@@ -6,6 +6,53 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
## [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
### Fixed
- **Replays are now self-contained (schema v4).** A replay stores the dealt
board itself via the upstream `card_game` session serializers instead of
re-dealing from the seed at playback time, so replays survive RNG and
upstream upgrades that change the seed→deal mapping — the failure that had
silently broken every stored replay. The web player and web game now
exchange the full payload through the wasm layer (the old JS path hardcoded
`schema_version: 2`, uploaded empty move lists, and corrupted u64 seeds via
`Math.round`), and the replay viewer reports unplayable old-format replays
in the caption instead of dying silently. Pre-v4 replays are rejected by a
version gate; local histories repopulate with new wins. (#170)
- **Difficulty-mode wins can upload.** The server's replay `mode` validation
now accepts data-carrying `GameMode` variants (previously a 400). (#170)
## [0.42.0] — 2026-07-06 ## [0.42.0] — 2026-07-06
### Added ### Added
+14 -7
View File
@@ -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 IM 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.
+145 -41
View File
@@ -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()
@@ -524,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.
/// ///
@@ -1697,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);
+11 -1
View File
@@ -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,
+10 -5
View File
@@ -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!(
+8
View File
@@ -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)]
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>,
+37 -19
View File
@@ -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
+201 -76
View File
@@ -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,
+155 -1
View File
@@ -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"
);
}
+19 -5
View File
@@ -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,
+14 -3
View File
@@ -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,
+27 -1
View File
@@ -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()
}, },
@@ -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 }) => {
+9 -5
View File
@@ -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);
}); });