Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2581024f3 | |||
| c60d465711 | |||
| ce2b29f5df | |||
| 4cb4212829 | |||
| fce0266b47 | |||
| 8d80f95bd0 | |||
| 48f0907f78 | |||
| dda6a25439 | |||
| 1aad3251ba | |||
| b26200f948 | |||
| ea8d8eee9a | |||
| adbcb8f59a | |||
| 15c924c3dc | |||
| ede58f9666 | |||
| ccfb9394e0 | |||
| b5c1ba4867 | |||
| d179d9d582 |
@@ -10,9 +10,11 @@ on:
|
||||
- 'solitaire_web/**'
|
||||
- 'solitaire_sync/**'
|
||||
- 'solitaire_core/**'
|
||||
- 'solitaire_data/**'
|
||||
- 'solitaire_engine/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'build_wasm.sh'
|
||||
- 'solitaire_server/Dockerfile'
|
||||
- '.gitea/workflows/docker-build.yml'
|
||||
|
||||
@@ -36,12 +38,9 @@ jobs:
|
||||
id: meta
|
||||
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# WASM artifact freshness is owned by the `web-wasm-rebuild` workflow,
|
||||
# which rebuilds pkg/ in CI on every master change to a wasm-feeding crate
|
||||
# and commits it back (CI is the single source of truth — the artifacts
|
||||
# aren't byte-reproducible on contributor machines). That pkg/ commit then
|
||||
# triggers this workflow, so the deployed image always ships fresh wasm.
|
||||
# No drift check is needed here.
|
||||
# The wasm bundles (solitaire_server/web/pkg/) are not in the repo —
|
||||
# the Dockerfile's wasm-builder stage builds them from source inside
|
||||
# this image build, so the deployed image always ships fresh wasm.
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Workspace gate: the same clippy + test commands CLAUDE.md §6 requires
|
||||
# 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
|
||||
# master (or the web-wasm-rebuild bot commit) was entirely unguarded.
|
||||
# master was entirely unguarded.
|
||||
name: Test
|
||||
|
||||
on:
|
||||
|
||||
@@ -8,9 +8,13 @@ on:
|
||||
- 'solitaire_server/src/**'
|
||||
- 'solitaire_server/e2e/**'
|
||||
- 'solitaire_wasm/**'
|
||||
- 'solitaire_web/**'
|
||||
- 'solitaire_engine/**'
|
||||
- 'solitaire_data/**'
|
||||
- 'solitaire_core/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'build_wasm.sh'
|
||||
- '.gitea/workflows/web-e2e.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -24,10 +28,31 @@ jobs:
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Cache cargo build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
# The wasm bundles (solitaire_server/web/pkg/) are not in the repo —
|
||||
# build them here so the served pages have real wasm to load. Tool
|
||||
# versions are pinned; keep in sync with solitaire_server/Dockerfile.
|
||||
- name: Install wasm-bindgen-cli + wasm-pack (pinned)
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: wasm-bindgen-cli@0.2.120,wasm-pack@0.14.0
|
||||
|
||||
- name: Install binaryen 130 (wasm-opt, pinned)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -sSL \
|
||||
https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
|
||||
| tar xz
|
||||
echo "$PWD/binaryen-version_130/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Build WASM artifacts
|
||||
run: ./build_wasm.sh
|
||||
|
||||
# Prebuild the server so Playwright's `webServer` (which runs
|
||||
# `cargo run -p solitaire_server`) starts from a compiled binary instead
|
||||
# of cold-compiling the whole dependency graph (axum/sqlx/reqwest) inside
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
name: Web WASM Rebuild
|
||||
|
||||
# CI is the single source of truth for solitaire_server/web/pkg/.
|
||||
#
|
||||
# The wasm artifacts cannot be reproduced byte-for-byte on an arbitrary
|
||||
# contributor machine: even with identical rustc 1.95.0 / LLVM 22.1.2, the same
|
||||
# flags, the same Cargo.lock and remapped source paths, the output still differs
|
||||
# by host environment. So rather than police freshness with a rebuild-and-diff
|
||||
# gate (which false-failed for exactly that reason), CI rebuilds the artifacts
|
||||
# itself on every master change to a wasm-feeding crate and commits them back.
|
||||
#
|
||||
# Result: the deployed pkg/ can't silently rot, and contributors never need to
|
||||
# run build_wasm.sh by hand. The commit touches only pkg/, which is not in this
|
||||
# workflow's trigger paths (so it does not re-trigger here) but does match
|
||||
# docker-build's, so the refreshed wasm deploys.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'solitaire_core/**'
|
||||
- 'solitaire_engine/**'
|
||||
- 'solitaire_data/**'
|
||||
- 'solitaire_sync/**'
|
||||
- 'solitaire_wasm/**'
|
||||
- 'solitaire_web/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'build_wasm.sh'
|
||||
- '.gitea/workflows/web-wasm-rebuild.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: web-wasm-rebuild
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
rebuild:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.CI_TOKEN }}
|
||||
|
||||
- name: Install Rust 1.95.0
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Cache cargo build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Install wasm-bindgen-cli + wasm-pack (pinned)
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: wasm-bindgen-cli@0.2.120,wasm-pack@0.14.0
|
||||
|
||||
- name: Install binaryen 130 (wasm-opt, pinned)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -sSL \
|
||||
https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
|
||||
| tar xz
|
||||
echo "$PWD/binaryen-version_130/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Rebuild WASM artifacts
|
||||
run: ./build_wasm.sh
|
||||
|
||||
- name: Commit refreshed artifacts if changed
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git diff --quiet -- solitaire_server/web/pkg/; then
|
||||
echo "pkg/ already up to date — nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
git config user.email "ci@gitea.local"
|
||||
git config user.name "Gitea CI"
|
||||
git add solitaire_server/web/pkg/
|
||||
git commit -m "chore(web): regenerate wasm artifacts"
|
||||
# master is unprotected; retry once if the tip moved under us.
|
||||
git push origin HEAD:master || {
|
||||
git fetch origin master
|
||||
git rebase origin/master
|
||||
git push origin HEAD:master
|
||||
}
|
||||
@@ -41,3 +41,7 @@ deploy/*-auth-secret.yaml
|
||||
# Local token-saving helper scripts (peek/cargoclip/testfail/diffclip/etc.) —
|
||||
# inspection-only Go tools, not committed. Tracked scripts/*.sh and *.md stay.
|
||||
scripts/*.go
|
||||
|
||||
# WASM bundles — built by build_wasm.sh locally and by the Docker wasm-builder
|
||||
# stage / web-e2e workflow in CI; never committed (issue #156)
|
||||
solitaire_server/web/pkg/
|
||||
|
||||
+3
-5
@@ -145,7 +145,6 @@ Shared API contract types imported by both the game client (`solitaire_data`) an
|
||||
Owns:
|
||||
- `SyncPayload`, `SyncResponse`, `ConflictReport`
|
||||
- `ChallengeGoal`, `LeaderboardEntry`
|
||||
- `ApiError` enum
|
||||
- Merge logic (pure functions, no I/O)
|
||||
|
||||
### `solitaire_data`
|
||||
@@ -193,7 +192,7 @@ Owns:
|
||||
### `solitaire_wasm`
|
||||
**Dependencies:** `solitaire_core`, `serde`, `serde_json`, `chrono`, `wasm-bindgen`, `serde-wasm-bindgen`.
|
||||
|
||||
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build`; the output lives in `solitaire_server/web/pkg/` and is served statically by the server.
|
||||
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build` (`build_wasm.sh`); the output lands in `solitaire_server/web/pkg/` — gitignored, built in CI (Docker `wasm-builder` stage, web-e2e workflow) — and is served statically by the server.
|
||||
|
||||
Intentionally **does not** depend on `solitaire_data` (which pulls in `dirs`, `keyring`, `reqwest`, and other non-WASM crates). Instead it defines a minimal `Replay` mirror with the same serde shape as `solitaire_data::Replay` — the JSON wire format is the compatibility contract.
|
||||
|
||||
@@ -257,7 +256,7 @@ solitaire_sync::merge(local, remote)
|
||||
│
|
||||
▼
|
||||
Write merged result to disk
|
||||
│ fires SyncCompleteEvent
|
||||
│
|
||||
▼
|
||||
Bevy main thread reads updated StatsResource
|
||||
```
|
||||
@@ -377,7 +376,6 @@ struct StateChangedEvent;
|
||||
struct CardFlippedEvent(u32);
|
||||
struct GameWonEvent { score: i32, time_seconds: u64 }
|
||||
struct AchievementUnlockedEvent(AchievementRecord);
|
||||
struct SyncCompleteEvent(Result<SyncResponse, String>);
|
||||
```
|
||||
|
||||
### Layout System
|
||||
@@ -745,7 +743,7 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
|
||||
| Method | Path | Auth | Notes |
|
||||
|---|---|---|---|
|
||||
| GET | `/replays/:id` | None | Serves `web/index.html`; JS fetches `/api/replays/:id` and steps through via the `solitaire_wasm` WASM module |
|
||||
| GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output) |
|
||||
| GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output — gitignored, produced by `build_wasm.sh` / CI) |
|
||||
|
||||
### Account Management
|
||||
|
||||
|
||||
@@ -6,6 +6,23 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+1
@@ -7333,6 +7333,7 @@ dependencies = [
|
||||
"proptest",
|
||||
"rand 0.10.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
|
||||
+4
-3
@@ -13,9 +13,10 @@
|
||||
# Run from the repo root:
|
||||
# ./build_wasm.sh
|
||||
#
|
||||
# The generated pkg/ files are committed to git so self-hosters who don't
|
||||
# touch the WASM crates can skip this step. Regenerate after any change to
|
||||
# solitaire_wasm/, solitaire_web/, solitaire_engine/, or solitaire_core/.
|
||||
# The generated pkg/ files are NOT committed to git (issue #156). CI builds
|
||||
# them where needed: the Docker image's wasm-builder stage for deployment,
|
||||
# and the web-e2e workflow for browser tests. Run this script locally before
|
||||
# serving /web or /play from a source checkout.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# Live-watch the Gitea Actions deploy pipeline for Ferrous Solitaire.
|
||||
#
|
||||
# Polls recent workflow runs and prints a compact status block each cycle.
|
||||
# Stops when the newest docker-build (the deploy) has completed and no
|
||||
# web-wasm-rebuild is still pending — i.e. the full fix is live.
|
||||
# Stops when the newest docker-build (the deploy) has completed — the wasm
|
||||
# is built inside that image build, so no other workflow gates the deploy.
|
||||
#
|
||||
# Usage: ./scripts/watch_deploy.sh [interval_seconds]
|
||||
# token is read from ~/.config/tea/config.yml (never printed).
|
||||
@@ -49,16 +49,15 @@ for r in runs:
|
||||
r.get("id"), str(r.get("head_sha"))[:7], wf[:18],
|
||||
r.get("status"), r.get("conclusion")))
|
||||
db = [r for r in runs if "docker-build" in str(r.get("path"))]
|
||||
wr_pending = any(r.get("status") != "completed" for r in runs if "web-wasm-rebuild" in str(r.get("path")))
|
||||
top = db[0] if db else None
|
||||
live = bool(top and top.get("status")=="completed" and top.get("conclusion")=="success" and not wr_pending)
|
||||
print("STATE=%s" % ("LIVE" if live else ("WAIT_WASM" if wr_pending else "DEPLOYING")))
|
||||
live = bool(top and top.get("status")=="completed" and top.get("conclusion")=="success")
|
||||
print("STATE=%s" % ("LIVE" if live else "DEPLOYING"))
|
||||
PY
|
||||
)"
|
||||
echo "$out" | grep -v '^STATE='
|
||||
if echo "$out" | grep -q '^STATE=LIVE'; then
|
||||
echo ""
|
||||
echo "DEPLOY LIVE — newest docker-build succeeded, no wasm rebuild pending."
|
||||
echo "DEPLOY LIVE — newest docker-build succeeded."
|
||||
echo " Test: https://klondike.aleshym.co/play?v=${RANDOM}"
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -37,11 +37,6 @@ fn load_settings() -> Settings {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Build the Bevy app without entering the event loop.
|
||||
pub fn build_app(sync_provider: Box<dyn SyncProvider + Send + Sync>) -> App {
|
||||
build_app_with_settings(load_settings(), sync_provider)
|
||||
}
|
||||
|
||||
/// App entry point — configures runtime services, builds, and runs the app.
|
||||
///
|
||||
/// Called from both the desktop `bin` target's `main` shim and (on
|
||||
|
||||
@@ -10,6 +10,7 @@ test-support = []
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -277,6 +277,90 @@ impl<'de> Deserialize<'de> for GameState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Self-contained recording of one deal plus every instruction applied to it.
|
||||
///
|
||||
/// Serialises via the upstream `card_game` [`Session`] serde, whose wire
|
||||
/// format is `{config, initial_state, instructions}` — the dealt board is
|
||||
/// stored **explicitly**, so playback never depends on the seed→deal mapping
|
||||
/// staying stable across RNG or upstream-crate upgrades. This is the payload
|
||||
/// replays must persist; a bare seed is only sufficient for the exact build
|
||||
/// that recorded it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionRecording(Session<Klondike>);
|
||||
|
||||
impl SessionRecording {
|
||||
/// Builds a recording by dealing a fresh board from `seed` and
|
||||
/// force-applying `instructions` **without validation**.
|
||||
///
|
||||
/// Fixture/test aid only — production recordings come from
|
||||
/// [`GameState::recording`], whose history is valid by construction.
|
||||
/// Invalid instructions are absorbed by the upstream session's
|
||||
/// `Option`-based pile pops rather than rejected, so a recording built
|
||||
/// here may not replay cleanly through
|
||||
/// [`GameState::apply_instruction`]'s validation.
|
||||
pub fn from_instructions_unchecked(
|
||||
seed: u64,
|
||||
draw_mode: DrawStockConfig,
|
||||
instructions: impl IntoIterator<Item = KlondikeInstruction>,
|
||||
) -> Self {
|
||||
let mut session = GameState::new_session(seed, draw_mode);
|
||||
for instruction in instructions {
|
||||
session.process_instruction(instruction);
|
||||
}
|
||||
Self(session)
|
||||
}
|
||||
|
||||
/// The dealt board the recording starts from (before any instruction).
|
||||
fn initial_state(&self) -> &Klondike {
|
||||
self.0
|
||||
.history()
|
||||
.first()
|
||||
.map(|snapshot| snapshot.state())
|
||||
.unwrap_or_else(|| self.0.state().state())
|
||||
}
|
||||
|
||||
/// Ordered instruction list, replayable via
|
||||
/// [`GameState::apply_instruction`] against the game returned by
|
||||
/// [`GameState::from_recording`].
|
||||
pub fn instructions(&self) -> Vec<KlondikeInstruction> {
|
||||
self.0
|
||||
.history()
|
||||
.iter()
|
||||
.map(|snapshot| *snapshot.instruction())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Number of recorded instructions.
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.history().len()
|
||||
}
|
||||
|
||||
/// `true` when no instructions have been recorded.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.history().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for SessionRecording {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.initial_state() == other.initial_state() && self.instructions() == other.instructions()
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for SessionRecording {}
|
||||
|
||||
impl Serialize for SessionRecording {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
self.0.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SessionRecording {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
Session::deserialize(deserializer).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl GameState {
|
||||
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
|
||||
pub fn new(seed: u64, draw_mode: DrawStockConfig) -> Self {
|
||||
@@ -296,6 +380,44 @@ impl GameState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of the live session for replay persistence: the dealt board
|
||||
/// plus the forward instruction history (undone moves are absent — the
|
||||
/// session pops them). Serialise the returned [`SessionRecording`] with
|
||||
/// the upstream `card_game` serializers; rebuild playback with
|
||||
/// [`Self::from_recording`].
|
||||
pub fn recording(&self) -> SessionRecording {
|
||||
SessionRecording(self.session.clone())
|
||||
}
|
||||
|
||||
/// Rebuilds the initial-deal game plus the ordered instruction list from
|
||||
/// a [`SessionRecording`].
|
||||
///
|
||||
/// The board and the session config (including draw mode) come from the
|
||||
/// recording itself, so playback is independent of the current build's
|
||||
/// seed→deal mapping. `seed` and `mode` are presentation metadata carried
|
||||
/// alongside the recording by the replay file. Step through the returned
|
||||
/// instructions with [`Self::apply_instruction`], which re-validates each
|
||||
/// one and fails gracefully on corrupt input.
|
||||
pub fn from_recording(
|
||||
recording: &SessionRecording,
|
||||
seed: u64,
|
||||
mode: GameMode,
|
||||
) -> (Self, Vec<KlondikeInstruction>) {
|
||||
let game = Self {
|
||||
mode,
|
||||
elapsed_seconds: 0,
|
||||
seed,
|
||||
take_from_foundation: true,
|
||||
session: Session::new(
|
||||
recording.initial_state().clone(),
|
||||
recording.0.config().clone(),
|
||||
),
|
||||
#[cfg(feature = "test-support")]
|
||||
test_pile_state: None,
|
||||
};
|
||||
(game, recording.instructions())
|
||||
}
|
||||
|
||||
/// Whether the player draws one or three cards from the stock per turn.
|
||||
/// Derived from the underlying session config (set once at deal time).
|
||||
pub fn draw_mode(&self) -> DrawStockConfig {
|
||||
@@ -339,6 +461,32 @@ impl GameState {
|
||||
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
|
||||
/// from the session's instruction history length.
|
||||
pub fn move_count(&self) -> u32 {
|
||||
@@ -1512,4 +1660,77 @@ mod tests {
|
||||
assert!(easy.is_err());
|
||||
assert!(matches!(medium, Ok(Some(_))));
|
||||
}
|
||||
|
||||
/// Play a few real moves on a fresh deal and return the game.
|
||||
fn game_with_some_moves(seed: u64) -> GameState {
|
||||
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
|
||||
for _ in 0..40 {
|
||||
let instructions = game.possible_instructions();
|
||||
let applied = instructions
|
||||
.into_iter()
|
||||
.find(|i| game.clone().apply_instruction(*i).is_ok())
|
||||
.and_then(|i| game.apply_instruction(i).ok());
|
||||
if applied.is_none() && game.draw().is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!game.instruction_history().is_empty(),
|
||||
"test needs at least one recorded move"
|
||||
);
|
||||
game
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_round_trips_through_upstream_serde() {
|
||||
let game = game_with_some_moves(51);
|
||||
let recording = game.recording();
|
||||
let json = serde_json::to_string(&recording).expect("serialize recording");
|
||||
let restored: SessionRecording = serde_json::from_str(&json).expect("parse recording");
|
||||
assert_eq!(recording, restored);
|
||||
assert_eq!(recording.instructions(), game.instruction_history());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_recording_replays_to_identical_board_without_seed_dealing() {
|
||||
let game = game_with_some_moves(145);
|
||||
let recording = game.recording();
|
||||
let json = serde_json::to_string(&recording).expect("serialize recording");
|
||||
let restored: SessionRecording = serde_json::from_str(&json).expect("parse recording");
|
||||
|
||||
// Deliberately pass a DIFFERENT seed: the board must come from the
|
||||
// recording, proving playback no longer depends on seed→deal mapping.
|
||||
let (mut replayed, instructions) =
|
||||
GameState::from_recording(&restored, 0xDEAD_BEEF, game.mode);
|
||||
assert_eq!(replayed.draw_mode(), game.draw_mode());
|
||||
for instruction in instructions {
|
||||
replayed
|
||||
.apply_instruction(instruction)
|
||||
.expect("recorded instruction must replay cleanly");
|
||||
}
|
||||
for pile in [KlondikePile::Stock]
|
||||
.into_iter()
|
||||
.chain(crate::TABLEAUS.map(KlondikePile::Tableau))
|
||||
.chain(crate::FOUNDATIONS.map(KlondikePile::Foundation))
|
||||
{
|
||||
assert_eq!(
|
||||
replayed.pile(pile),
|
||||
game.pile(pile),
|
||||
"pile {pile:?} differs"
|
||||
);
|
||||
}
|
||||
assert_eq!(replayed.waste_cards(), game.waste_cards());
|
||||
assert_eq!(replayed.move_count(), game.move_count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_recording_of_fresh_deal_returns_empty_instructions() {
|
||||
let game = GameState::new(7, DrawStockConfig::DrawThree);
|
||||
let recording = game.recording();
|
||||
assert!(recording.is_empty());
|
||||
let (replayed, instructions) = GameState::from_recording(&recording, 7, game.mode);
|
||||
assert!(instructions.is_empty());
|
||||
assert_eq!(replayed.stock_cards(), game.stock_cards());
|
||||
assert_eq!(replayed.draw_mode(), DrawStockConfig::DrawThree);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,16 @@ pub mod spider;
|
||||
// re-exported — they are only used internally (in `klondike_adapter.rs` and
|
||||
// when decoding instructions to piles in `instruction_to_piles`) and do not
|
||||
// appear in any public method signature.
|
||||
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
|
||||
pub use card_game::{Card, Deck, Rank, SolveError, Suit};
|
||||
pub use klondike::{
|
||||
DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau,
|
||||
};
|
||||
|
||||
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
||||
// former `solitaire_data::solver` wrapper module.
|
||||
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
||||
pub use game_state::{
|
||||
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SessionRecording, SolveOutcome,
|
||||
};
|
||||
|
||||
// Spider rules (second `card_game::Game` implementation; engine UI is a
|
||||
// later phase — nothing outside solitaire_core consumes these yet).
|
||||
|
||||
@@ -105,10 +105,9 @@ pub use stats::{StatsExt, StatsSnapshot};
|
||||
pub mod storage;
|
||||
pub use storage::{
|
||||
TimeAttackSession, cleanup_orphaned_tmp_files, delete_game_state_at,
|
||||
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats,
|
||||
load_stats_from, load_time_attack_session_from, load_time_attack_session_from_at,
|
||||
save_game_state_to, save_stats, save_stats_to, save_time_attack_session_to, stats_file_path,
|
||||
time_attack_session_path, time_attack_session_with_now,
|
||||
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats_from,
|
||||
load_time_attack_session_from, save_game_state_to, save_stats_to, save_time_attack_session_to,
|
||||
stats_file_path, time_attack_session_path,
|
||||
};
|
||||
|
||||
pub mod achievements;
|
||||
@@ -136,11 +135,9 @@ pub use difficulty_seeds::{DifficultySeeds, seeds_for};
|
||||
|
||||
pub mod settings;
|
||||
pub use settings::{
|
||||
AnimSpeed, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
|
||||
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
|
||||
TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP,
|
||||
TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry,
|
||||
load_settings_from, save_settings_to, settings_file_path,
|
||||
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
|
||||
TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry, load_settings_from,
|
||||
save_settings_to, settings_file_path,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
@@ -152,9 +149,7 @@ mod android_keystore;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod auth_tokens;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use auth_tokens::{
|
||||
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
|
||||
};
|
||||
pub use auth_tokens::{TokenError, delete_tokens, store_tokens};
|
||||
|
||||
pub mod sync_client;
|
||||
pub use sync_client::LocalOnlyProvider;
|
||||
|
||||
@@ -12,22 +12,24 @@
|
||||
//! carries any other version so older replays are silently dropped instead
|
||||
//! of crashing the loader.
|
||||
//!
|
||||
//! The recording is intentionally minimal — only the
|
||||
//! [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) inputs that
|
||||
//! successfully advanced the game. `Undo` is **not** recorded: a replay
|
||||
//! represents the canonical path the player ultimately took to win, so
|
||||
//! backed-out missteps simply do not appear in the move list. The starting
|
||||
//! deal is not stored either — the [`seed`](Replay::seed) +
|
||||
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
|
||||
//! for `GameState::new_with_mode` to rebuild the identical layout.
|
||||
//! The payload is a [`SessionRecording`](solitaire_core::SessionRecording):
|
||||
//! the upstream `card_game` session serialisation, which stores the dealt
|
||||
//! board **explicitly** plus the ordered instruction list. `Undo` is not
|
||||
//! recorded: a replay represents the canonical path the player ultimately
|
||||
//! took to win, so backed-out missteps simply do not appear (the session
|
||||
//! pops them from its history).
|
||||
//!
|
||||
//! Storing the deal (rather than re-dealing from [`seed`](Replay::seed) at
|
||||
//! playback time, as schemas ≤ v3 did) makes replays immune to seed→deal
|
||||
//! mapping drift across RNG or upstream-crate upgrades — the exact failure
|
||||
//! that silently broke every pre-upgrade replay. `seed`, `draw_mode`, and
|
||||
//! `mode` remain as presentation/indexing metadata only.
|
||||
//!
|
||||
//! Each recorded move is the player's atomic *input*, not its outcome.
|
||||
//! `KlondikeInstruction::RotateStock` covers every click on the stock pile;
|
||||
//! the engine resolves draw-vs-recycle deterministically from the current
|
||||
//! stock state during playback, so the same input always produces the same
|
||||
//! effect on the same starting deal. Runtime-only pile-position types are
|
||||
//! never serialised — the instruction itself serialises via its compact
|
||||
//! upstream serde representation.
|
||||
//! effect on the same starting deal.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
@@ -35,7 +37,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
||||
use solitaire_core::{DrawStockConfig, SessionRecording, game_state::GameMode};
|
||||
|
||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||
@@ -77,13 +79,19 @@ fn history_schema_v0() -> u32 {
|
||||
/// variants which carried the *outcome* of a stock interaction rather
|
||||
/// than the player's atomic input.
|
||||
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
|
||||
/// - v3 (current): the bespoke `ReplayMove` serde mirror was dropped. Moves
|
||||
/// are now stored directly as upstream
|
||||
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) (compact
|
||||
/// int serde); `StockClick` is now `RotateStock`. Pile-position types are
|
||||
/// runtime-only and are never serialised. v1/v2 files fail to deserialise
|
||||
/// and are discarded by the loader.
|
||||
pub const REPLAY_SCHEMA_VERSION: u32 = 3;
|
||||
/// - v3: the bespoke `ReplayMove` serde mirror was dropped. Moves
|
||||
/// were stored directly as upstream `KlondikeInstruction` (compact
|
||||
/// int serde); `StockClick` became `RotateStock`. Pile-position types are
|
||||
/// runtime-only and are never serialised. The starting deal was still
|
||||
/// rebuilt from the seed at playback time.
|
||||
/// - v4 (current): the bare `moves` list was replaced by a
|
||||
/// [`SessionRecording`](solitaire_core::SessionRecording) — the upstream
|
||||
/// `card_game` session serialisation carrying the dealt board explicitly
|
||||
/// plus the instruction list. Playback no longer re-deals from the seed,
|
||||
/// so replays survive RNG/upstream upgrades that change the seed→deal
|
||||
/// mapping (which invalidated every v3 replay). v1–v3 files fail the
|
||||
/// version gate and are discarded by the loader.
|
||||
pub const REPLAY_SCHEMA_VERSION: u32 = 4;
|
||||
|
||||
/// Default value for [`Replay::schema_version`] when deserialising files
|
||||
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
|
||||
@@ -94,9 +102,10 @@ fn schema_v0() -> u32 {
|
||||
|
||||
/// A complete recording of a single winning game.
|
||||
///
|
||||
/// Replays are reconstructed by rebuilding a fresh
|
||||
/// `GameState::new_with_mode(seed, draw_mode, mode)` and applying the
|
||||
/// [`moves`](Self::moves) in order. The presentation fields
|
||||
/// Replays are reconstructed via
|
||||
/// `GameState::from_recording(&replay.recording, replay.seed, replay.mode)`,
|
||||
/// which rebuilds the recorded deal directly and returns the instruction
|
||||
/// list to step through. The presentation fields
|
||||
/// ([`time_seconds`](Self::time_seconds), [`final_score`](Self::final_score),
|
||||
/// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as
|
||||
/// "Replay (2:14 win on 2026-05-02)".
|
||||
@@ -105,8 +114,9 @@ pub struct Replay {
|
||||
/// Schema version. See [`REPLAY_SCHEMA_VERSION`].
|
||||
#[serde(default = "schema_v0")]
|
||||
pub schema_version: u32,
|
||||
/// Seed used for the deal — replay rasterises the deck via
|
||||
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
|
||||
/// Seed the recorded game was originally dealt from. Presentation /
|
||||
/// indexing metadata only — playback rebuilds the board from
|
||||
/// [`recording`](Self::recording), never by re-dealing this seed.
|
||||
pub seed: u64,
|
||||
/// Draw mode the recorded game was played in.
|
||||
pub draw_mode: DrawStockConfig,
|
||||
@@ -119,11 +129,10 @@ pub struct Replay {
|
||||
pub final_score: i32,
|
||||
/// ISO-8601 date the win was recorded.
|
||||
pub recorded_at: NaiveDate,
|
||||
/// Ordered move list. Each entry is the atomic
|
||||
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) the player
|
||||
/// issued, replayable against a fresh `GameState` constructed from the
|
||||
/// seed via `GameState::apply_instruction`.
|
||||
pub moves: Vec<KlondikeInstruction>,
|
||||
/// The dealt board plus the ordered instruction list, serialised via the
|
||||
/// upstream `card_game` session serializers. Self-contained: playback
|
||||
/// needs nothing else to reproduce the game move-for-move.
|
||||
pub recording: SessionRecording,
|
||||
/// Public share URL for this replay on the active sync backend, set
|
||||
/// by `sync_plugin::poll_replay_upload_result` when the upload
|
||||
/// task resolves. `None` when the player won on a local-only
|
||||
@@ -133,11 +142,12 @@ pub struct Replay {
|
||||
/// [`REPLAY_SCHEMA_VERSION`].
|
||||
#[serde(default)]
|
||||
pub share_url: Option<String>,
|
||||
/// Index into [`moves`](Self::moves) of the move that triggered
|
||||
/// the win condition (i.e. completed the last foundation pile).
|
||||
/// Index into the [`recording`](Self::recording)'s instruction list
|
||||
/// of the move that triggered the win condition (i.e. completed the
|
||||
/// last foundation pile).
|
||||
///
|
||||
/// For replays recorded by the live engine this is always
|
||||
/// `Some(moves.len() - 1)` because recording freezes on win — but
|
||||
/// `Some(recording.len() - 1)` because recording freezes on win — but
|
||||
/// the field is stored explicitly so the playback UI can read it
|
||||
/// directly without re-deriving "the last move was the win" each
|
||||
/// time, and to leave room for future recording semantics that
|
||||
@@ -172,7 +182,7 @@ impl Replay {
|
||||
time_seconds: u64,
|
||||
final_score: i32,
|
||||
recorded_at: NaiveDate,
|
||||
moves: Vec<KlondikeInstruction>,
|
||||
recording: SessionRecording,
|
||||
) -> Self {
|
||||
Self {
|
||||
schema_version: REPLAY_SCHEMA_VERSION,
|
||||
@@ -182,7 +192,7 @@ impl Replay {
|
||||
time_seconds,
|
||||
final_score,
|
||||
recorded_at,
|
||||
moves,
|
||||
recording,
|
||||
share_url: None,
|
||||
win_move_index: None,
|
||||
}
|
||||
@@ -193,7 +203,7 @@ impl Replay {
|
||||
/// [`Replay::new`]:
|
||||
///
|
||||
/// ```ignore
|
||||
/// let replay = Replay::new(...).with_win_move_index(Some(recording.moves.len() - 1));
|
||||
/// let replay = Replay::new(...).with_win_move_index(recording.len().checked_sub(1));
|
||||
/// ```
|
||||
///
|
||||
/// `None` is a valid input — useful for tests that don't care about
|
||||
@@ -430,7 +440,8 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use klondike::{
|
||||
DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau,
|
||||
DstFoundation, DstTableau, Foundation, KlondikeInstruction, KlondikePile,
|
||||
KlondikePileStack, Tableau,
|
||||
};
|
||||
use std::env;
|
||||
|
||||
@@ -447,7 +458,10 @@ mod tests {
|
||||
134,
|
||||
5_120,
|
||||
date,
|
||||
vec![
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
12345,
|
||||
DrawStockConfig::DrawThree,
|
||||
[
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::DstTableau(DstTableau {
|
||||
src: KlondikePileStack::Stock,
|
||||
@@ -459,6 +473,7 @@ mod tests {
|
||||
foundation: Foundation::Foundation1,
|
||||
}),
|
||||
],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -518,25 +533,22 @@ mod tests {
|
||||
/// rolling history wiped on the v0.19.0 update.
|
||||
#[test]
|
||||
fn replay_loads_when_share_url_field_is_absent() {
|
||||
let pre_v019_json = format!(
|
||||
r#"{{
|
||||
"schema_version": {schema},
|
||||
"seed": 1,
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
"time_seconds": 60,
|
||||
"final_score": 100,
|
||||
"recorded_at": "2025-01-01",
|
||||
"moves": []
|
||||
}}"#,
|
||||
schema = REPLAY_SCHEMA_VERSION,
|
||||
);
|
||||
let parsed: Replay = serde_json::from_str(&pre_v019_json)
|
||||
.expect("pre-v0.19.0 replay JSON must still deserialise");
|
||||
// Build a current-schema JSON object, then strip the optional
|
||||
// fields to simulate a file written before they existed.
|
||||
let mut value = serde_json::to_value(sample_replay()).expect("serialise sample");
|
||||
let obj = value.as_object_mut().expect("replay serialises as object");
|
||||
obj.remove("share_url");
|
||||
obj.remove("win_move_index");
|
||||
let parsed: Replay = serde_json::from_value(value)
|
||||
.expect("replay JSON without optional fields must still deserialise");
|
||||
assert!(
|
||||
parsed.share_url.is_none(),
|
||||
"missing share_url field must default to None",
|
||||
);
|
||||
assert!(
|
||||
parsed.win_move_index.is_none(),
|
||||
"missing win_move_index field must default to None",
|
||||
);
|
||||
}
|
||||
|
||||
/// Atomic-write contract — `.tmp` must not be left behind after
|
||||
@@ -588,7 +600,11 @@ mod tests {
|
||||
60,
|
||||
id,
|
||||
date,
|
||||
vec![KlondikeInstruction::RotateStock],
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
id as u64,
|
||||
DrawStockConfig::DrawOne,
|
||||
[KlondikeInstruction::RotateStock],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -824,22 +840,14 @@ mod tests {
|
||||
let path = tmp_path("legacy_no_win_move_index");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
// Hand-rolled minimal current-schema replay JSON with no
|
||||
// win_move_index field — the additive field must still default to None.
|
||||
let no_field = format!(
|
||||
r#"{{
|
||||
"schema_version": {schema},
|
||||
"seed": 1,
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
"time_seconds": 60,
|
||||
"final_score": 100,
|
||||
"recorded_at": "2026-05-02",
|
||||
"moves": []
|
||||
}}"#,
|
||||
schema = REPLAY_SCHEMA_VERSION,
|
||||
);
|
||||
fs::write(&path, no_field).expect("write fixture");
|
||||
// Current-schema replay JSON with the win_move_index field stripped —
|
||||
// the additive field must still default to None.
|
||||
let mut value = serde_json::to_value(sample_replay()).expect("serialise sample");
|
||||
value
|
||||
.as_object_mut()
|
||||
.expect("replay serialises as object")
|
||||
.remove("win_move_index");
|
||||
fs::write(&path, serde_json::to_string(&value).expect("to_string")).expect("write fixture");
|
||||
|
||||
let loaded = load_latest_replay_from(&path).expect("load");
|
||||
assert_eq!(loaded.win_move_index, None);
|
||||
|
||||
@@ -46,22 +46,6 @@ pub fn save_stats_to(path: &Path, stats: &StatsSnapshot) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load stats from the platform default path. Returns default if the path
|
||||
/// is unavailable or the file is missing/corrupt.
|
||||
pub fn load_stats() -> StatsSnapshot {
|
||||
stats_file_path()
|
||||
.map(|p| load_stats_from(&p))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Save stats to the platform default path. Returns an error if the platform
|
||||
/// data dir is unavailable or the write fails.
|
||||
pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
|
||||
let path = stats_file_path()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable"))?;
|
||||
save_stats_to(&path, stats)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-progress game state
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -245,18 +229,6 @@ pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience helper for callers that want to stamp a session with the
|
||||
/// current wall-clock time. Equivalent to constructing the struct
|
||||
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
|
||||
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
|
||||
let now = Utc::now().timestamp().max(0) as u64;
|
||||
TimeAttackSession {
|
||||
remaining_secs,
|
||||
wins,
|
||||
saved_at_unix_secs: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner helper: delete `*.tmp` entries inside `dir`.
|
||||
///
|
||||
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
||||
|
||||
@@ -1343,7 +1343,9 @@ mod tests {
|
||||
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
||||
use solitaire_core::{
|
||||
DrawStockConfig, KlondikeInstruction, SessionRecording, game_state::GameMode,
|
||||
};
|
||||
use solitaire_data::Replay;
|
||||
|
||||
/// Headless app variant that injects a default `ReplayPlaybackState`
|
||||
@@ -1364,7 +1366,11 @@ mod tests {
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![KlondikeInstruction::RotateStock],
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
1,
|
||||
DrawStockConfig::DrawOne,
|
||||
[KlondikeInstruction::RotateStock],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1408,12 +1414,8 @@ mod tests {
|
||||
|
||||
// Frame 1: enter Playing. The observer's first sample sees
|
||||
// `last_was_playing = false` and `now_playing = true`.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||
app.update();
|
||||
assert!(
|
||||
!cinephile_unlocked(&app),
|
||||
@@ -1442,12 +1444,8 @@ mod tests {
|
||||
fn cinephile_does_not_unlock_on_stop_button_abort() {
|
||||
let mut app = cinephile_app();
|
||||
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||
app.update();
|
||||
|
||||
// Direct Playing → Inactive — the path the Stop button takes via
|
||||
@@ -1473,12 +1471,8 @@ mod tests {
|
||||
let mut app = cinephile_app();
|
||||
|
||||
// First completion cycle to unlock.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
@@ -1496,12 +1490,8 @@ mod tests {
|
||||
// Second cycle: Inactive → Playing → Completed once more.
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive;
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
@@ -1520,12 +1510,8 @@ mod tests {
|
||||
fn cinephile_fires_once_across_completed_linger() {
|
||||
let mut app = cinephile_app();
|
||||
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
||||
replay: dummy_replay(),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
};
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||
app.update();
|
||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||
app.update();
|
||||
|
||||
@@ -252,10 +252,17 @@ fn advance_card_anims(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Keep the winit loop awake at full frame rate while slides (including
|
||||
// staggered deals still in their delay phase) are in flight — required
|
||||
// for Android's reactive_low_power focused_mode.
|
||||
if !anims.is_empty() {
|
||||
redraw.write(RequestRedraw);
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
if anim.delay > 0.0 {
|
||||
@@ -558,10 +565,17 @@ fn drive_toast_display(
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut queue: ResMut<ToastQueue>,
|
||||
mut active: ResMut<ActiveToast>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Keep the loop ticking while a toast is displayed or queued so the
|
||||
// countdown advances and the despawn frame isn't held hostage by
|
||||
// Android's reactive_low_power wake ceiling.
|
||||
if active.entity.is_some() || !queue.0.is_empty() {
|
||||
redraw.write(RequestRedraw);
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
|
||||
// Tick down the active toast timer.
|
||||
@@ -593,9 +607,9 @@ pub enum ToastVariant {
|
||||
/// Neutral system message — teal border. Default for `InfoToastEvent`,
|
||||
/// settings volume notifications, and the auto-complete announcement.
|
||||
Info,
|
||||
/// Caution / penalty — gold border. Currently unused by an in-engine
|
||||
/// event; kept so future warning-flavoured toasts have a slot.
|
||||
#[allow(dead_code)]
|
||||
/// Caution / penalty — gold border. Used by [`handle_warning_toast`]
|
||||
/// for `WarningToastEvent` messages (daily-challenge expiry, sync,
|
||||
/// theme-store, and leaderboard warnings).
|
||||
Warning,
|
||||
/// Failure / rejected action — pink border. Used by
|
||||
/// [`handle_move_rejected_toast`] for illegal-placement
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Sound-effect playback via `kira`.
|
||||
//!
|
||||
//! Loads five embedded WAVs (`include_bytes!`) at startup and plays them in
|
||||
//! response to gameplay events:
|
||||
//! Loads seven embedded WAVs (`include_bytes!`) at startup — six SFX plus
|
||||
//! the ambient loop — and plays them in response to gameplay events:
|
||||
//!
|
||||
//! | Event | Sound |
|
||||
//! |---|---|
|
||||
@@ -10,6 +10,7 @@
|
||||
//! | `MoveRejectedEvent` | `card_invalid.wav` |
|
||||
//! | `NewGameRequestEvent` | `card_deal.wav` |
|
||||
//! | `GameWonEvent` | `win_fanfare.wav` |
|
||||
//! | `FoundationCompletedEvent` | `foundation_complete.wav` |
|
||||
//!
|
||||
//! An ambient loop (`ambient_loop.wav`) is started at plugin startup at very
|
||||
//! low volume (0.05 amplitude) routed through `music_track`.
|
||||
@@ -38,7 +39,7 @@ use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
|
||||
const RECYCLE_VOLUME: f64 = 0.5;
|
||||
|
||||
/// Volume amplitude for the ambient music loop placeholder.
|
||||
/// Volume amplitude for the ambient music loop.
|
||||
const AMBIENT_VOLUME: f64 = 0.05;
|
||||
|
||||
/// Converts a linear amplitude (0.0–1.0+) to the `Decibels` type used by
|
||||
@@ -101,7 +102,7 @@ pub struct MuteState {
|
||||
pub music_muted: bool,
|
||||
}
|
||||
|
||||
/// Plays sound effects and background music via `bevy_kira_audio`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
|
||||
/// Plays sound effects and background music via `kira`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
|
||||
pub struct AudioPlugin;
|
||||
|
||||
impl Plugin for AudioPlugin {
|
||||
|
||||
@@ -147,6 +147,7 @@ fn drive_auto_complete(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut moves: MessageWriter<MoveRequestEvent>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if !state.active {
|
||||
return;
|
||||
@@ -154,6 +155,10 @@ fn drive_auto_complete(
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Keepalive: the step-interval cooldown only advances on frames that
|
||||
// actually run, so keep the winit loop awake for the whole burst under
|
||||
// Android's reactive_low_power focused_mode.
|
||||
redraw.write(RequestRedraw);
|
||||
|
||||
state.cooldown -= time.delta_secs();
|
||||
if state.cooldown > 0.0 {
|
||||
|
||||
@@ -18,11 +18,6 @@
|
||||
//! The sine term is 0 at `t = 0` and `t = 1` and peaks at `t = 0.5`, so the
|
||||
//! card "floats up" in the middle of its travel and lands at its correct rest z.
|
||||
//!
|
||||
//! # Retargeting
|
||||
//!
|
||||
//! When a card is redirected mid-flight, call [`retarget_animation`]. It reads
|
||||
//! the current interpolated position so the card never snaps.
|
||||
//!
|
||||
//! # Coexistence with `CardAnim`
|
||||
//!
|
||||
//! `CardAnimation` and the legacy `CardAnim` can coexist in the same world but
|
||||
@@ -33,6 +28,7 @@
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::RequestRedraw;
|
||||
|
||||
use super::curves::{MotionCurve, sample_curve};
|
||||
use super::timing::compute_duration;
|
||||
@@ -122,8 +118,6 @@ impl CardAnimation {
|
||||
}
|
||||
|
||||
/// Returns the current interpolated XY position without advancing time.
|
||||
///
|
||||
/// Used by [`retarget_animation`] to read mid-flight position cleanly.
|
||||
pub fn current_xy(&self) -> Vec2 {
|
||||
if self.duration <= 0.0 {
|
||||
return self.end;
|
||||
@@ -134,90 +128,6 @@ impl CardAnimation {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retarget helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Redirects a card to a new destination without snapping or interrupting motion.
|
||||
///
|
||||
/// Reads the card's current interpolated position (from a live [`CardAnimation`]
|
||||
/// if present, or from `Transform` if stationary) and starts a fresh
|
||||
/// [`CardAnimation`] from that position. Duration is recalculated from the
|
||||
/// remaining distance so short paths stay quick.
|
||||
///
|
||||
/// # Velocity continuity
|
||||
///
|
||||
/// When a card is mid-flight, the new animation starts with a small positive
|
||||
/// `elapsed` offset (`carry`) derived from how far through the current animation
|
||||
/// the card is. This preserves a sense of forward momentum: the new curve does
|
||||
/// not restart from zero velocity, avoiding a visible "lurch" when the target
|
||||
/// changes rapidly.
|
||||
///
|
||||
/// The carry is deliberately small (≤ 10 % of the new duration) so that it
|
||||
/// never causes a visible position jump — the card's start position is still
|
||||
/// read from the current transform.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Inside a system that decides to move a card to a new target:
|
||||
/// let (entity, transform, anim) = cards.get(card_entity)?;
|
||||
/// retarget_animation(
|
||||
/// &mut commands,
|
||||
/// entity,
|
||||
/// anim, // Option<&CardAnimation>
|
||||
/// transform,
|
||||
/// Vec2::new(400.0, 200.0),
|
||||
/// resting_z,
|
||||
/// MotionCurve::SmoothSnap,
|
||||
/// );
|
||||
/// ```
|
||||
pub fn retarget_animation(
|
||||
commands: &mut Commands,
|
||||
entity: Entity,
|
||||
current_anim: Option<&CardAnimation>,
|
||||
transform: &Transform,
|
||||
new_end: Vec2,
|
||||
new_end_z: f32,
|
||||
curve: MotionCurve,
|
||||
) {
|
||||
let (current_xy, current_z, momentum_carry) = match current_anim {
|
||||
Some(anim) if anim.duration > 0.0 => {
|
||||
// Estimate how far into the current animation we are and carry
|
||||
// a small fraction of that progress into the new animation.
|
||||
// This avoids restarting from zero velocity and makes the motion
|
||||
// feel continuous when the target changes mid-flight.
|
||||
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
|
||||
// Cap at 10 % of the new animation so there's no visible jump.
|
||||
let carry = (t * 0.12).min(0.10);
|
||||
(anim.current_xy(), transform.translation.z, carry)
|
||||
}
|
||||
_ => (
|
||||
transform.translation.truncate(),
|
||||
transform.translation.z,
|
||||
0.0,
|
||||
),
|
||||
};
|
||||
|
||||
let distance = current_xy.distance(new_end);
|
||||
let duration = compute_duration(distance);
|
||||
|
||||
commands.entity(entity).insert(CardAnimation {
|
||||
start: current_xy,
|
||||
end: new_end,
|
||||
// Start slightly into the new animation to carry forward momentum.
|
||||
elapsed: momentum_carry * duration,
|
||||
duration,
|
||||
curve,
|
||||
delay: 0.0,
|
||||
start_z: current_z,
|
||||
end_z: new_end_z,
|
||||
z_lift: 8.0,
|
||||
scale_start: 1.0,
|
||||
scale_end: 1.0,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -232,10 +142,18 @@ pub(crate) fn advance_card_animations(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut q: Query<(Entity, &mut Transform, &mut CardAnimation)>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Keep the winit event loop awake while any animation (including one
|
||||
// still in its delay phase) needs per-frame ticks. Without this,
|
||||
// Android's reactive_low_power focused_mode only wakes at its 100 ms
|
||||
// ceiling and card slides render at ~10 fps.
|
||||
if !q.is_empty() {
|
||||
redraw.write(RequestRedraw);
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
|
||||
for (entity, mut transform, mut anim) in &mut q {
|
||||
@@ -283,27 +201,6 @@ pub(crate) fn advance_card_animations(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Win cascade
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Win-cascade scatter targets — 8 points beyond the window edges.
|
||||
///
|
||||
/// Scaled by `radius` (pass `layout.card_size.x * 8.0` for a good result).
|
||||
pub fn win_scatter_targets(radius: f32) -> [Vec2; 8] {
|
||||
let r = radius;
|
||||
[
|
||||
Vec2::new(r, r),
|
||||
Vec2::new(-r, r),
|
||||
Vec2::new(r, -r),
|
||||
Vec2::new(-r, -r),
|
||||
Vec2::new(0.0, r),
|
||||
Vec2::new(0.0, -r),
|
||||
Vec2::new(r, 0.0),
|
||||
Vec2::new(-r, 0.0),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -386,21 +283,4 @@ mod tests {
|
||||
.with_z_lift(12.0);
|
||||
assert!((anim.z_lift - 12.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_scatter_has_eight_targets() {
|
||||
let targets = win_scatter_targets(800.0);
|
||||
assert_eq!(targets.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_scatter_targets_are_off_center() {
|
||||
for t in win_scatter_targets(400.0) {
|
||||
let dist = t.length();
|
||||
assert!(
|
||||
dist > 100.0,
|
||||
"scatter target should be well off-center: {t:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,38 +31,6 @@
|
||||
//! ));
|
||||
//! ```
|
||||
//!
|
||||
//! Retarget a card mid-flight:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use solitaire_engine::card_animation::retarget_animation;
|
||||
//!
|
||||
//! fn handle_drop(
|
||||
//! mut commands: Commands,
|
||||
//! q: Query<(Entity, &Transform, Option<&CardAnimation>), With<CardEntity>>,
|
||||
//! ) {
|
||||
//! let (entity, transform, anim) = q.get(card_entity).unwrap();
|
||||
//! retarget_animation(
|
||||
//! &mut commands,
|
||||
//! entity,
|
||||
//! anim,
|
||||
//! transform,
|
||||
//! new_target_xy,
|
||||
//! new_target_z,
|
||||
//! MotionCurve::SmoothSnap,
|
||||
//! );
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Win cascade with `Expressive` curve
|
||||
//!
|
||||
//! The existing `AnimationPlugin` drives the win cascade with `CardAnim`
|
||||
//! (linear). To use the curve-based cascade instead, disable
|
||||
//! `handle_win_cascade` in `AnimationPlugin` and register `WinCascadePlugin`
|
||||
//! (declared below) which uses `CardAnimation` + `MotionCurve::Expressive`.
|
||||
//!
|
||||
//! They **must not both be active** — both write to `Transform` on the same
|
||||
//! 52 entities and will race.
|
||||
//!
|
||||
//! # Coexistence rules
|
||||
//!
|
||||
//! | Condition | Safe? |
|
||||
@@ -80,24 +48,21 @@ pub mod interaction;
|
||||
pub mod timing;
|
||||
pub mod tuning;
|
||||
|
||||
pub use animation::{CardAnimation, retarget_animation, win_scatter_targets};
|
||||
pub use animation::CardAnimation;
|
||||
pub use chain::AnimationChain;
|
||||
pub use curves::{MotionCurve, sample_curve};
|
||||
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
|
||||
pub use interaction::{BufferedInput, HoverState, InputBuffer};
|
||||
pub use timing::{
|
||||
DEAL_INTERVAL_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
|
||||
cascade_delay, compute_duration, micro_vary,
|
||||
DEAL_INTERVAL_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, compute_duration, micro_vary,
|
||||
};
|
||||
pub use tuning::{AnimationTuning, InputPlatform};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::RequestRedraw;
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::resources::DragState;
|
||||
|
||||
use animation::advance_card_animations;
|
||||
@@ -166,63 +131,6 @@ impl Plugin for CardAnimationPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Optional: win cascade with Expressive curve
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Optional plugin that replaces the linear win cascade in `AnimationPlugin`
|
||||
/// with an `Expressive`-curve cascade.
|
||||
///
|
||||
/// **Do not register this alongside `AnimationPlugin`'s win cascade** — they
|
||||
/// will race on the same card entities. To use this plugin, prevent
|
||||
/// `AnimationPlugin` from handling `GameWonEvent` (or remove it and manage
|
||||
/// win toasts manually).
|
||||
pub struct WinCascadePlugin;
|
||||
|
||||
impl Plugin for WinCascadePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Update, trigger_expressive_win_cascade.after(GameMutation));
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts `CardAnimation` (Expressive curve) on every card when `GameWonEvent` fires.
|
||||
///
|
||||
/// Cards scatter to 8 off-screen positions with per-card stagger. The z-lift
|
||||
/// creates a "burst" effect as cards fly outward.
|
||||
fn trigger_expressive_win_cascade(
|
||||
mut events: MessageReader<GameWonEvent>,
|
||||
cards: Query<(Entity, &Transform), With<CardEntity>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let radius = layout.as_ref().map_or(800.0, |l| l.0.card_size.x * 8.0);
|
||||
|
||||
let targets = win_scatter_targets(radius);
|
||||
|
||||
for (index, (entity, transform)) in cards.iter().enumerate() {
|
||||
let start_xy = transform.translation.truncate();
|
||||
let start_z = transform.translation.z;
|
||||
let target = targets[index % targets.len()];
|
||||
|
||||
commands.entity(entity).insert(
|
||||
CardAnimation::slide(
|
||||
start_xy,
|
||||
start_z,
|
||||
target,
|
||||
start_z + 60.0,
|
||||
MotionCurve::Expressive,
|
||||
)
|
||||
.with_delay(cascade_delay(index, WIN_CASCADE_INTERVAL_SECS))
|
||||
.with_duration(0.65)
|
||||
.with_z_lift(25.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -307,6 +215,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test for the v0.40.0 Android animation-lag bug: commit
|
||||
/// 38e4c03 switched Android to `reactive_low_power` focused_mode on the
|
||||
/// premise that animation systems write `RequestRedraw` while active,
|
||||
/// but the writers were never added — card slides rendered at the 100 ms
|
||||
/// wake ceiling (~10 fps). Active animations MUST emit `RequestRedraw`
|
||||
/// every frame; an idle board must not.
|
||||
#[test]
|
||||
fn active_card_animation_requests_redraw() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(CardAnimationPlugin);
|
||||
|
||||
// Idle board: no redraw requests.
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<Messages<RequestRedraw>>().is_empty(),
|
||||
"no RequestRedraw expected while no animation is active"
|
||||
);
|
||||
|
||||
app.world_mut().spawn((
|
||||
Transform::from_translation(Vec3::ZERO),
|
||||
CardAnimation {
|
||||
start: Vec2::ZERO,
|
||||
end: Vec2::new(100.0, 0.0),
|
||||
elapsed: 0.0,
|
||||
duration: 1.0,
|
||||
curve: MotionCurve::Responsive,
|
||||
delay: 0.0,
|
||||
start_z: 0.0,
|
||||
end_z: 0.0,
|
||||
z_lift: 0.0,
|
||||
scale_start: 1.0,
|
||||
scale_end: 1.0,
|
||||
},
|
||||
));
|
||||
app.update();
|
||||
assert!(
|
||||
!app.world().resource::<Messages<RequestRedraw>>().is_empty(),
|
||||
"an active CardAnimation must write RequestRedraw each frame to \
|
||||
sustain the reactive render loop"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_animation_instant_snaps_on_zero_duration() {
|
||||
let mut app = App::new();
|
||||
@@ -412,19 +363,4 @@ mod tests {
|
||||
let state = HoverState::default();
|
||||
assert!(state.entity.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_scatter_produces_eight_distinct_points() {
|
||||
let targets = win_scatter_targets(600.0);
|
||||
assert_eq!(targets.len(), 8);
|
||||
// All must be different.
|
||||
for i in 0..8 {
|
||||
for j in (i + 1)..8 {
|
||||
assert_ne!(
|
||||
targets[i], targets[j],
|
||||
"scatter targets {i} and {j} must be distinct"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,17 +49,6 @@ pub fn micro_vary(duration: f32, entity_index: u32) -> f32 {
|
||||
duration * (1.0 + variation)
|
||||
}
|
||||
|
||||
/// Returns the pre-animation delay for card at `index` in a staggered cascade.
|
||||
///
|
||||
/// `delay = index × interval_secs`.
|
||||
#[inline]
|
||||
pub fn cascade_delay(index: usize, interval_secs: f32) -> f32 {
|
||||
index as f32 * interval_secs
|
||||
}
|
||||
|
||||
/// Recommended per-card interval for the win cascade (Normal speed).
|
||||
pub const WIN_CASCADE_INTERVAL_SECS: f32 = 0.018;
|
||||
|
||||
/// Recommended per-card interval for deal animations (Normal speed).
|
||||
pub const DEAL_INTERVAL_SECS: f32 = 0.022;
|
||||
|
||||
@@ -137,22 +126,4 @@ mod tests {
|
||||
"micro_vary should differ for different indices"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_delay_zero_index_is_zero() {
|
||||
assert_eq!(cascade_delay(0, 0.018), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_delay_scales_linearly() {
|
||||
let interval = 0.018;
|
||||
for i in 0..52usize {
|
||||
let expected = i as f32 * interval;
|
||||
let actual = cascade_delay(i, interval);
|
||||
assert!(
|
||||
(actual - expected).abs() < 1e-6,
|
||||
"cascade_delay({i}) = {actual}, expected {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ use solitaire_core::KlondikePile;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_core::{Card, Suit};
|
||||
use solitaire_data::AchievementRecord;
|
||||
use solitaire_sync::SyncResponse;
|
||||
|
||||
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
|
||||
/// consumed by `GamePlugin`.
|
||||
@@ -248,16 +247,6 @@ pub struct ToggleLeaderboardRequestEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ToggleHomeRequestEvent;
|
||||
|
||||
/// Fired by `SyncPlugin` after a pull task resolves and the merged result has
|
||||
/// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus
|
||||
/// any `ConflictReport`s the merge produced. `Err(String)` carries a
|
||||
/// human-readable failure message (network, auth, serialization, etc.).
|
||||
///
|
||||
/// UI systems listen for this to refresh views without polling
|
||||
/// `SyncStatusResource`. See [ARCHITECTURE.md §4](../../ARCHITECTURE.md).
|
||||
#[derive(Message, Debug, Clone)]
|
||||
pub struct SyncCompleteEvent(pub Result<SyncResponse, String>);
|
||||
|
||||
/// Generic informational toast message. Any system can fire this to display
|
||||
/// a short string to the player, e.g. "Locked — reach level 5".
|
||||
#[derive(Message, Debug, Clone)]
|
||||
@@ -300,15 +289,6 @@ pub struct ForfeitEvent;
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ForfeitRequestEvent;
|
||||
|
||||
/// Fired when the player clicks "Scan for new themes" in Settings.
|
||||
///
|
||||
/// Consumed by `handle_scan_themes` in `SettingsPlugin`, which scans
|
||||
/// `user_theme_dir()` for `.zip` files, calls `import_theme()` on each
|
||||
/// unrecognised archive, refreshes [`crate::theme::ThemeRegistry`], and
|
||||
/// fires [`InfoToastEvent`] messages to report results.
|
||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||
pub struct ScanThemesRequestEvent;
|
||||
|
||||
/// Fired when the player requests a hint (H key). Carries the source card ID
|
||||
/// and destination pile for visual highlighting.
|
||||
///
|
||||
|
||||
@@ -276,10 +276,16 @@ fn tick_shake_anim(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Sustain full-rate frames for the shake under Android's
|
||||
// reactive_low_power focused_mode.
|
||||
if !anims.is_empty() {
|
||||
redraw.write(RequestRedraw);
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
anim.elapsed += dt;
|
||||
@@ -356,10 +362,16 @@ fn tick_settle_anim(
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Sustain full-rate frames for the settle bounce under Android's
|
||||
// reactive_low_power focused_mode.
|
||||
if !anims.is_empty() {
|
||||
redraw.write(RequestRedraw);
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
for (entity, mut transform, mut anim) in &mut anims {
|
||||
anim.elapsed += dt;
|
||||
@@ -581,10 +593,16 @@ fn tick_foundation_flourish(
|
||||
(Entity, &mut Sprite, &mut FoundationMarkerFlourish),
|
||||
Without<FoundationFlourish>,
|
||||
>,
|
||||
mut redraw: MessageWriter<RequestRedraw>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
// Sustain full-rate frames for the flourish under Android's
|
||||
// reactive_low_power focused_mode.
|
||||
if !card_anims.is_empty() || !marker_anims.is_empty() {
|
||||
redraw.write(RequestRedraw);
|
||||
}
|
||||
let dt = time.delta_secs();
|
||||
|
||||
// Advance the King's scale pulse.
|
||||
|
||||
@@ -1062,11 +1062,16 @@ pub fn record_replay_on_win(
|
||||
if recording.moves.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// The session itself is the authoritative recording: its history
|
||||
// holds the dealt board plus the forward instruction list (undos
|
||||
// already popped), and it serialises via the upstream card_game
|
||||
// serializers so playback never re-deals from the seed.
|
||||
let session_recording = game.0.recording();
|
||||
// Recording freezes on win, so the move that triggered the
|
||||
// win condition is the last one in the list. Storing the
|
||||
// index explicitly lets the playback UI read the WIN MOVE
|
||||
// position directly instead of re-deriving it on every render.
|
||||
let win_move_index = recording.moves.len().checked_sub(1);
|
||||
let win_move_index = session_recording.len().checked_sub(1);
|
||||
let replay = Replay::new(
|
||||
game.0.seed,
|
||||
game.0.draw_mode(),
|
||||
@@ -1074,7 +1079,7 @@ pub fn record_replay_on_win(
|
||||
ev.time_seconds,
|
||||
ev.score,
|
||||
Utc::now().date_naive(),
|
||||
recording.moves.clone(),
|
||||
session_recording,
|
||||
)
|
||||
.with_win_move_index(win_move_index);
|
||||
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
|
||||
|
||||
@@ -853,17 +853,15 @@ fn replay_recording_freezes_into_replay_on_game_won() {
|
||||
let mut app = test_app(7654);
|
||||
app.insert_resource(ReplayPath(Some(path.clone())));
|
||||
|
||||
// Push two recorded instructions manually so we can verify they
|
||||
// survive the freeze/save round-trip without having to drive a
|
||||
// real win. Both are `RotateStock` — the only instruction
|
||||
// constructible without the runtime-only `klondike` pile-stack
|
||||
// types (which the engine intentionally does not depend on); the
|
||||
// round-trip shape is identical for any instruction variant.
|
||||
{
|
||||
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
|
||||
recording.moves.push(KlondikeInstruction::RotateStock);
|
||||
recording.moves.push(KlondikeInstruction::RotateStock);
|
||||
}
|
||||
// Drive two real draws so the *session* history (the source the
|
||||
// freeze now serialises from, via `GameState::recording()`) holds
|
||||
// two instructions. `RotateStock` is the only instruction the
|
||||
// engine can drive without the runtime-only `klondike` pile-stack
|
||||
// types; the round-trip shape is identical for any variant.
|
||||
app.world_mut().write_message(DrawRequestEvent);
|
||||
app.update();
|
||||
app.world_mut().write_message(DrawRequestEvent);
|
||||
app.update();
|
||||
|
||||
// Fire the win event the engine emits when the last foundation
|
||||
// completes — `record_replay_on_win` listens for it.
|
||||
@@ -895,9 +893,15 @@ fn replay_recording_freezes_into_replay_on_game_won() {
|
||||
loaded.time_seconds, 250,
|
||||
"time_seconds must come from the win event"
|
||||
);
|
||||
assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip");
|
||||
assert!(matches!(loaded.moves[0], KlondikeInstruction::RotateStock));
|
||||
assert!(matches!(loaded.moves[1], KlondikeInstruction::RotateStock));
|
||||
let instructions = loaded.recording.instructions();
|
||||
assert_eq!(instructions.len(), 2, "every recorded move must round-trip");
|
||||
assert!(matches!(instructions[0], KlondikeInstruction::RotateStock));
|
||||
assert!(matches!(instructions[1], KlondikeInstruction::RotateStock));
|
||||
assert_eq!(
|
||||
loaded.win_move_index,
|
||||
Some(1),
|
||||
"win move index must point at the last recorded instruction",
|
||||
);
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
@@ -22,7 +22,7 @@ use std::collections::HashMap;
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::input::touch::{TouchInput, TouchPhase, Touches};
|
||||
use bevy::math::{Vec2, Vec3};
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
@@ -1370,17 +1370,37 @@ pub fn best_tableau_destination_for_stack(
|
||||
None
|
||||
}
|
||||
|
||||
/// Decide the auto-move for the face-up run headed by the clicked/tapped card.
|
||||
///
|
||||
/// The move covers **exactly** `run_len` cards — the run from the clicked
|
||||
/// card to the top of its pile. A lone top card goes to its best foundation
|
||||
/// (or tableau) destination; a multi-card run goes whole to the best tableau
|
||||
/// column. Runs larger or smaller than the clicked one are never considered.
|
||||
///
|
||||
/// Returns `(destination, count)`, or `None` when the clicked run has no
|
||||
/// legal destination.
|
||||
pub fn auto_move_for_run(
|
||||
clicked_card: &Card,
|
||||
pile: &KlondikePile,
|
||||
game: &GameState,
|
||||
run_len: usize,
|
||||
) -> Option<(KlondikePile, usize)> {
|
||||
if run_len == 1 {
|
||||
best_destination(clicked_card, game).map(|dest| (dest, 1))
|
||||
} else {
|
||||
best_tableau_destination_for_stack(clicked_card, pile, game, run_len)
|
||||
}
|
||||
}
|
||||
|
||||
/// System that detects double-clicks on face-up cards and fires `MoveRequestEvent`
|
||||
/// to the best legal destination.
|
||||
///
|
||||
/// Move priority:
|
||||
/// 1. Move the single **top** card to its best foundation (or tableau) destination.
|
||||
/// 2. If no single-card move exists and the clicked card is the base of a
|
||||
/// multi-card face-up stack, move the whole stack to the best tableau column.
|
||||
/// The move covers exactly the face-up run headed by the clicked card —
|
||||
/// see [`auto_move_for_run`].
|
||||
///
|
||||
/// When a multi-card stack double-click finds no legal destination (Priority 2
|
||||
/// returns `None`), fires `MoveRejectedEvent` with `from == to == pile` so the
|
||||
/// invalid-move sound plays and the source pile cards shake as feedback.
|
||||
/// When the clicked run has no legal destination, fires `MoveRejectedEvent`
|
||||
/// with `from == to == pile` so the invalid-move sound plays and the source
|
||||
/// pile cards shake as feedback.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_double_click(
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
@@ -1411,7 +1431,11 @@ fn handle_double_click(
|
||||
return;
|
||||
};
|
||||
|
||||
// The topmost card in the draggable run — used as the double-click key.
|
||||
// The clicked card heads the run and keys the double-click: two clicks
|
||||
// on different cards of the same stack are not a double-click.
|
||||
let Some(clicked_card) = card_ids.first() else {
|
||||
return;
|
||||
};
|
||||
let Some(top_card) = card_ids.last() else {
|
||||
return;
|
||||
};
|
||||
@@ -1426,31 +1450,15 @@ fn handle_double_click(
|
||||
|
||||
let now = time.elapsed_secs();
|
||||
let prev = last_click
|
||||
.get(top_card)
|
||||
.get(clicked_card)
|
||||
.copied()
|
||||
.unwrap_or(f32::NEG_INFINITY);
|
||||
|
||||
if now - prev <= DOUBLE_CLICK_WINDOW {
|
||||
// Double-click confirmed.
|
||||
last_click.remove(top_card);
|
||||
last_click.remove(clicked_card);
|
||||
|
||||
// Priority 1: move the single top card (foundation preferred, then tableau).
|
||||
if let Some(dest) = best_destination(top_card, &game.0) {
|
||||
moves.write(MoveRequestEvent {
|
||||
from: pile,
|
||||
to: dest,
|
||||
count: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 2: if the player clicked the base of a multi-card face-up
|
||||
// stack (card_ids.len() > 1), try moving the whole stack to another
|
||||
// tableau column.
|
||||
if card_ids.len() > 1
|
||||
&& let Some((bottom_card, _)) = pile_cards.get(stack_index)
|
||||
&& let Some((dest, count)) =
|
||||
best_tableau_destination_for_stack(bottom_card, &pile, &game.0, card_ids.len())
|
||||
if let Some((dest, count)) = auto_move_for_run(clicked_card, &pile, &game.0, card_ids.len())
|
||||
{
|
||||
moves.write(MoveRequestEvent {
|
||||
from: pile,
|
||||
@@ -1460,14 +1468,10 @@ fn handle_double_click(
|
||||
return;
|
||||
}
|
||||
|
||||
// Both priorities failed — play the invalid-move sound and shake
|
||||
// the source pile as feedback. `MoveRejectedEvent` with
|
||||
// `from == to` routes the shake to the source pile (which
|
||||
// `start_shake_anim` reads from `ev.to`). Pre-fix, this branch
|
||||
// only fired for multi-card stacks, so a double-click on a
|
||||
// single card with no legal destination did nothing — no
|
||||
// sound, no shake. Now both single-card and stack misses get
|
||||
// the same feedback.
|
||||
// No legal destination for the clicked run — play the invalid-move
|
||||
// sound and shake the source pile as feedback. `MoveRejectedEvent`
|
||||
// with `from == to` routes the shake to the source pile (which
|
||||
// `start_shake_anim` reads from `ev.to`).
|
||||
rejected.write(MoveRejectedEvent {
|
||||
from: pile,
|
||||
to: pile,
|
||||
@@ -1475,7 +1479,7 @@ fn handle_double_click(
|
||||
});
|
||||
} else {
|
||||
// Single click — record the time.
|
||||
last_click.insert(top_card.clone(), now);
|
||||
last_click.insert(clicked_card.clone(), now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1491,10 +1495,9 @@ fn handle_double_click(
|
||||
/// `cards`, and `origin_pile`; once `touch_end_drag` fires those fields
|
||||
/// are cleared and the tap/drag distinction is permanently lost.
|
||||
///
|
||||
/// Move priority:
|
||||
/// 1. Single top card to its best foundation (or tableau).
|
||||
/// 2. Whole face-up run to best tableau column when no single-card move exists.
|
||||
/// 3. `MoveRejectedEvent` for audio + shake feedback when no legal move found.
|
||||
/// The move covers exactly the face-up run headed by the tapped card —
|
||||
/// see [`auto_move_for_run`]. Fires `MoveRejectedEvent` for audio + shake
|
||||
/// feedback when the tapped run has no legal destination.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_double_tap(
|
||||
mut touch_events: MessageReader<TouchInput>,
|
||||
@@ -1554,8 +1557,7 @@ fn handle_double_tap(
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((found_card, found_face_up)) = pile_cards.iter().find(|(c, _)| c == top_card)
|
||||
else {
|
||||
let Some((_, found_face_up)) = pile_cards.iter().find(|(c, _)| c == top_card) else {
|
||||
return;
|
||||
};
|
||||
if !*found_face_up {
|
||||
@@ -1591,35 +1593,10 @@ fn handle_double_tap(
|
||||
|
||||
// --- One-tap auto-move (original behaviour) ---
|
||||
|
||||
// Priority 1: move single top card.
|
||||
if let Some(dest) = best_destination(found_card, &game.0) {
|
||||
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
|
||||
if ce.card == *top_card {
|
||||
sprite.color = STATE_SUCCESS;
|
||||
commands.entity(entity).insert(HintHighlight {
|
||||
remaining: DOUBLE_TAP_FLASH_SECS,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
moves.write(MoveRequestEvent {
|
||||
from: *tapped_pile,
|
||||
to: dest,
|
||||
count: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 2: move whole face-up stack to best tableau column.
|
||||
if drag.cards.len() > 1 {
|
||||
let stack_index = pile_cards.len() - drag.cards.len();
|
||||
if let Some((bottom_card, _)) = pile_cards.get(stack_index)
|
||||
&& let Some((dest, count)) = best_tableau_destination_for_stack(
|
||||
bottom_card,
|
||||
tapped_pile,
|
||||
&game.0,
|
||||
drag.cards.len(),
|
||||
)
|
||||
// Move exactly the run headed by the tapped card.
|
||||
if let Some(tapped_card) = drag.cards.first()
|
||||
&& let Some((dest, count)) =
|
||||
auto_move_for_run(tapped_card, tapped_pile, &game.0, drag.cards.len())
|
||||
{
|
||||
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
|
||||
if drag.cards.contains(&ce.card) {
|
||||
@@ -1636,7 +1613,6 @@ fn handle_double_tap(
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
rejected.write(MoveRejectedEvent {
|
||||
from: *tapped_pile,
|
||||
@@ -1806,10 +1782,5 @@ pub fn find_hint(game: &GameState) -> Option<(KlondikePile, KlondikePile)> {
|
||||
all_hints(game).into_iter().next()
|
||||
}
|
||||
|
||||
// `Vec3` is referenced only via the `DRAG_Z` constant; keep the import silenced
|
||||
// when the compiler can't see it used.
|
||||
#[allow(dead_code)]
|
||||
const _VEC3_REFERENCED: Option<Vec3> = None;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -429,6 +429,118 @@ fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// auto_move_for_run pure-function tests (issue #158)
|
||||
// -----------------------------------------------------------------------
|
||||
//
|
||||
// These need real positions — `can_move_cards` validates against the live
|
||||
// session, not the `set_test_*` overlays. Seeds 51 and 145 both deal an
|
||||
// Ace and its Two on tableau tops plus an opposite-color Three elsewhere,
|
||||
// letting two moves build a face-up [Three, Two] run whose top card is
|
||||
// foundation-eligible (the bait the pre-#158 code would take).
|
||||
|
||||
/// Deal `seed`, send the Ace on tableau 1 to its foundation, then stack the
|
||||
/// matching Two from `two_from` onto the opposite-color Three on `run_on`.
|
||||
/// Returns the game with a 2-card face-up run on `run_on`.
|
||||
fn deal_run_with_foundation_bait(seed: u64, two_from: Tableau, run_on: Tableau) -> GameState {
|
||||
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
|
||||
let (ace, _) = game
|
||||
.pile(KlondikePile::Tableau(Tableau::Tableau1))
|
||||
.last()
|
||||
.cloned()
|
||||
.expect("seed deals a card on tableau 1");
|
||||
let foundation = best_destination(&ace, &game).expect("ace has a foundation home");
|
||||
game.move_cards(KlondikePile::Tableau(Tableau::Tableau1), foundation, 1)
|
||||
.expect("ace moves to foundation");
|
||||
game.move_cards(
|
||||
KlondikePile::Tableau(two_from),
|
||||
KlondikePile::Tableau(run_on),
|
||||
1,
|
||||
)
|
||||
.expect("two stacks onto three");
|
||||
game
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_move_for_run_moves_exact_clicked_run_not_top_card() {
|
||||
let game = deal_run_with_foundation_bait(51, Tableau::Tableau6, Tableau::Tableau5);
|
||||
let run_pile = KlondikePile::Tableau(Tableau::Tableau5);
|
||||
let cards = game.pile(run_pile);
|
||||
let (top, _) = cards.last().cloned().expect("run pile has cards");
|
||||
let (clicked, _) = cards[cards.len() - 2].clone();
|
||||
|
||||
// The bait: the lone top card has a foundation move available.
|
||||
assert!(
|
||||
matches!(
|
||||
best_destination(&top, &game),
|
||||
Some(KlondikePile::Foundation(_))
|
||||
),
|
||||
"precondition: run top card must be foundation-eligible"
|
||||
);
|
||||
|
||||
// Clicking the run base must move exactly the 2-card run to a tableau.
|
||||
match auto_move_for_run(&clicked, &run_pile, &game, 2) {
|
||||
Some((KlondikePile::Tableau(dest), 2)) => assert_ne!(dest, Tableau::Tableau5),
|
||||
other => panic!("expected a whole-run tableau move, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_move_for_run_rejects_when_clicked_run_cannot_move() {
|
||||
let game = deal_run_with_foundation_bait(145, Tableau::Tableau4, Tableau::Tableau7);
|
||||
let run_pile = KlondikePile::Tableau(Tableau::Tableau7);
|
||||
let cards = game.pile(run_pile);
|
||||
let (top, _) = cards.last().cloned().expect("run pile has cards");
|
||||
let (clicked, _) = cards[cards.len() - 2].clone();
|
||||
|
||||
assert!(
|
||||
matches!(
|
||||
best_destination(&top, &game),
|
||||
Some(KlondikePile::Foundation(_))
|
||||
),
|
||||
"precondition: run top card must be foundation-eligible"
|
||||
);
|
||||
|
||||
// The 2-card run has no legal home — the top card's foundation move
|
||||
// must NOT be taken as a substitute.
|
||||
assert_eq!(
|
||||
auto_move_for_run(&clicked, &run_pile, &game, 2),
|
||||
None,
|
||||
"an immovable clicked run must not fall back to a top-card move"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_move_for_run_single_card_prefers_foundation() {
|
||||
// Seed 51 after the Ace reaches the foundation: the Two on tableau 6
|
||||
// is a lone face-up card that could go to the foundation OR onto the
|
||||
// Three on tableau 5. Foundation must win.
|
||||
let mut game = GameState::new(51, DrawStockConfig::DrawOne);
|
||||
let (ace, _) = game
|
||||
.pile(KlondikePile::Tableau(Tableau::Tableau1))
|
||||
.last()
|
||||
.cloned()
|
||||
.expect("seed 51 deals a card on tableau 1");
|
||||
let foundation = best_destination(&ace, &game).expect("ace has a foundation home");
|
||||
game.move_cards(KlondikePile::Tableau(Tableau::Tableau1), foundation, 1)
|
||||
.expect("ace moves to foundation");
|
||||
|
||||
let two_pile = KlondikePile::Tableau(Tableau::Tableau6);
|
||||
let (two, _) = game.pile(two_pile).last().cloned().expect("two on top");
|
||||
assert!(
|
||||
game.can_move_cards(&two_pile, &KlondikePile::Tableau(Tableau::Tableau5), 1),
|
||||
"precondition: a tableau destination also exists"
|
||||
);
|
||||
|
||||
assert!(
|
||||
matches!(
|
||||
auto_move_for_run(&two, &two_pile, &game, 1),
|
||||
Some((KlondikePile::Foundation(_), 1))
|
||||
),
|
||||
"a lone top card still prefers the foundation"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #28 — find_hint pure-function tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -83,9 +83,8 @@ pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource};
|
||||
pub use card_animation::{
|
||||
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
|
||||
DEAL_INTERVAL_SECS, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, HoverState, InputBuffer,
|
||||
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, WIN_CASCADE_INTERVAL_SECS,
|
||||
WinCascadePlugin, cascade_delay, compute_duration, micro_vary, retarget_animation,
|
||||
sample_curve, win_scatter_targets,
|
||||
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, compute_duration, micro_vary,
|
||||
sample_curve,
|
||||
};
|
||||
pub use card_plugin::{
|
||||
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
|
||||
@@ -107,7 +106,7 @@ pub use events::{
|
||||
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
||||
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent,
|
||||
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent,
|
||||
ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent,
|
||||
ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
|
||||
XpAwardedEvent,
|
||||
|
||||
@@ -122,12 +122,8 @@ pub(crate) fn format_move_body(instruction: &KlondikeInstruction) -> String {
|
||||
/// `▌ MOVE LOG · COMPLETE` in `Completed`.
|
||||
pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
|
||||
match state {
|
||||
ReplayPlaybackState::Playing { replay, cursor, .. } => {
|
||||
format!(
|
||||
"\u{258C} MOVE LOG \u{00B7} {}/{}",
|
||||
cursor,
|
||||
replay.moves.len()
|
||||
)
|
||||
ReplayPlaybackState::Playing { moves, cursor, .. } => {
|
||||
format!("\u{258C} MOVE LOG \u{00B7} {}/{}", cursor, moves.len())
|
||||
}
|
||||
ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(),
|
||||
ReplayPlaybackState::Inactive => String::new(),
|
||||
@@ -135,7 +131,7 @@ pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
|
||||
}
|
||||
|
||||
/// Pure helper — formats the kth-most-recently-applied move's row
|
||||
/// text. `k = 1` is the active row (`replay.moves[cursor - 1]`,
|
||||
/// text. `k = 1` is the active row (`moves[cursor - 1]`,
|
||||
/// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above
|
||||
/// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`),
|
||||
/// and so on.
|
||||
@@ -147,14 +143,14 @@ pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
|
||||
/// for k=1 and k=2 only, k=3 returns empty).
|
||||
/// - The move list is shorter than expected (defensive guard).
|
||||
pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String {
|
||||
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
|
||||
let ReplayPlaybackState::Playing { moves, cursor, .. } = state else {
|
||||
return String::new();
|
||||
};
|
||||
if k == 0 || k > *cursor {
|
||||
return String::new();
|
||||
}
|
||||
let zero_idx = *cursor - k;
|
||||
let Some(m) = replay.moves.get(zero_idx) else {
|
||||
let Some(m) = moves.get(zero_idx) else {
|
||||
return String::new();
|
||||
};
|
||||
let display_idx = *cursor - k + 1;
|
||||
@@ -162,7 +158,7 @@ pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> St
|
||||
}
|
||||
|
||||
/// Pure helper — formats the kth-NEXT move's row text. `k = 1`
|
||||
/// is the move that will apply next (`replay.moves[cursor]`,
|
||||
/// is the move that will apply next (`moves[cursor]`,
|
||||
/// displayed as `cursor + 1`); `k = 2` is the move after that,
|
||||
/// and so on.
|
||||
///
|
||||
@@ -174,14 +170,14 @@ pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> St
|
||||
/// replay — late in the move list, the trailing next rows
|
||||
/// stay empty).
|
||||
pub(crate) fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String {
|
||||
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
|
||||
let ReplayPlaybackState::Playing { moves, cursor, .. } = state else {
|
||||
return String::new();
|
||||
};
|
||||
if k == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let zero_idx = *cursor + k - 1;
|
||||
let Some(m) = replay.moves.get(zero_idx) else {
|
||||
let Some(m) = moves.get(zero_idx) else {
|
||||
return String::new();
|
||||
};
|
||||
let display_idx = *cursor + k;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::*;
|
||||
@@ -27,960 +25,6 @@ pub(crate) struct ReplayScrubKeyHold {
|
||||
pub(crate) right_held_secs: f32,
|
||||
}
|
||||
|
||||
/// Marker on the keybind-hint footer row at the bottom edge of the
|
||||
/// banner. Carries two `Text` children: a vim-style mode indicator
|
||||
/// (`▌ NORMAL │ replay`) on the left and the keybind hint
|
||||
/// (`[SPACE] pause/resume`) on the right. 1 px top border in
|
||||
/// [`BORDER_SUBTLE`] separates it from the notch-label row above.
|
||||
///
|
||||
/// Surfaces the existing Space-key accelerator visually so the
|
||||
/// UI-first contract from CLAUDE.md §3.3 (every player action has
|
||||
/// a visible UI control) holds for keyboard accelerators too.
|
||||
/// Future commits that wire ESC for stop or ← / → for scrub will
|
||||
/// extend the right-hand text in lockstep — the footer always
|
||||
/// reflects what's actually wired, never aspirational.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayKeybindFooter;
|
||||
|
||||
/// Marker on the bottom-edge **Move Log** panel — a separate root
|
||||
/// UI entity (not a child of the banner) that sits anchored to the
|
||||
/// viewport's bottom edge. Carries a header (`▌ MOVE LOG · N/M`)
|
||||
/// plus a row showing the most-recently-applied move.
|
||||
///
|
||||
/// Spawned by `spawn_overlay` alongside the banner and the
|
||||
/// floating progress chip; despawned by `react_to_state_change`
|
||||
/// on the same `Playing → Inactive` transition. Same lifecycle
|
||||
/// pattern as `ReplayFloatingProgressChip` — a sibling root, not
|
||||
/// a banner child, because it lives at a different screen anchor.
|
||||
///
|
||||
/// First slice of the move-log mockup at
|
||||
/// `docs/ui-mockups/replay-overlay-mobile.html` § "Move Log Card".
|
||||
/// Subsequent commits add prev/next rows and scrolling.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayMoveLogPanel;
|
||||
|
||||
/// Marker on the move-log panel's header `Text`. Carries
|
||||
/// `▌ MOVE LOG · N/M` while a replay is playing; the
|
||||
/// `update_move_log_header` system repaints it as the cursor
|
||||
/// advances.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayMoveLogHeader;
|
||||
|
||||
/// Marker on the move-log panel's active-row `Text`. Carries the
|
||||
/// most-recently-applied move's text (`47 │ waste → tableau 5`)
|
||||
/// when `cursor > 0`; empty when no moves have been applied yet
|
||||
/// (initial spawn) or in `Completed`/`Inactive` states. The
|
||||
/// `update_move_log_active_row` system repaints it as the cursor
|
||||
/// advances.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayMoveLogActiveRow;
|
||||
|
||||
/// Marker on a "previous move" row above the active row.
|
||||
/// `offset` is the 1-based distance backwards from the active
|
||||
/// row: `offset = 1` is the move applied just before the active
|
||||
/// one (e.g. cursor=47 → row reads "46 │ ..."), `offset = 2` is
|
||||
/// the one before that, and so on. Up to [`MOVE_LOG_PREV_ROWS`]
|
||||
/// rows render above the active row.
|
||||
///
|
||||
/// Empty text when there isn't enough history (`offset >= cursor`,
|
||||
/// e.g. cursor=1 has no prev rows; cursor=2 has only the
|
||||
/// `offset = 1` row populated).
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayMoveLogPrevRow {
|
||||
/// Distance backwards from the active row (1-based).
|
||||
pub offset: u8,
|
||||
}
|
||||
|
||||
/// Marker on a "next move" row below the active row. `offset`
|
||||
/// is the 1-based distance forward from the active row:
|
||||
/// `offset = 1` is the move that will apply next
|
||||
/// (`replay.moves[cursor]`, displayed as `cursor + 1`),
|
||||
/// `offset = 2` is the one after that, and so on. Up to
|
||||
/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row.
|
||||
///
|
||||
/// Empty text when there isn't enough remaining replay
|
||||
/// (`cursor + offset - 1 >= moves.len()`, e.g. cursor=99 of
|
||||
/// a 100-move replay shows offset 1 but offset 2 stays empty).
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayOverlayMoveLogNextRow {
|
||||
/// Distance forward from the active row (1-based).
|
||||
pub offset: u8,
|
||||
}
|
||||
|
||||
/// Marker added to every top-level entity spawned by [`spawn_overlay`].
|
||||
/// `react_to_state_change` uses a single `Query<Entity, With<DespawnWithReplay>>`
|
||||
/// to despawn all of them, rather than keeping a separate query per
|
||||
/// entity type. Future sibling overlay surfaces just need this marker
|
||||
/// at spawn time — no changes to the despawn logic required.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct DespawnWithReplay;
|
||||
|
||||
/// Marker on the mini-tableau preview panel root. A right-edge-anchored
|
||||
/// panel that shows a compact summary of the live game state during
|
||||
/// replay: the four foundation tops and the stock / waste heads.
|
||||
/// Spawned as a sibling root entity (same lifecycle pattern as
|
||||
/// [`ReplayOverlayMoveLogPanel`]) at `right: 0`, `top: MINI_TABLEAU_TOP_OFFSET`.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayMiniTableauPanel;
|
||||
|
||||
/// Marker on the foundations row `Text` inside the mini-tableau panel.
|
||||
/// Carries `F: A♠ 7♥ 5♦ K♣` (or `--` for empty slots); repainted by
|
||||
/// `update_mini_tableau` whenever [`GameStateResource`] changes.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayMiniTableauFoundations;
|
||||
|
||||
/// Marker on the stock/waste row `Text` inside the mini-tableau panel.
|
||||
/// Carries `STK:14 WST:7♥`; repainted by `update_mini_tableau` whenever
|
||||
/// [`GameStateResource`] changes.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ReplayMiniTableauStockWaste;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bevy plugin that registers every system needed to drive the replay
|
||||
/// overlay's lifecycle.
|
||||
///
|
||||
/// The plugin is independent of [`crate::replay_playback::ReplayPlaybackPlugin`]
|
||||
/// — it only reads the shared `ReplayPlaybackState` resource. Tests insert
|
||||
/// the resource manually and exercise the overlay in isolation.
|
||||
pub struct ReplayOverlayPlugin;
|
||||
|
||||
impl Plugin for ReplayOverlayPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// The systems are ordered so that, on a single frame:
|
||||
// 1. The state-watcher spawns or despawns the overlay if the
|
||||
// `ReplayPlaybackState` resource changed.
|
||||
// 2. The completion-text update swaps the banner label when the
|
||||
// state is `Completed`.
|
||||
// 3. The progress-text update writes the latest "Move N of M".
|
||||
// 4. The Stop-button click handler reads `Interaction::Pressed`
|
||||
// and calls `stop_replay_playback` (which mutates the state).
|
||||
// Putting Stop last means a click in frame N is observed by
|
||||
// `react_to_state_change` in frame N+1, which then despawns the
|
||||
// overlay in response — a clean state-driven loop.
|
||||
// Step-button handler dispatches into the same canonical move
|
||||
// / draw events that the tick loop fires. Register them
|
||||
// defensively here so this plugin can run under
|
||||
// `MinimalPlugins` without the playback plugin attached;
|
||||
// `add_message` is idempotent so the duplicate registration
|
||||
// in production (alongside `replay_playback`) is harmless.
|
||||
app.init_resource::<ReplayScrubKeyHold>()
|
||||
.add_message::<MoveRequestEvent>()
|
||||
.add_message::<DrawRequestEvent>()
|
||||
.add_message::<UndoRequestEvent>()
|
||||
.add_message::<StateChangedEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
react_to_state_change,
|
||||
update_banner_label,
|
||||
update_progress_text,
|
||||
update_floating_progress_chip,
|
||||
update_scrub_fill,
|
||||
update_move_log_header,
|
||||
update_move_log_active_row,
|
||||
update_move_log_prev_rows,
|
||||
update_move_log_next_rows,
|
||||
update_mini_tableau_foundations,
|
||||
update_mini_tableau_stock_waste,
|
||||
update_pause_button_label,
|
||||
handle_pause_button,
|
||||
handle_step_button,
|
||||
handle_pause_keyboard,
|
||||
handle_stop_keyboard,
|
||||
handle_arrow_keyboard,
|
||||
handle_stop_button,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Reads [`ReplayPlaybackState`] every time the resource changes and either
|
||||
/// spawns or despawns the overlay accordingly. Treats the resource as the
|
||||
/// single source of truth — the spawn / despawn decision is derived from
|
||||
/// `is_playing() || is_completed()` rather than tracking previous-state
|
||||
/// transitions explicitly, which keeps the system stateless.
|
||||
pub(crate) fn react_to_state_change(
|
||||
mut commands: Commands,
|
||||
state: Res<ReplayPlaybackState>,
|
||||
roots: Query<Entity, With<ReplayOverlayRoot>>,
|
||||
despawnable: Query<Entity, With<DespawnWithReplay>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
|
||||
let should_be_visible = state.is_playing() || state.is_completed();
|
||||
let already_spawned = roots.iter().next().is_some();
|
||||
|
||||
if should_be_visible && !already_spawned {
|
||||
spawn_overlay(&mut commands, font_res.as_deref(), &state);
|
||||
} else if !should_be_visible && already_spawned {
|
||||
// Despawn all sibling root entities in one loop — every entity
|
||||
// spawned by `spawn_overlay` carries `DespawnWithReplay` for
|
||||
// exactly this purpose.
|
||||
for entity in &despawnable {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
// The `should_be_visible && already_spawned` branch is a no-op here —
|
||||
// the per-frame text update systems below repaint the banner label
|
||||
// and progress readout in place without a respawn.
|
||||
}
|
||||
|
||||
/// Spawns the banner — a flex-row Node anchored to the top edge of the
|
||||
/// window with three children: the "▌ replay" / "▌ replay complete" label,
|
||||
/// the centred progress text, and the right-aligned Stop button.
|
||||
pub(crate) fn spawn_overlay(
|
||||
commands: &mut Commands,
|
||||
font_res: Option<&FontResource>,
|
||||
state: &ReplayPlaybackState,
|
||||
) {
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
// Clone for the floating chip spawn that runs *after* the
|
||||
// banner's `.with_children(|banner| { ... })` closure consumes
|
||||
// the original `font_handle`. Cheap — Bevy's `Handle<Font>` is
|
||||
// `Arc`-backed, the clone bumps a refcount.
|
||||
let font_handle_for_floating = font_handle.clone();
|
||||
// Second clone for the scrub-bar label row and keybind footer
|
||||
// inside the outer banner closure. The inner top-row closure
|
||||
// consumes the original `font_handle` for the progress-chip
|
||||
// text, so by the time the outer closure reaches the
|
||||
// label-row / footer spawns the original is gone.
|
||||
// `font_handle_for_labels` is `.clone()`'d (never moved) inside
|
||||
// the labels closure, so it's still alive for the footer
|
||||
// spawn afterwards — single shared clone covers both.
|
||||
let font_handle_for_labels = font_handle.clone();
|
||||
// Third clone for the move-log panel — a separate root
|
||||
// entity spawned after the banner closure closes. Mirrors the
|
||||
// floating-chip clone reasoning.
|
||||
let font_handle_for_move_log = font_handle.clone();
|
||||
// Fourth clone for the mini-tableau preview panel.
|
||||
let font_handle_for_mini_tableau = font_handle.clone();
|
||||
|
||||
let banner_label = if state.is_completed() {
|
||||
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
|
||||
} else {
|
||||
"\u{258C} replay" // ▌
|
||||
};
|
||||
let progress_label = format_progress(state);
|
||||
|
||||
// Tableau dim layer — full-screen scrim at z = Z_REPLAY_DIM (= 54).
|
||||
// Spawned first so it sits behind the banner (z=55) and move-log (z=55)
|
||||
// in the UI stacking context. World-space sprites (cards, badges) are
|
||||
// always below any UI node, so the dim layer darkens the entire
|
||||
// gameplay scene without needing to touch card_plugin. No Interaction
|
||||
// component — purely visual.
|
||||
commands.spawn((
|
||||
ReplayTableauDimLayer,
|
||||
DespawnWithReplay,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
top: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, TABLEAU_DIM_ALPHA)),
|
||||
ZIndex(Z_REPLAY_DIM),
|
||||
GlobalZIndex(Z_REPLAY_DIM),
|
||||
));
|
||||
|
||||
let banner_bg = Color::srgba(
|
||||
BG_ELEVATED_HI.to_srgba().red,
|
||||
BG_ELEVATED_HI.to_srgba().green,
|
||||
BG_ELEVATED_HI.to_srgba().blue,
|
||||
BANNER_ALPHA,
|
||||
);
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
ReplayOverlayRoot,
|
||||
DespawnWithReplay,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
top: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(BANNER_HEIGHT),
|
||||
// Column outer so the content row sits above the 1px
|
||||
// scrub bar at the bottom edge.
|
||||
flex_direction: FlexDirection::Column,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(banner_bg),
|
||||
// Pin the banner to its z layer in both the local and the
|
||||
// global stacking context — `GlobalZIndex` matters because
|
||||
// the overlay is a top-level Node (no parent), and Bevy 0.18
|
||||
// has historically had subtle stacking-context drift here.
|
||||
ZIndex(Z_REPLAY_OVERLAY),
|
||||
GlobalZIndex(Z_REPLAY_OVERLAY),
|
||||
))
|
||||
.with_children(|banner| {
|
||||
// Top row: the existing content (label / progress / Stop).
|
||||
banner
|
||||
.spawn(Node {
|
||||
flex_grow: 1.0,
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
justify_content: JustifyContent::SpaceBetween,
|
||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
||||
column_gap: VAL_SPACE_4,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
// Left: column with the accent "▌ replay" headline
|
||||
// above and a small `GAME #YYYY-DDD` caption below.
|
||||
// The caption mirrors the mockup's right-anchored
|
||||
// game identifier but stays visually grouped with
|
||||
// the headline so the two pieces of "this is a
|
||||
// replay of game X" read as a single unit.
|
||||
row.spawn(Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
align_items: AlignItems::FlexStart,
|
||||
row_gap: Val::Px(2.0),
|
||||
..default()
|
||||
})
|
||||
.with_children(|left| {
|
||||
left.spawn((
|
||||
ReplayOverlayBannerText,
|
||||
Text::new(banner_label),
|
||||
TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_HEADLINE,
|
||||
..default()
|
||||
},
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
left.spawn((
|
||||
ReplayOverlayGameCaption,
|
||||
Text::new(format_game_caption(state).unwrap_or_default()),
|
||||
TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
});
|
||||
|
||||
// Centre: progress readout, wrapped in a 1 px
|
||||
// ACCENT_PRIMARY-bordered chip so it reads as a
|
||||
// discrete callout rather than free-floating
|
||||
// text. No fill — the Terminal aesthetic gets
|
||||
// depth from borders + tonal layering, not
|
||||
// shadows. The marker stays on the inner Text so
|
||||
// `update_progress_text` keeps working unchanged.
|
||||
row.spawn((
|
||||
Node {
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||
..default()
|
||||
},
|
||||
BorderColor::all(ACCENT_PRIMARY),
|
||||
))
|
||||
.with_children(|chip| {
|
||||
chip.spawn((
|
||||
ReplayOverlayProgressText,
|
||||
Text::new(progress_label),
|
||||
TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
});
|
||||
|
||||
// Right: Stop button. Tertiary variant — the
|
||||
// action is available but not the loudest element
|
||||
// in the banner; the "Replay" primary accent owns
|
||||
// that slot. `spawn_modal_button` gives us hover /
|
||||
// press paint and focus rings for free via the
|
||||
// existing `UiModalPlugin` paint system.
|
||||
row.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
})
|
||||
.with_children(|wrap| {
|
||||
// Pause / Resume label is set from the current
|
||||
// state so a freshly-spawned overlay (which
|
||||
// currently always starts unpaused) reads
|
||||
// "Pause". `update_pause_button_label`
|
||||
// repaints it whenever the state changes.
|
||||
spawn_modal_button(
|
||||
wrap,
|
||||
ReplayPauseButton,
|
||||
pause_button_label(state),
|
||||
None,
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
wrap,
|
||||
ReplayStepButton,
|
||||
"Step",
|
||||
None,
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
spawn_modal_button(
|
||||
wrap,
|
||||
ReplayStopButton,
|
||||
"Stop",
|
||||
None,
|
||||
ButtonVariant::Tertiary,
|
||||
font_res,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Bottom edge: 1px-tall scrub bar. Track in `BORDER_SUBTLE`,
|
||||
// fill in `ACCENT_PRIMARY`. The fill width is rewritten by
|
||||
// [`update_scrub_fill`] every tick the cursor advances.
|
||||
// Initial fill width matches the spawn-time progress so the
|
||||
// first-frame paint already reflects state instead of
|
||||
// popping from 0 → cursor on the first tick.
|
||||
let initial_scrub_pct = scrub_pct(state);
|
||||
let win_pct = win_move_marker_pct(state);
|
||||
banner
|
||||
.spawn((
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(1.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BORDER_SUBTLE),
|
||||
// HC marker: bumps the 1 px track from #505050
|
||||
// → #a0a0a0 under high-contrast mode. The track
|
||||
// paints via BackgroundColor (it's a 1 px Node,
|
||||
// not a border on a wider container) so the
|
||||
// BorderColor-targeting HighContrastBorder marker
|
||||
// doesn't apply — HighContrastBackground is the
|
||||
// parallel primitive for this case.
|
||||
HighContrastBackground::with_default(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|track| {
|
||||
track.spawn((
|
||||
ReplayOverlayScrubFill,
|
||||
Node {
|
||||
width: Val::Percent(initial_scrub_pct),
|
||||
height: Val::Percent(100.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(ACCENT_PRIMARY),
|
||||
));
|
||||
// WIN MOVE marker — small green tick anchored at
|
||||
// `win_move_index / total`. Spawned only when the
|
||||
// active replay carries the field; older replays
|
||||
// pre-dating `win_move_index` simply don't get a
|
||||
// marker. Centered vertically on the 1px track via
|
||||
// a 3px-tall node offset 1px above the track top so
|
||||
// 1px sits above and 1px below the track line.
|
||||
if let Some(pct) = win_pct {
|
||||
track.spawn((
|
||||
ReplayOverlayWinMoveMarker,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(pct),
|
||||
top: Val::Px(-1.0),
|
||||
width: Val::Px(2.0),
|
||||
height: Val::Px(3.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(STATE_SUCCESS),
|
||||
// HC bump: lime → brighter lime so the win
|
||||
// marker reads clearly above the bumped
|
||||
// notch ticks (BORDER_SUBTLE_HC gray) under
|
||||
// high-contrast mode.
|
||||
HighContrastBackground::with_hc(STATE_SUCCESS, STATE_SUCCESS_HC),
|
||||
));
|
||||
}
|
||||
// Fixed quarter-mark notches: five 1px vertical
|
||||
// ticks at 0 / 25 / 50 / 75 / 100 % that give the
|
||||
// player visual anchor points without needing to
|
||||
// mentally bisect the bar. Painted in
|
||||
// BORDER_SUBTLE — same colour as the unfilled
|
||||
// track — so visibility comes from extending past
|
||||
// the 1px track height (5px tall, anchored 2px
|
||||
// above the track top) rather than colour
|
||||
// contrast. Spawned *after* the WIN MOVE marker
|
||||
// so a notch and the marker landing on the same
|
||||
// percentage paint the marker on top.
|
||||
for pct in scrub_notch_positions() {
|
||||
track.spawn((
|
||||
ReplayOverlayScrubNotch,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(pct),
|
||||
top: Val::Px(-2.0),
|
||||
width: Val::Px(1.0),
|
||||
height: Val::Px(5.0),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BORDER_SUBTLE),
|
||||
// Same HC-paint reasoning as the track
|
||||
// above: 5 px tall × 1 px wide tick mark
|
||||
// paints via BackgroundColor, so
|
||||
// HighContrastBackground (not -Border) is
|
||||
// the right marker.
|
||||
HighContrastBackground::with_default(BORDER_SUBTLE),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
// Third banner row: percentage labels (`0%` / `25%` /
|
||||
// `50%` / `75%` / `100%`) under each scrub-bar notch.
|
||||
// Sibling of (not child of) the 1px track because labels
|
||||
// need their own vertical real estate (TYPE_CAPTION text
|
||||
// doesn't fit inside a 1px container). Position math:
|
||||
// track Node has `Val::Percent(p)` referencing the
|
||||
// banner's full width; this label row also has the
|
||||
// banner's full width, so labels at the same
|
||||
// percentages line up vertically with their notches.
|
||||
let labels = scrub_notch_labels();
|
||||
let positions = scrub_notch_positions();
|
||||
banner
|
||||
.spawn(Node {
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(SCRUB_LABEL_ROW_HEIGHT),
|
||||
position_type: PositionType::Relative,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
for (i, (label, pct)) in labels.iter().zip(positions.iter()).enumerate() {
|
||||
// Endpoints flush to the row's edges; middle
|
||||
// three labels use the `translateX(-50%)`
|
||||
// pattern for Bevy 0.18 UI: a fixed-width
|
||||
// container is placed at `left: Percent(pct)`
|
||||
// then shifted left by half its own width via
|
||||
// `margin.left: Px(-SCRUB_LABEL_CENTER_WIDTH/2)`.
|
||||
// `Justify::Center` renders the text centred
|
||||
// within the container so the text's visual
|
||||
// centre coincides with the notch line.
|
||||
let (node, justify) = if i == 0 {
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(2.0),
|
||||
left: Val::Px(0.0),
|
||||
..default()
|
||||
},
|
||||
Justify::Left,
|
||||
)
|
||||
} else if i == labels.len() - 1 {
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(2.0),
|
||||
right: Val::Px(0.0),
|
||||
..default()
|
||||
},
|
||||
Justify::Right,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(2.0),
|
||||
left: Val::Percent(*pct),
|
||||
width: Val::Px(SCRUB_LABEL_CENTER_WIDTH),
|
||||
margin: UiRect {
|
||||
left: Val::Px(-SCRUB_LABEL_CENTER_WIDTH / 2.0),
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
},
|
||||
Justify::Center,
|
||||
)
|
||||
};
|
||||
row.spawn((
|
||||
ReplayOverlayScrubNotchLabel,
|
||||
node,
|
||||
Text::new(*label),
|
||||
TextLayout::new_with_justify(justify),
|
||||
TextFont {
|
||||
font: font_handle_for_labels.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
// TEXT_SECONDARY keeps the subdued visual
|
||||
// hierarchy (caption, not headline) while
|
||||
// staying readable against BG_ELEVATED_HI.
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
// Fourth banner row: keybind-hint footer. Vim-style
|
||||
// mode line on the left (`▌ NORMAL │ replay`), keybind
|
||||
// hint on the right (`[SPACE] pause/resume`), 1px top
|
||||
// border in BORDER_SUBTLE separating it from the
|
||||
// labels row above. Surfaces the existing Space
|
||||
// accelerator visually so CLAUDE.md §3.3's UI-first
|
||||
// contract holds for keyboard accelerators too.
|
||||
banner
|
||||
.spawn((
|
||||
ReplayOverlayKeybindFooter,
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(KEYBIND_FOOTER_HEIGHT),
|
||||
flex_direction: FlexDirection::Row,
|
||||
justify_content: JustifyContent::SpaceBetween,
|
||||
align_items: AlignItems::Center,
|
||||
padding: UiRect::horizontal(VAL_SPACE_4),
|
||||
border: UiRect::top(Val::Px(1.0)),
|
||||
..default()
|
||||
},
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
// Marker for `apply_high_contrast_borders`: bumps
|
||||
// the 1 px top border from BORDER_SUBTLE (#505050)
|
||||
// to BORDER_SUBTLE_HC (#a0a0a0) when
|
||||
// `Settings::high_contrast_mode` is on. Without
|
||||
// this the footer reads as floating loose under
|
||||
// HC because the border that visually anchors it
|
||||
// to the labels row above is near-invisible.
|
||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|footer| {
|
||||
footer.spawn((
|
||||
Text::new(keybind_footer_mode_text()),
|
||||
TextFont {
|
||||
font: font_handle_for_labels.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
if SHOW_KEYBOARD_ACCELERATORS {
|
||||
footer.spawn((
|
||||
Text::new(keybind_footer_hint_text()),
|
||||
TextFont {
|
||||
font: font_handle_for_labels.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Floating progress chip — a 2D world-space `Text2d` rendered
|
||||
// above the destination pile of the most-recently-applied move.
|
||||
// Sibling of (not child of) the banner overlay because it lives
|
||||
// in world-space coordinates, not the UI tree. Spawned hidden;
|
||||
// `update_floating_progress_chip` shows + positions it on the
|
||||
// first frame the cursor advances past 0. Lifecycle matches
|
||||
// the banner overlay — `react_to_state_change` despawns both
|
||||
// when the replay state transitions back to `Inactive`.
|
||||
commands.spawn((
|
||||
ReplayFloatingProgressChip,
|
||||
DespawnWithReplay,
|
||||
Text2d::new(format_progress(state)),
|
||||
TextFont {
|
||||
font: font_handle_for_floating,
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_PRIMARY),
|
||||
// High Z keeps the chip above every card stack
|
||||
// (Z_DROP_OVERLAY = 50, Z_STOCK_BADGE = 30, regular cards
|
||||
// stack to the low double digits at most).
|
||||
Transform::from_xyz(0.0, 0.0, 100.0),
|
||||
Visibility::Hidden,
|
||||
));
|
||||
|
||||
// Move-log panel — a separate root UI entity anchored to the
|
||||
// viewport's bottom edge. Carries a `▌ MOVE LOG · N/M` header
|
||||
// plus a row showing the most-recently-applied move.
|
||||
// Sibling-of-banner pattern (not a banner child) because the
|
||||
// panel lives at a different screen anchor and has its own
|
||||
// spawn/despawn lifecycle synced via `react_to_state_change`.
|
||||
let banner_bg = Color::srgba(
|
||||
BG_ELEVATED_HI.to_srgba().red,
|
||||
BG_ELEVATED_HI.to_srgba().green,
|
||||
BG_ELEVATED_HI.to_srgba().blue,
|
||||
BANNER_ALPHA,
|
||||
);
|
||||
commands
|
||||
.spawn((
|
||||
ReplayOverlayMoveLogPanel,
|
||||
DespawnWithReplay,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
bottom: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(MOVE_LOG_PANEL_HEIGHT),
|
||||
flex_direction: FlexDirection::Column,
|
||||
align_items: AlignItems::FlexStart,
|
||||
justify_content: JustifyContent::Center,
|
||||
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
||||
row_gap: VAL_SPACE_1,
|
||||
border: UiRect::top(Val::Px(1.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(banner_bg),
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
// Same z-stack rationale as the banner — above gameplay,
|
||||
// below modals.
|
||||
ZIndex(Z_REPLAY_OVERLAY),
|
||||
GlobalZIndex(Z_REPLAY_OVERLAY),
|
||||
// HC marker so the top border bumps under HC mode.
|
||||
// Without it the panel reads as floating loose because
|
||||
// the border that anchors it to the gameplay area above
|
||||
// is near-invisible at #505050.
|
||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|panel| {
|
||||
// Header row: `▌ MOVE LOG · N/M` in ACCENT_PRIMARY for
|
||||
// the cursor-block prefix consistency with the banner
|
||||
// headline.
|
||||
panel.spawn((
|
||||
ReplayOverlayMoveLogHeader,
|
||||
Text::new(format_move_log_header(state)),
|
||||
TextFont {
|
||||
font: font_handle_for_move_log.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
// Prev rows — render above the active row in display
|
||||
// order (oldest first), so the active row sits at the
|
||||
// bottom of the visible window. Spawn from
|
||||
// MOVE_LOG_PREV_ROWS down to 1 (offset 2, then 1) so
|
||||
// the highest-offset (oldest) row is topmost in the
|
||||
// panel's flex column. Each carries
|
||||
// ReplayOverlayMoveLogPrevRow { offset } — the
|
||||
// per-frame system reads `offset` and recomputes the
|
||||
// text on cursor advance. Painted in TEXT_SECONDARY
|
||||
// so the active row stands out from context rows.
|
||||
for offset in (1..=MOVE_LOG_PREV_ROWS as u8).rev() {
|
||||
panel.spawn((
|
||||
ReplayOverlayMoveLogPrevRow { offset },
|
||||
Text::new(format_kth_recent_row(state, offset as usize + 1)),
|
||||
TextFont {
|
||||
font: font_handle_for_move_log.clone(),
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
// Active move row. Wrapped in a Node with an
|
||||
// ACCENT_PRIMARY background so the row reads as
|
||||
// "current focus" — the player can scan vertically
|
||||
// and the highlighted row is the move that just
|
||||
// applied. Empty text at spawn time when cursor=0;
|
||||
// the per-frame update system populates it as the
|
||||
// cursor advances. Text colour is TEXT_PRIMARY_HC
|
||||
// (near-white) for contrast against the brick-red
|
||||
// background — same trick as the modal-button
|
||||
// primary-variant paint.
|
||||
panel
|
||||
.spawn((
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(ACCENT_PRIMARY),
|
||||
))
|
||||
.with_children(|active| {
|
||||
active.spawn((
|
||||
ReplayOverlayMoveLogActiveRow,
|
||||
Text::new(format_active_move_row(state)),
|
||||
TextFont {
|
||||
font: font_handle_for_move_log.clone(),
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_PRIMARY_HC),
|
||||
));
|
||||
});
|
||||
// Next rows — render below the active row in display
|
||||
// order (offset 1 directly below active, then offset
|
||||
// 2). Same TEXT_SECONDARY de-emphasis as prev rows so
|
||||
// the active row stays the focal point. Empty text
|
||||
// late in the replay (when cursor + offset exceeds
|
||||
// moves.len()) — the panel under-fills gracefully.
|
||||
for offset in 1..=MOVE_LOG_NEXT_ROWS as u8 {
|
||||
panel.spawn((
|
||||
ReplayOverlayMoveLogNextRow { offset },
|
||||
Text::new(format_kth_next_row(state, offset as usize)),
|
||||
TextFont {
|
||||
font: font_handle_for_move_log.clone(),
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
// Mini-tableau preview panel — right-edge anchor, just below the banner.
|
||||
// Compact two-row readout: foundation tops then stock/waste head.
|
||||
// Sibling-of-banner pattern (separate root entity, own spawn/despawn).
|
||||
let banner_bg = Color::srgba(
|
||||
BG_ELEVATED_HI.to_srgba().red,
|
||||
BG_ELEVATED_HI.to_srgba().green,
|
||||
BG_ELEVATED_HI.to_srgba().blue,
|
||||
BANNER_ALPHA,
|
||||
);
|
||||
commands
|
||||
.spawn((
|
||||
ReplayMiniTableauPanel,
|
||||
DespawnWithReplay,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
right: Val::Px(0.0),
|
||||
top: Val::Px(MINI_TABLEAU_TOP_OFFSET),
|
||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
||||
flex_direction: FlexDirection::Column,
|
||||
align_items: AlignItems::FlexStart,
|
||||
row_gap: VAL_SPACE_1,
|
||||
border: UiRect::left(Val::Px(1.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(banner_bg),
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
ZIndex(Z_REPLAY_OVERLAY),
|
||||
GlobalZIndex(Z_REPLAY_OVERLAY),
|
||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|panel| {
|
||||
panel.spawn((
|
||||
Text::new("\u{258C} BOARD"),
|
||||
TextFont {
|
||||
font: font_handle_for_mini_tableau.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
panel.spawn((
|
||||
ReplayMiniTableauFoundations,
|
||||
Text::new("F: -- -- -- --"),
|
||||
TextFont {
|
||||
font: font_handle_for_mini_tableau.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
panel.spawn((
|
||||
ReplayMiniTableauStockWaste,
|
||||
Text::new("STK:-- WST:--"),
|
||||
TextFont {
|
||||
font: font_handle_for_mini_tableau,
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Pure helper — returns the scrub-fill width as a percentage of the
|
||||
/// track for the given playback state. `Completed` reads as 100 %;
|
||||
/// `Inactive` and `Playing` with no progress read as 0 %.
|
||||
pub(crate) fn scrub_pct(state: &ReplayPlaybackState) -> f32 {
|
||||
if state.is_completed() {
|
||||
return 100.0;
|
||||
}
|
||||
match state.progress() {
|
||||
Some((_, 0)) | None => 0.0,
|
||||
Some((cursor, total)) => {
|
||||
let frac = (cursor as f32 / total as f32).clamp(0.0, 1.0);
|
||||
frac * 100.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — returns the fixed scrub-bar notch positions as
|
||||
/// percentages along the track. Five evenly-spaced notches at the
|
||||
/// quarter-marks: `[0, 25, 50, 75, 100]`. Function (rather than
|
||||
/// const) so the unit-test surface is obvious and a future
|
||||
/// regression — e.g. someone simplifying to three notches — fails
|
||||
/// at the helper test rather than at visual review.
|
||||
pub(crate) fn scrub_notch_positions() -> [f32; 5] {
|
||||
[0.0, 25.0, 50.0, 75.0, 100.0]
|
||||
}
|
||||
|
||||
/// Pure helper — returns the percentage-label text for each notch,
|
||||
/// in left-to-right order. Paired with [`scrub_notch_positions`] so
|
||||
/// `labels[i]` belongs at `positions[i]`. Lifted to a function for
|
||||
/// the same reason as the positions helper: a clean unit-test
|
||||
/// surface that fails at a regression (e.g. someone simplifying
|
||||
/// `100%` → `MAX`) rather than at visual review.
|
||||
pub(crate) fn scrub_notch_labels() -> [&'static str; 5] {
|
||||
["0%", "25%", "50%", "75%", "100%"]
|
||||
}
|
||||
|
||||
/// Pure helper — returns the vim-style mode indicator text shown on
|
||||
/// the left side of the keybind-hint footer row. `▌ NORMAL │ replay`
|
||||
/// matches the `▌replay.tsx` motif from the splash boot-screen and
|
||||
/// the screen-takeover mockup. The cursor block (`▌`) matches the
|
||||
/// banner-label prefix; "NORMAL" is the vim mode (mockup parity);
|
||||
/// "replay" identifies the surface.
|
||||
pub(crate) fn keybind_footer_mode_text() -> &'static str {
|
||||
"\u{258C} NORMAL \u{2502} replay" // ▌ NORMAL │ replay
|
||||
}
|
||||
|
||||
/// Pure helper — returns the keybind-hint text shown on the right
|
||||
/// side of the keybind-hint footer row. Lists only the keys that
|
||||
/// are *actually wired* today: the Space accelerator for
|
||||
/// pause/resume, the ESC accelerator for stop, and the ← / →
|
||||
/// accelerators for paused single-move stepping. The footer never
|
||||
/// lists unimplemented keybinds (would lie to users).
|
||||
pub(crate) fn keybind_footer_hint_text() -> &'static str {
|
||||
if SHOW_KEYBOARD_ACCELERATORS {
|
||||
"[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — returns the WIN MOVE marker's left-edge position as
|
||||
/// a percentage of the scrub track, or `None` when no marker should
|
||||
/// be drawn.
|
||||
///
|
||||
/// `None` is returned in any of these cases:
|
||||
/// - The state isn't `Playing` (no replay attached).
|
||||
/// - The replay's `win_move_index` is `None` (older replay loaded
|
||||
/// from disk pre-dating the field).
|
||||
/// - The replay's move list is empty (shouldn't happen for real wins,
|
||||
/// but guards the divide-by-zero).
|
||||
///
|
||||
/// The percentage clamps to `[0, 100]` so a malformed
|
||||
/// `win_move_index >= total` (defensive — shouldn't happen) doesn't
|
||||
/// position the marker outside the track.
|
||||
pub(crate) fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
|
||||
let ReplayPlaybackState::Playing { replay, .. } = state else {
|
||||
return None;
|
||||
};
|
||||
let idx = replay.win_move_index?;
|
||||
let total = replay.moves.len();
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
let frac = (idx as f32 / total as f32).clamp(0.0, 1.0);
|
||||
Some(frac * 100.0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Playback-control button handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -383,7 +383,7 @@ pub struct ReplayOverlayMoveLogPrevRow {
|
||||
/// Marker on a "next move" row below the active row. `offset`
|
||||
/// is the 1-based distance forward from the active row:
|
||||
/// `offset = 1` is the move that will apply next
|
||||
/// (`replay.moves[cursor]`, displayed as `cursor + 1`),
|
||||
/// (`moves[cursor]`, displayed as `cursor + 1`),
|
||||
/// `offset = 2` is the one after that, and so on. Up to
|
||||
/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row.
|
||||
///
|
||||
@@ -1258,11 +1258,11 @@ fn keybind_footer_hint_text() -> &'static str {
|
||||
/// `win_move_index >= total` (defensive — shouldn't happen) doesn't
|
||||
/// position the marker outside the track.
|
||||
fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
|
||||
let ReplayPlaybackState::Playing { replay, .. } = state else {
|
||||
let ReplayPlaybackState::Playing { replay, moves, .. } = state else {
|
||||
return None;
|
||||
};
|
||||
let idx = replay.win_move_index?;
|
||||
let total = replay.moves.len();
|
||||
let total = moves.len();
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, SessionRecording, Tableau};
|
||||
use solitaire_core::{Rank, Suit};
|
||||
use solitaire_data::Replay;
|
||||
|
||||
@@ -17,9 +17,11 @@ fn synthetic_replay(move_count: usize) -> Replay {
|
||||
120,
|
||||
1_000,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
|
||||
(0..move_count)
|
||||
.map(|_| KlondikeInstruction::RotateStock)
|
||||
.collect(),
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
42,
|
||||
DrawStockConfig::DrawOne,
|
||||
(0..move_count).map(|_| KlondikeInstruction::RotateStock),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -93,12 +95,7 @@ fn overlay_spawns_when_playback_starts() {
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -117,12 +114,7 @@ fn overlay_progress_text_reflects_cursor() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -138,12 +130,7 @@ fn overlay_stop_button_click_clears_playback() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(overlay_root_count(&mut app), 1);
|
||||
@@ -195,12 +182,7 @@ fn floating_chip_spawns_and_despawns_with_overlay() {
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(5),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -232,12 +214,7 @@ fn overlay_despawns_when_playback_returns_to_inactive() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(3),
|
||||
cursor: 1,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(3), 1, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(overlay_root_count(&mut app), 1);
|
||||
@@ -260,12 +237,7 @@ fn overlay_text_changes_on_completed() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(7),
|
||||
cursor: 7,
|
||||
secs_to_next: 0.0,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(7), 7, 0.0, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(banner_text(&mut app), "\u{258C} replay");
|
||||
@@ -314,30 +286,30 @@ fn scrub_pct_covers_state_corners() {
|
||||
assert_eq!(scrub_pct(&ReplayPlaybackState::Inactive), 0.0);
|
||||
assert_eq!(scrub_pct(&ReplayPlaybackState::Completed), 100.0);
|
||||
assert_eq!(
|
||||
scrub_pct(&ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
}),
|
||||
scrub_pct(&ReplayPlaybackState::playing(
|
||||
synthetic_replay(10),
|
||||
0,
|
||||
0.5,
|
||||
false,
|
||||
)),
|
||||
0.0,
|
||||
);
|
||||
assert_eq!(
|
||||
scrub_pct(&ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
}),
|
||||
scrub_pct(&ReplayPlaybackState::playing(
|
||||
synthetic_replay(10),
|
||||
5,
|
||||
0.5,
|
||||
false,
|
||||
)),
|
||||
50.0,
|
||||
);
|
||||
assert_eq!(
|
||||
scrub_pct(&ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 10,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
}),
|
||||
scrub_pct(&ReplayPlaybackState::playing(
|
||||
synthetic_replay(10),
|
||||
10,
|
||||
0.5,
|
||||
false,
|
||||
)),
|
||||
100.0,
|
||||
);
|
||||
}
|
||||
@@ -367,12 +339,12 @@ fn format_game_caption_covers_state_corners() {
|
||||
// Mar = 31, Apr = 30, May 2 = 122). Synthetic_replay always
|
||||
// uses this date so the assertion is stable.
|
||||
assert_eq!(
|
||||
format_game_caption(&ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
}),
|
||||
format_game_caption(&ReplayPlaybackState::playing(
|
||||
synthetic_replay(10),
|
||||
5,
|
||||
0.5,
|
||||
false,
|
||||
)),
|
||||
Some("GAME #2026-122".to_string()),
|
||||
);
|
||||
|
||||
@@ -381,12 +353,7 @@ fn format_game_caption_covers_state_corners() {
|
||||
let mut early_january = synthetic_replay(10);
|
||||
early_january.recorded_at = NaiveDate::from_ymd_opt(2026, 1, 5).expect("valid date");
|
||||
assert_eq!(
|
||||
format_game_caption(&ReplayPlaybackState::Playing {
|
||||
replay: early_january,
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
}),
|
||||
format_game_caption(&ReplayPlaybackState::playing(early_january, 0, 0.5, false,)),
|
||||
Some("GAME #2026-005".to_string()),
|
||||
);
|
||||
}
|
||||
@@ -399,12 +366,7 @@ fn overlay_game_caption_shows_replay_date() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(game_caption_text(&mut app), "GAME #2026-122");
|
||||
@@ -431,12 +393,7 @@ fn overlay_scrub_fill_tracks_cursor() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8),
|
||||
cursor: 2,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -447,12 +404,7 @@ fn overlay_scrub_fill_tracks_cursor() {
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8),
|
||||
cursor: 6,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(8), 6, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -497,12 +449,7 @@ fn win_move_marker_pct_is_none_for_completed() {
|
||||
fn win_move_marker_pct_is_none_when_replay_lacks_field() {
|
||||
// Synthetic replay constructor leaves win_move_index as None
|
||||
// (legacy / pre-`ab857bb` path).
|
||||
let state = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let state = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
|
||||
assert_eq!(win_move_marker_pct(&state), None);
|
||||
}
|
||||
|
||||
@@ -511,12 +458,12 @@ fn win_move_marker_pct_is_some_at_correct_position() {
|
||||
// 10 moves, win at index 9 → marker sits at 90 % of the track.
|
||||
// Matches the recording semantic: cursor reaches the marker
|
||||
// exactly when the about-to-apply move IS the win move.
|
||||
let state = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10).with_win_move_index(Some(9)),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let state = ReplayPlaybackState::playing(
|
||||
synthetic_replay(10).with_win_move_index(Some(9)),
|
||||
0,
|
||||
0.5,
|
||||
false,
|
||||
);
|
||||
assert_eq!(win_move_marker_pct(&state), Some(90.0));
|
||||
}
|
||||
|
||||
@@ -524,12 +471,12 @@ fn win_move_marker_pct_is_some_at_correct_position() {
|
||||
fn win_move_marker_pct_clamps_to_track_bounds() {
|
||||
// Defensive: if a malformed replay carried `win_move_index >=
|
||||
// total`, the marker must still sit on the track, not past it.
|
||||
let state = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(5).with_win_move_index(Some(99)),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let state = ReplayPlaybackState::playing(
|
||||
synthetic_replay(5).with_win_move_index(Some(99)),
|
||||
0,
|
||||
0.5,
|
||||
false,
|
||||
);
|
||||
assert_eq!(win_move_marker_pct(&state), Some(100.0));
|
||||
}
|
||||
|
||||
@@ -538,12 +485,12 @@ fn marker_spawned_when_replay_has_win_move_index() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8).with_win_move_index(Some(7)),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(
|
||||
synthetic_replay(8).with_win_move_index(Some(7)),
|
||||
0,
|
||||
0.5,
|
||||
false,
|
||||
),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -559,12 +506,7 @@ fn marker_not_spawned_when_replay_lacks_win_move_index() {
|
||||
// Default constructor → win_move_index: None (legacy replay).
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -579,12 +521,12 @@ fn marker_despawns_when_replay_state_returns_to_inactive() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8).with_win_move_index(Some(7)),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(
|
||||
synthetic_replay(8).with_win_move_index(Some(7)),
|
||||
0,
|
||||
0.5,
|
||||
false,
|
||||
),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(win_marker_count(&mut app), 1);
|
||||
@@ -608,12 +550,12 @@ fn win_move_marker_carries_hc_background_marker() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8).with_win_move_index(Some(7)),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(
|
||||
synthetic_replay(8).with_win_move_index(Some(7)),
|
||||
0,
|
||||
0.5,
|
||||
false,
|
||||
),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -669,12 +611,7 @@ fn scrub_notches_spawn_with_overlay() {
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -693,12 +630,7 @@ fn scrub_notches_carry_high_contrast_background_marker() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -723,12 +655,7 @@ fn scrub_track_carries_high_contrast_background_marker() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -759,12 +686,7 @@ fn scrub_notches_despawn_with_overlay() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(scrub_notch_count(&mut app), 5);
|
||||
@@ -821,12 +743,7 @@ fn scrub_notch_labels_spawn_with_overlay() {
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -844,12 +761,7 @@ fn scrub_notch_labels_carry_helper_strings() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -870,12 +782,7 @@ fn scrub_notch_labels_despawn_with_overlay() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(scrub_notch_label_count(&mut app), 5);
|
||||
@@ -947,12 +854,7 @@ fn keybind_footer_spawns_with_overlay() {
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -970,12 +872,7 @@ fn keybind_footer_paints_helper_strings() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -999,12 +896,7 @@ fn keybind_footer_carries_high_contrast_border_marker() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -1025,12 +917,7 @@ fn keybind_footer_despawns_with_overlay() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(keybind_footer_count(&mut app), 1);
|
||||
@@ -1054,12 +941,7 @@ fn scrub_notches_spawn_even_without_win_marker() {
|
||||
// Default constructor → win_move_index: None.
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1144,12 +1026,7 @@ fn format_move_body_handles_stock_cycle() {
|
||||
/// `Inactive` → empty.
|
||||
#[test]
|
||||
fn format_move_log_header_covers_state_branches() {
|
||||
let playing = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 3,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let playing = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
||||
assert_eq!(
|
||||
format_move_log_header(&playing),
|
||||
"\u{258C} MOVE LOG \u{00B7} 3/10"
|
||||
@@ -1167,24 +1044,14 @@ fn format_move_log_header_covers_state_branches() {
|
||||
/// `replay.moves[N - 1]` and the row reads `"N | ..."`.
|
||||
#[test]
|
||||
fn format_active_move_row_handles_cursor_zero_and_positive() {
|
||||
let cursor_zero = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
|
||||
assert_eq!(
|
||||
format_active_move_row(&cursor_zero),
|
||||
"",
|
||||
"cursor=0 means no move applied yet; row stays empty",
|
||||
);
|
||||
|
||||
let cursor_three = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 3,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let cursor_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
||||
// synthetic_replay produces all StockClicks, so the body
|
||||
// is "stock cycle". The displayed index is 3 (cursor),
|
||||
// matching the most-recently-applied move at moves[2].
|
||||
@@ -1208,12 +1075,7 @@ fn move_log_panel_spawns_with_overlay() {
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1232,12 +1094,7 @@ fn move_log_panel_header_paints_helper_string() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(8),
|
||||
cursor: 2,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1254,12 +1111,7 @@ fn move_log_active_row_repaints_on_cursor_advance() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1271,12 +1123,7 @@ fn move_log_active_row_repaints_on_cursor_advance() {
|
||||
// Advance cursor to 2 (most-recently-applied move is moves[1]).
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 2,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1292,12 +1139,7 @@ fn move_log_active_row_repaints_on_cursor_advance() {
|
||||
/// early in a replay don't paint stale text.
|
||||
#[test]
|
||||
fn format_kth_recent_row_handles_in_range_and_out_of_range() {
|
||||
let state_at_three = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 3,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
||||
// k=1 → active (most recent applied). cursor=3 → display=3.
|
||||
assert_eq!(
|
||||
format_kth_recent_row(&state_at_three, 1),
|
||||
@@ -1348,12 +1190,7 @@ fn move_log_prev_rows_spawn_with_panel() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 3,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1371,12 +1208,7 @@ fn move_log_prev_rows_paint_helper_strings_at_spawn() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -1401,12 +1233,7 @@ fn move_log_prev_rows_repaint_on_cursor_advance() {
|
||||
// Start at cursor=2: offset 1 → k=2 → display=1, offset 2 → k=3 → empty (k > cursor).
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 2,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1422,12 +1249,7 @@ fn move_log_prev_rows_repaint_on_cursor_advance() {
|
||||
// Advance to cursor=5 — both offsets now have history.
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1465,12 +1287,7 @@ fn move_log_next_row_text_at_offset(app: &mut App, offset: u8) -> String {
|
||||
/// alongside in-range correctness.
|
||||
#[test]
|
||||
fn format_kth_next_row_handles_in_range_and_out_of_range() {
|
||||
let state_at_three = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 3,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
||||
// k=1 → moves[3], display=4
|
||||
assert_eq!(
|
||||
format_kth_next_row(&state_at_three, 1),
|
||||
@@ -1498,12 +1315,7 @@ fn move_log_next_rows_spawn_with_panel() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 3,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1520,12 +1332,7 @@ fn move_log_next_rows_paint_helper_strings_at_spawn() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -1549,12 +1356,7 @@ fn move_log_next_rows_underfill_at_replay_end() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 9,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 9, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -1578,12 +1380,7 @@ fn active_row_wrapper_carries_accent_primary_background() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 3,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -1621,12 +1418,7 @@ fn active_row_text_uses_high_contrast_color_for_highlight() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 3,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -1647,24 +1439,14 @@ fn active_row_text_uses_high_contrast_color_for_highlight() {
|
||||
/// dropping it has to also update this test.
|
||||
#[test]
|
||||
fn active_row_format_includes_focus_prefix() {
|
||||
let state = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 5,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let state = ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false);
|
||||
let row = format_active_move_row(&state);
|
||||
assert!(
|
||||
row.starts_with('\u{25B6}'),
|
||||
"active-row format must start with ▶ focus marker; got {row:?}",
|
||||
);
|
||||
// Cursor=0 still returns empty, never just the prefix.
|
||||
let cursor_zero = ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
};
|
||||
let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
|
||||
assert_eq!(
|
||||
format_active_move_row(&cursor_zero),
|
||||
"",
|
||||
@@ -1679,12 +1461,7 @@ fn move_log_panel_despawns_with_overlay() {
|
||||
let mut app = headless_app();
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(10),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(move_log_panel_count(&mut app), 1);
|
||||
@@ -1728,21 +1505,11 @@ fn unique_button<M: Component>(app: &mut App) -> Entity {
|
||||
}
|
||||
|
||||
fn pressed_paused_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(replay_len),
|
||||
cursor,
|
||||
secs_to_next: 0.5,
|
||||
paused: true,
|
||||
}
|
||||
ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, true)
|
||||
}
|
||||
|
||||
fn running_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(replay_len),
|
||||
cursor,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
}
|
||||
ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2191,12 +1958,7 @@ fn dim_layer_spawns_and_despawns_with_overlay() {
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(5),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
@@ -2255,12 +2017,7 @@ fn mini_tableau_panel_spawns_and_despawns_with_overlay() {
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(5),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
|
||||
@@ -83,11 +83,11 @@ pub(crate) fn update_floating_progress_chip(
|
||||
// any). `cursor` is the index of the *next* move to apply, so
|
||||
// the most-recently-applied move sits at `cursor - 1`.
|
||||
let dest_pile = match state.as_ref() {
|
||||
ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
|
||||
ReplayPlaybackState::Playing { moves, cursor, .. } if *cursor > 0 => {
|
||||
// The destination pile is recoverable directly from the
|
||||
// instruction — no live state needed. `RotateStock` has no
|
||||
// destination (the chip hides over the stock pile).
|
||||
match &replay.moves[cursor - 1] {
|
||||
match &moves[cursor - 1] {
|
||||
KlondikeInstruction::DstFoundation(dst) => {
|
||||
Some(KlondikePile::Foundation(dst.foundation))
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! In-engine replay playback core.
|
||||
//!
|
||||
//! When the player clicks "Watch replay" on the Stats overlay, the live
|
||||
//! game state is reset to the deal seeded from the replay's `seed` /
|
||||
//! `mode` / `draw_mode`, and the engine ticks through `replay.moves` at a
|
||||
//! steady cadence — firing the canonical [`MoveRequestEvent`] /
|
||||
//! [`DrawRequestEvent`] for each one. The existing animation pipeline
|
||||
//! plays back identically to a live game.
|
||||
//! game state is reset to the recorded deal — rebuilt directly from
|
||||
//! `replay.recording` via `GameState::from_recording`, never by
|
||||
//! re-dealing the seed — and the engine ticks through the recording's
|
||||
//! instruction list at a steady cadence, firing the canonical
|
||||
//! [`MoveRequestEvent`] / [`DrawRequestEvent`] for each one. The
|
||||
//! existing animation pipeline plays back identically to a live game.
|
||||
//!
|
||||
//! ## Public surface
|
||||
//!
|
||||
@@ -95,7 +96,7 @@ pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0;
|
||||
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
|
||||
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
|
||||
/// for each [`KlondikeInstruction`].
|
||||
/// 4. When `cursor == replay.moves.len()`, the state transitions to
|
||||
/// 4. When `cursor == moves.len()`, the state transitions to
|
||||
/// [`Completed`](Self::Completed). It lingers for
|
||||
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
|
||||
/// [`auto_clear_completed_replay`]) before returning to
|
||||
@@ -109,14 +110,20 @@ pub enum ReplayPlaybackState {
|
||||
#[default]
|
||||
Inactive,
|
||||
/// A replay is currently being played back. The overlay reads
|
||||
/// `replay.moves.len()` for the denominator of the progress
|
||||
/// `moves.len()` for the denominator of the progress
|
||||
/// indicator and `cursor` for the numerator.
|
||||
Playing {
|
||||
/// The replay being played back. Owned so the state is the
|
||||
/// only place playback metadata lives — no separate resource
|
||||
/// needed.
|
||||
replay: Replay,
|
||||
/// Index of the next move to apply, in `[0, replay.moves.len()]`.
|
||||
/// needed. Boxed: the v4 recording embeds the dealt board and
|
||||
/// would otherwise dwarf the other variants
|
||||
/// (`clippy::large_enum_variant`).
|
||||
replay: Box<Replay>,
|
||||
/// Instruction list extracted once from `replay.recording` at
|
||||
/// [`start_replay_playback`] time, so the per-frame tick indexes
|
||||
/// a plain slice instead of re-walking the recording.
|
||||
moves: Vec<KlondikeInstruction>,
|
||||
/// Index of the next move to apply, in `[0, moves.len()]`.
|
||||
cursor: usize,
|
||||
/// Seconds remaining until the next move is dispatched.
|
||||
secs_to_next: f32,
|
||||
@@ -138,6 +145,19 @@ pub enum ReplayPlaybackState {
|
||||
}
|
||||
|
||||
impl ReplayPlaybackState {
|
||||
/// Construct a [`Self::Playing`] state, extracting the instruction
|
||||
/// list from `replay.recording` once up front.
|
||||
pub fn playing(replay: Replay, cursor: usize, secs_to_next: f32, paused: bool) -> Self {
|
||||
let moves = replay.recording.instructions();
|
||||
Self::Playing {
|
||||
replay: Box::new(replay),
|
||||
moves,
|
||||
cursor,
|
||||
secs_to_next,
|
||||
paused,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when a replay is currently being played back.
|
||||
pub fn is_playing(&self) -> bool {
|
||||
matches!(self, Self::Playing { .. })
|
||||
@@ -156,7 +176,7 @@ impl ReplayPlaybackState {
|
||||
/// the total is no longer available in `Completed`.
|
||||
pub fn progress(&self) -> Option<(usize, usize)> {
|
||||
match self {
|
||||
Self::Playing { replay, cursor, .. } => Some((*cursor, replay.moves.len())),
|
||||
Self::Playing { moves, cursor, .. } => Some((*cursor, moves.len())),
|
||||
Self::Inactive | Self::Completed => None,
|
||||
}
|
||||
}
|
||||
@@ -191,7 +211,11 @@ pub fn start_replay_playback(
|
||||
) {
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
|
||||
// The recording carries the dealt board and the session config
|
||||
// (including draw mode), so the fresh game is bit-identical to the
|
||||
// recorded deal regardless of how the current build maps seeds to
|
||||
// deals — `replay.seed` is presentation metadata only.
|
||||
let (fresh, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
|
||||
commands.insert_resource(GameStateResource(fresh));
|
||||
|
||||
// Initial `secs_to_next` uses the constant rather than reading
|
||||
@@ -201,7 +225,8 @@ pub fn start_replay_playback(
|
||||
// unusually short setting; subsequent ticks read the live setting
|
||||
// every frame via [`tick_replay_playback`].
|
||||
**state = ReplayPlaybackState::Playing {
|
||||
replay,
|
||||
replay: Box::new(replay),
|
||||
moves,
|
||||
cursor: 0,
|
||||
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
|
||||
paused: false,
|
||||
@@ -256,7 +281,7 @@ pub fn step_replay_playback(
|
||||
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
||||
) -> bool {
|
||||
let ReplayPlaybackState::Playing {
|
||||
replay,
|
||||
moves,
|
||||
cursor,
|
||||
paused: true,
|
||||
..
|
||||
@@ -264,10 +289,10 @@ pub fn step_replay_playback(
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if *cursor >= replay.moves.len() {
|
||||
if *cursor >= moves.len() {
|
||||
return false;
|
||||
}
|
||||
let instruction = replay.moves[*cursor];
|
||||
let instruction = moves[*cursor];
|
||||
dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer);
|
||||
*cursor += 1;
|
||||
true
|
||||
@@ -362,7 +387,7 @@ pub fn step_backwards_replay_playback(
|
||||
/// Drains `secs_to_next` by `time.delta_secs()`. When the countdown
|
||||
/// expires, fires the canonical event for the move at `cursor`,
|
||||
/// increments `cursor`, and resets `secs_to_next`. When `cursor`
|
||||
/// reaches `replay.moves.len()`, transitions to
|
||||
/// reaches `moves.len()`, transitions to
|
||||
/// [`ReplayPlaybackState::Completed`].
|
||||
///
|
||||
/// The advance loop is a `while`, not an `if`, so coarse time steps
|
||||
@@ -384,10 +409,11 @@ fn tick_replay_playback(
|
||||
let mut transition_to_completed = false;
|
||||
|
||||
if let ReplayPlaybackState::Playing {
|
||||
replay,
|
||||
moves,
|
||||
cursor,
|
||||
secs_to_next,
|
||||
paused,
|
||||
..
|
||||
} = state.as_mut()
|
||||
{
|
||||
// While paused, the cursor and the timer freeze together —
|
||||
@@ -397,8 +423,8 @@ fn tick_replay_playback(
|
||||
// path.
|
||||
if !*paused {
|
||||
*secs_to_next -= dt;
|
||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
||||
let instruction = replay.moves[*cursor];
|
||||
while *secs_to_next <= 0.0 && *cursor < moves.len() {
|
||||
let instruction = moves[*cursor];
|
||||
dispatch_instruction(
|
||||
instruction,
|
||||
*cursor,
|
||||
@@ -410,7 +436,7 @@ fn tick_replay_playback(
|
||||
*secs_to_next += interval;
|
||||
}
|
||||
|
||||
if *cursor >= replay.moves.len() {
|
||||
if *cursor >= moves.len() {
|
||||
transition_to_completed = true;
|
||||
}
|
||||
}
|
||||
@@ -562,8 +588,7 @@ mod tests {
|
||||
use crate::game_plugin::GamePlugin;
|
||||
use bevy::time::TimeUpdateStrategy;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::KlondikeInstruction;
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||
use solitaire_core::{DrawStockConfig, SessionRecording, game_state::GameMode};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
||||
@@ -612,11 +637,11 @@ mod tests {
|
||||
60,
|
||||
500,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::RotateStock,
|
||||
KlondikeInstruction::RotateStock,
|
||||
],
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
12345,
|
||||
DrawStockConfig::DrawOne,
|
||||
[KlondikeInstruction::RotateStock; 3],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -673,11 +698,14 @@ mod tests {
|
||||
let state = app.world().resource::<ReplayPlaybackState>();
|
||||
match state {
|
||||
ReplayPlaybackState::Playing {
|
||||
cursor, replay: r, ..
|
||||
cursor,
|
||||
replay: r,
|
||||
moves,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*cursor, 0);
|
||||
assert_eq!(r.seed, replay.seed);
|
||||
assert_eq!(r.moves.len(), 3);
|
||||
assert_eq!(moves.len(), 3);
|
||||
}
|
||||
other => panic!("expected Playing, got {other:?}"),
|
||||
}
|
||||
@@ -778,7 +806,11 @@ mod tests {
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![KlondikeInstruction::RotateStock],
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
42,
|
||||
DrawStockConfig::DrawOne,
|
||||
[KlondikeInstruction::RotateStock],
|
||||
),
|
||||
);
|
||||
start_playback(&mut app, one_move);
|
||||
app.update();
|
||||
@@ -887,7 +919,11 @@ mod tests {
|
||||
10,
|
||||
100,
|
||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||
vec![KlondikeInstruction::RotateStock; 10],
|
||||
SessionRecording::from_instructions_unchecked(
|
||||
7,
|
||||
DrawStockConfig::DrawOne,
|
||||
[KlondikeInstruction::RotateStock; 10],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1939,7 +1939,11 @@ mod tests {
|
||||
time_seconds,
|
||||
0,
|
||||
date,
|
||||
vec![],
|
||||
solitaire_core::SessionRecording::from_instructions_unchecked(
|
||||
1,
|
||||
solitaire_core::DrawStockConfig::DrawOne,
|
||||
[],
|
||||
),
|
||||
);
|
||||
r.share_url = share_url;
|
||||
r
|
||||
|
||||
@@ -22,12 +22,11 @@ use solitaire_data::{
|
||||
AchievementRecord, PlayerProgress, Replay, StatsSnapshot, SyncError, SyncProvider,
|
||||
save_achievements_to, save_progress_to, save_replay_history_to, save_stats_to,
|
||||
};
|
||||
use solitaire_sync::{SyncPayload, SyncResponse, merge};
|
||||
use solitaire_sync::{SyncPayload, merge};
|
||||
|
||||
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
||||
use crate::events::{
|
||||
GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent, SyncConfigureRequestEvent,
|
||||
WarningToastEvent,
|
||||
GameWonEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, WarningToastEvent,
|
||||
};
|
||||
use crate::game_plugin::RecordingReplay;
|
||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||
@@ -108,7 +107,6 @@ impl Plugin for SyncPlugin {
|
||||
.init_resource::<PullTask>()
|
||||
.init_resource::<PendingReplayUpload>()
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
.add_message::<SyncCompleteEvent>()
|
||||
.add_message::<SyncConfigureRequestEvent>()
|
||||
.add_message::<WarningToastEvent>();
|
||||
|
||||
@@ -198,7 +196,6 @@ fn poll_pull_result(
|
||||
achievements_path: Res<AchievementsStoragePath>,
|
||||
mut progress: ResMut<ProgressResource>,
|
||||
progress_path: Res<ProgressStoragePath>,
|
||||
mut complete_writer: MessageWriter<SyncCompleteEvent>,
|
||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||
) {
|
||||
@@ -213,7 +210,7 @@ fn poll_pull_result(
|
||||
match result {
|
||||
Ok(remote) => {
|
||||
let local = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||
let (merged, conflicts) = merge(&local, &remote);
|
||||
let (merged, _conflicts) = merge(&local, &remote);
|
||||
|
||||
// Persist merged state atomically.
|
||||
if let Some(p) = &stats_path.0
|
||||
@@ -233,17 +230,10 @@ fn poll_pull_result(
|
||||
}
|
||||
|
||||
// Update in-world resources.
|
||||
let now = Utc::now();
|
||||
stats.0 = merged.stats.clone();
|
||||
achievements.0 = merged.achievements.clone();
|
||||
progress.0 = merged.progress.clone();
|
||||
status.0 = SyncStatus::LastSynced(now);
|
||||
|
||||
complete_writer.write(SyncCompleteEvent(Ok(SyncResponse {
|
||||
merged,
|
||||
server_time: now,
|
||||
conflicts,
|
||||
})));
|
||||
stats.0 = merged.stats;
|
||||
achievements.0 = merged.achievements;
|
||||
progress.0 = merged.progress;
|
||||
status.0 = SyncStatus::LastSynced(Utc::now());
|
||||
}
|
||||
Err(SyncError::UnsupportedPlatform) => {
|
||||
// No backend configured — not an error, just leave status as Idle.
|
||||
@@ -266,8 +256,7 @@ fn poll_pull_result(
|
||||
if matches!(e, SyncError::Auth(_)) {
|
||||
configure_sync.write(SyncConfigureRequestEvent);
|
||||
}
|
||||
status.0 = SyncStatus::Error(msg.clone());
|
||||
complete_writer.write(SyncCompleteEvent(Err(msg)));
|
||||
status.0 = SyncStatus::Error(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,7 +335,11 @@ fn push_replay_on_win(
|
||||
ev.time_seconds,
|
||||
ev.score,
|
||||
Utc::now().date_naive(),
|
||||
recording.moves.clone(),
|
||||
// The live session is the authoritative recording; it
|
||||
// serialises via the upstream card_game serializers so web
|
||||
// playback rebuilds the exact deal instead of re-dealing
|
||||
// the seed.
|
||||
game.0.recording(),
|
||||
);
|
||||
let provider = provider.0.clone();
|
||||
let rt = rt.0.clone();
|
||||
@@ -632,7 +625,11 @@ mod tests {
|
||||
60,
|
||||
500,
|
||||
chrono::NaiveDate::from_ymd_opt(2026, 5, 6).expect("valid date"),
|
||||
vec![],
|
||||
solitaire_core::SessionRecording::from_instructions_unchecked(
|
||||
7,
|
||||
DrawStockConfig::DrawOne,
|
||||
[],
|
||||
),
|
||||
);
|
||||
let history = ReplayHistory {
|
||||
schema_version: solitaire_data::REPLAY_HISTORY_SCHEMA_VERSION,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//! shake duration elapses.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_core::game_state::{GameMode, GameState};
|
||||
use solitaire_core::scoring::compute_time_bonus;
|
||||
use solitaire_data::AnimSpeed;
|
||||
|
||||
@@ -93,6 +93,40 @@ pub struct WinSummaryPending {
|
||||
/// score-breakdown reveal can format the mode-multiplier row
|
||||
/// (e.g. `Zen ×0.0`, `Classic ×1.0`).
|
||||
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.
|
||||
@@ -492,6 +526,7 @@ fn cache_win_data(
|
||||
pending.challenge_level = challenge_level;
|
||||
pending.undo_count = game.0.undo_count();
|
||||
pending.mode = game.0.mode;
|
||||
pending.move_detail = build_move_detail(&game.0);
|
||||
|
||||
if is_new_record {
|
||||
toast.write(InfoToastEvent("New Record!".to_string()));
|
||||
@@ -913,6 +948,18 @@ 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;
|
||||
// excess is summarised with "...and N more".
|
||||
if !session.names.is_empty() {
|
||||
@@ -1288,6 +1335,49 @@ mod tests {
|
||||
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]
|
||||
fn build_xp_detail_slow_win_with_undo() {
|
||||
// 300s >= 120s → no speed bonus; undo used → no no-undo bonus.
|
||||
|
||||
@@ -1,3 +1,68 @@
|
||||
# --- WASM build stage ---
|
||||
# Builds solitaire_server/web/pkg/ (replay viewer + Bevy canvas app) from
|
||||
# source. The artifacts are not committed to the repo (issue #156); this
|
||||
# stage is their single source of truth for deployment.
|
||||
FROM rust:1.95-slim AS wasm-builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN rustup target add wasm32-unknown-unknown
|
||||
|
||||
# Pinned wasm toolchain — keep versions in sync with
|
||||
# .gitea/workflows/web-e2e.yml and build_wasm.sh prerequisites.
|
||||
RUN curl -sSL https://github.com/rustwasm/wasm-bindgen/releases/download/0.2.120/wasm-bindgen-0.2.120-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar xz -C /opt \
|
||||
&& ln -s /opt/wasm-bindgen-0.2.120-x86_64-unknown-linux-musl/wasm-bindgen /usr/local/bin/wasm-bindgen \
|
||||
&& curl -sSL https://github.com/rustwasm/wasm-pack/releases/download/v0.14.0/wasm-pack-v0.14.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar xz -C /opt \
|
||||
&& ln -s /opt/wasm-pack-v0.14.0-x86_64-unknown-linux-musl/wasm-pack /usr/local/bin/wasm-pack \
|
||||
&& curl -sSL https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
|
||||
| tar xz -C /opt \
|
||||
&& ln -s /opt/binaryen-version_130/bin/wasm-opt /usr/local/bin/wasm-opt
|
||||
|
||||
# Manifests first so the dependency-fetch layer caches across source changes
|
||||
# (same pattern as the server build stage below).
|
||||
COPY .cargo/config.toml ./.cargo/config.toml
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY solitaire_core/Cargo.toml ./solitaire_core/Cargo.toml
|
||||
COPY solitaire_sync/Cargo.toml ./solitaire_sync/Cargo.toml
|
||||
COPY solitaire_data/Cargo.toml ./solitaire_data/Cargo.toml
|
||||
COPY solitaire_engine/Cargo.toml ./solitaire_engine/Cargo.toml
|
||||
COPY solitaire_server/Cargo.toml ./solitaire_server/Cargo.toml
|
||||
COPY solitaire_app/Cargo.toml ./solitaire_app/Cargo.toml
|
||||
COPY solitaire_assetgen/Cargo.toml ./solitaire_assetgen/Cargo.toml
|
||||
COPY solitaire_wasm/Cargo.toml ./solitaire_wasm/Cargo.toml
|
||||
COPY solitaire_web/Cargo.toml ./solitaire_web/Cargo.toml
|
||||
|
||||
RUN for crate in solitaire_core solitaire_sync solitaire_data solitaire_engine \
|
||||
solitaire_server solitaire_app solitaire_assetgen solitaire_wasm solitaire_web; do \
|
||||
mkdir -p $crate/src && echo "pub fn _stub() {}" > $crate/src/lib.rs; \
|
||||
done && \
|
||||
echo "fn main() {}" > solitaire_server/src/main.rs && \
|
||||
echo "fn main() {}" > solitaire_app/src/main.rs && \
|
||||
echo "fn main() {}" > solitaire_assetgen/src/main.rs
|
||||
|
||||
RUN cargo fetch --locked
|
||||
|
||||
# Real source for the wasm-feeding crates. Whole crate directories (not just
|
||||
# src/) because solitaire_engine embeds theme/audio/font assets at compile
|
||||
# time from its own assets/ and the workspace assets/.
|
||||
COPY build_wasm.sh ./
|
||||
COPY solitaire_core ./solitaire_core
|
||||
COPY solitaire_sync ./solitaire_sync
|
||||
COPY solitaire_data ./solitaire_data
|
||||
COPY solitaire_engine ./solitaire_engine
|
||||
COPY solitaire_wasm ./solitaire_wasm
|
||||
COPY solitaire_web ./solitaire_web
|
||||
COPY assets ./assets
|
||||
|
||||
RUN ./build_wasm.sh
|
||||
|
||||
# --- Build stage ---
|
||||
FROM rust:1.95-slim AS builder
|
||||
|
||||
@@ -67,6 +132,8 @@ COPY --from=builder /build/target/release/solitaire_server ./server
|
||||
# /app/assets → /assets route
|
||||
# Card themes (dark + classic) are embedded in the binary; no theme files needed here.
|
||||
COPY solitaire_server/web ./solitaire_server/web
|
||||
# The wasm bundles are never in the repo — they come from the wasm-builder stage.
|
||||
COPY --from=wasm-builder /build/solitaire_server/web/pkg ./solitaire_server/web/pkg
|
||||
COPY assets ./assets
|
||||
|
||||
ENV SERVER_PORT=8080
|
||||
|
||||
@@ -31,17 +31,37 @@ use crate::{AppState, error::AppError, middleware::AuthenticatedUser};
|
||||
const KNOWN_MODES: &[&str] = &["Classic", "Zen", "TimeAttack", "Challenge", "Difficulty"];
|
||||
const KNOWN_DRAW_MODES: &[&str] = &["DrawOne", "DrawThree"];
|
||||
|
||||
fn validate_header(h: &ReplayHeader) -> Result<(), AppError> {
|
||||
/// Extract the mode label from the client's serde representation.
|
||||
///
|
||||
/// Unit `GameMode` variants serialise as plain strings (`"Classic"`),
|
||||
/// but data-carrying variants serialise as single-key objects
|
||||
/// (`{"Difficulty": "Easy"}`) — a plain-`String` field rejected those
|
||||
/// uploads with a 400 even though `"Difficulty"` sits in `KNOWN_MODES`.
|
||||
fn mode_label(mode: &serde_json::Value) -> Result<String, AppError> {
|
||||
if let Some(s) = mode.as_str() {
|
||||
return Ok(s.to_string());
|
||||
}
|
||||
if let Some(obj) = mode.as_object()
|
||||
&& obj.len() == 1
|
||||
&& let Some(key) = obj.keys().next()
|
||||
{
|
||||
return Ok(key.clone());
|
||||
}
|
||||
Err(AppError::BadRequest(format!(
|
||||
"invalid mode '{mode}'; expected a mode string or single-variant object"
|
||||
)))
|
||||
}
|
||||
|
||||
fn validate_header(h: &ReplayHeader, mode: &str) -> Result<(), AppError> {
|
||||
if !KNOWN_DRAW_MODES.contains(&h.draw_mode.as_str()) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"invalid draw_mode '{}'; expected one of {:?}",
|
||||
h.draw_mode, KNOWN_DRAW_MODES
|
||||
)));
|
||||
}
|
||||
if !KNOWN_MODES.contains(&h.mode.as_str()) {
|
||||
if !KNOWN_MODES.contains(&mode) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"invalid mode '{}'; expected one of {:?}",
|
||||
h.mode, KNOWN_MODES
|
||||
"invalid mode '{mode}'; expected one of {KNOWN_MODES:?}"
|
||||
)));
|
||||
}
|
||||
if h.time_seconds <= 0 || h.time_seconds > 86_400 {
|
||||
@@ -72,7 +92,11 @@ fn validate_header(h: &ReplayHeader) -> Result<(), AppError> {
|
||||
struct ReplayHeader {
|
||||
seed: u64,
|
||||
draw_mode: String,
|
||||
mode: String,
|
||||
/// Kept as raw JSON: unit variants arrive as strings, data-carrying
|
||||
/// variants (`GameMode::Difficulty(level)`) as single-key objects.
|
||||
/// [`mode_label`] projects both onto the label stored in the `mode`
|
||||
/// column.
|
||||
mode: serde_json::Value,
|
||||
time_seconds: i64,
|
||||
final_score: i64,
|
||||
recorded_at: String,
|
||||
@@ -126,7 +150,8 @@ pub async fn upload(
|
||||
let header: ReplayHeader = serde_json::from_value(payload.clone())
|
||||
.map_err(|e| AppError::BadRequest(format!("replay JSON missing fields: {e}")))?;
|
||||
|
||||
validate_header(&header)?;
|
||||
let mode = mode_label(&header.mode)?;
|
||||
validate_header(&header, &mode)?;
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let received_at = Utc::now().to_rfc3339();
|
||||
@@ -144,7 +169,7 @@ pub async fn upload(
|
||||
user.user_id,
|
||||
seed_i64,
|
||||
header.draw_mode,
|
||||
header.mode,
|
||||
mode,
|
||||
header.time_seconds,
|
||||
header.final_score,
|
||||
header.recorded_at,
|
||||
@@ -158,7 +183,7 @@ pub async fn upload(
|
||||
// beats their existing best. Only classic mode counts for the leaderboard.
|
||||
// Use `received_at` (server-computed) rather than `header.recorded_at`
|
||||
// (client-supplied) so clients cannot spoof the timestamp.
|
||||
if header.mode == "Classic" {
|
||||
if mode == "Classic" {
|
||||
sqlx::query!(
|
||||
r#"UPDATE leaderboard
|
||||
SET best_score = ?,
|
||||
|
||||
@@ -445,33 +445,30 @@ function showWin(s) {
|
||||
submitReplay(s);
|
||||
}
|
||||
|
||||
function buildReplayPayload(s) {
|
||||
if (!game || !s) return null;
|
||||
let moves;
|
||||
function buildReplayPayload() {
|
||||
if (!game) return null;
|
||||
// The wasm side assembles the entire schema-v4 payload (including the
|
||||
// session recording and the u64 seed, which JS numbers can't hold);
|
||||
// JS only supplies the wall-clock time and today's date.
|
||||
try {
|
||||
moves = game.replay_moves();
|
||||
if (!Array.isArray(moves) || moves.length === 0) return null;
|
||||
const json = game.replay_export(
|
||||
Math.max(1, elapsedSecs),
|
||||
new Date().toISOString().slice(0, 10),
|
||||
);
|
||||
const payload = JSON.parse(json);
|
||||
if (!Array.isArray(payload?.recording?.instructions)
|
||||
|| payload.recording.instructions.length === 0) return null;
|
||||
return payload;
|
||||
} catch (e) {
|
||||
console.warn("fs: replay export failed", e);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
schema_version: 2,
|
||||
seed: Math.round(game.seed()),
|
||||
draw_mode: drawThree ? "DrawThree" : "DrawOne",
|
||||
mode: "Classic",
|
||||
time_seconds: Math.max(1, elapsedSecs),
|
||||
final_score: s.score,
|
||||
recorded_at: new Date().toISOString().slice(0, 10),
|
||||
moves,
|
||||
win_move_index: moves.length - 1,
|
||||
};
|
||||
}
|
||||
|
||||
async function submitReplay(s) {
|
||||
const token = localStorage.getItem('fs_token');
|
||||
if (!token || !game) return;
|
||||
const payload = buildReplayPayload(s);
|
||||
const payload = buildReplayPayload();
|
||||
if (!payload) return;
|
||||
try {
|
||||
await fetch('/api/replays', {
|
||||
@@ -1029,7 +1026,7 @@ window.__FERROUS_DEBUG__ = {
|
||||
},
|
||||
replayPayload() {
|
||||
if (!game) return null;
|
||||
return buildReplayPayload(snap ?? game.state());
|
||||
return buildReplayPayload();
|
||||
},
|
||||
runAutoplay(options) {
|
||||
return runDebugAutoplay(options);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,595 +0,0 @@
|
||||
/**
|
||||
* Browser-side replay state machine. Owns a live `GameState` and the
|
||||
* replay's move list; each `step()` applies the next move.
|
||||
*/
|
||||
export class ReplayPlayer {
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
ReplayPlayerFinalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_replayplayer_free(ptr, 0);
|
||||
}
|
||||
/**
|
||||
* Returns `true` once every move has been applied.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
is_finished() {
|
||||
const ret = wasm.replayplayer_is_finished(this.__wbg_ptr);
|
||||
return ret !== 0;
|
||||
}
|
||||
/**
|
||||
* Construct from a raw replay JSON string.
|
||||
* @param {string} replay_json
|
||||
*/
|
||||
constructor(replay_json) {
|
||||
const ptr0 = passStringToWasm0(replay_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.replayplayer_new(ptr0, len0);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
this.__wbg_ptr = ret[0];
|
||||
ReplayPlayerFinalization.register(this, this.__wbg_ptr, this);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Snapshot the current `GameState` as a JS object (see `StateSnapshot`).
|
||||
*
|
||||
* Throws a JS string exception on serialisation failure (should never
|
||||
* occur in practice — `StateSnapshot` contains only primitive types).
|
||||
* @returns {any}
|
||||
*/
|
||||
state() {
|
||||
const ret = wasm.replayplayer_state(this.__wbg_ptr);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
/**
|
||||
* Apply the next move; returns the post-step snapshot, or `null`
|
||||
* once the move list is exhausted.
|
||||
*
|
||||
* Returns `null` (not an exception) when the replay is finished.
|
||||
* Throws `"replay_desync"` when the next recorded move is illegal for
|
||||
* the current state, and logs the underlying core error to the JS console.
|
||||
* Throws a JS string exception on serialisation failure.
|
||||
* @returns {any}
|
||||
*/
|
||||
step() {
|
||||
const ret = wasm.replayplayer_step(this.__wbg_ptr);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
/**
|
||||
* 0-indexed position of the next move to apply.
|
||||
* @returns {number}
|
||||
*/
|
||||
step_idx() {
|
||||
const ret = wasm.replayplayer_step_idx(this.__wbg_ptr);
|
||||
return ret >>> 0;
|
||||
}
|
||||
/**
|
||||
* Total number of moves the replay contains.
|
||||
* @returns {number}
|
||||
*/
|
||||
total_steps() {
|
||||
const ret = wasm.replayplayer_total_steps(this.__wbg_ptr);
|
||||
return ret >>> 0;
|
||||
}
|
||||
}
|
||||
if (Symbol.dispose) ReplayPlayer.prototype[Symbol.dispose] = ReplayPlayer.prototype.free;
|
||||
|
||||
/**
|
||||
* Interactive Klondike game backed by the real `solitaire_core` rules engine.
|
||||
*
|
||||
* Construct with `new(seed, draw_three)`, then call `draw()`, `move_cards()`,
|
||||
* `undo()`, `auto_complete_step()` to advance the game. `state()` returns the
|
||||
* full pile snapshot at any time without mutating state.
|
||||
*/
|
||||
export class SolitaireGame {
|
||||
static __wrap(ptr) {
|
||||
const obj = Object.create(SolitaireGame.prototype);
|
||||
obj.__wbg_ptr = ptr;
|
||||
SolitaireGameFinalization.register(obj, obj.__wbg_ptr, obj);
|
||||
return obj;
|
||||
}
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
SolitaireGameFinalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_solitairegame_free(ptr, 0);
|
||||
}
|
||||
/**
|
||||
* Apply one auto-complete move (only valid when `is_auto_completable`).
|
||||
*
|
||||
* If no card can go directly to a foundation this step, advances the
|
||||
* waste by calling `draw()` so the next step can try again. Returns the
|
||||
* post-move snapshot, or `null` when no progress is possible.
|
||||
* @returns {any}
|
||||
*/
|
||||
auto_complete_step() {
|
||||
const ret = wasm.solitairegame_auto_complete_step(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* Applies the legal move currently at `index` from `debug_legal_moves()`.
|
||||
* @param {number} index
|
||||
* @returns {any}
|
||||
*/
|
||||
debug_apply_legal_move(index) {
|
||||
const ret = wasm.solitairegame_debug_apply_legal_move(this.__wbg_ptr, index);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* Applies one debug move encoded as JSON.
|
||||
*
|
||||
* JSON must match [`DebugMove`], for example:
|
||||
* `{"kind":"move","from":"tableau-0","to":"foundation-1","count":1}` or
|
||||
* `{"kind":"stock_click"}`.
|
||||
* @param {string} move_json
|
||||
* @returns {any}
|
||||
*/
|
||||
debug_apply_move_json(move_json) {
|
||||
const ptr0 = passStringToWasm0(move_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.solitairegame_debug_apply_move_json(this.__wbg_ptr, ptr0, len0);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* Returns all currently-legal debug moves as a JS array.
|
||||
*
|
||||
* Includes [`DebugMove::StockClick`] when stock interaction is legal.
|
||||
* @returns {any}
|
||||
*/
|
||||
debug_legal_moves() {
|
||||
const ret = wasm.solitairegame_debug_legal_moves(this.__wbg_ptr);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
/**
|
||||
* Returns deterministic instruction history for the current game.
|
||||
*
|
||||
* Together with `seed()` and `draw_mode`, this history is replayable.
|
||||
* @returns {any}
|
||||
*/
|
||||
debug_move_history() {
|
||||
const ret = wasm.solitairegame_debug_move_history(this.__wbg_ptr);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
/**
|
||||
* Returns a comprehensive debug snapshot for automated verification.
|
||||
* @returns {any}
|
||||
*/
|
||||
debug_snapshot() {
|
||||
const ret = wasm.solitairegame_debug_snapshot(this.__wbg_ptr);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
/**
|
||||
* Draw from stock to waste (or recycle waste → stock when stock is empty).
|
||||
* Returns `{ok, error?, snapshot?}`.
|
||||
* @returns {any}
|
||||
*/
|
||||
draw() {
|
||||
const ret = wasm.solitairegame_draw(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* Restore a game from a JSON string previously produced by [`SolitaireGame::serialize`].
|
||||
*
|
||||
* Returns an error string if the JSON is malformed or describes a state
|
||||
* that can't be deserialised (e.g. from a future schema version).
|
||||
* @param {string} json
|
||||
* @returns {SolitaireGame}
|
||||
*/
|
||||
static from_saved(json) {
|
||||
const ptr0 = passStringToWasm0(json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.solitairegame_from_saved(ptr0, len0);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return SolitaireGame.__wrap(ret[0]);
|
||||
}
|
||||
/**
|
||||
* Move `count` cards from pile `from` to pile `to`.
|
||||
*
|
||||
* Pile names: `"stock"`, `"waste"`, `"foundation-0"` .. `"foundation-3"`,
|
||||
* `"tableau-0"` .. `"tableau-6"`.
|
||||
*
|
||||
* Returns `{ok, error?, snapshot?}`.
|
||||
* @param {string} from
|
||||
* @param {string} to
|
||||
* @param {number} count
|
||||
* @returns {any}
|
||||
*/
|
||||
move_cards(from, to, count) {
|
||||
const ptr0 = passStringToWasm0(from, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passStringToWasm0(to, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.solitairegame_move_cards(this.__wbg_ptr, ptr0, len0, ptr1, len1, count);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* Create a new DrawOne or DrawThree Classic game from the given seed.
|
||||
*
|
||||
* `seed` is a JS `number` (f64); values up to 2^53 are represented exactly.
|
||||
* Pass `Date.now()` or a random integer from JS for variety.
|
||||
* @param {number} seed
|
||||
* @param {boolean} draw_three
|
||||
*/
|
||||
constructor(seed, draw_three) {
|
||||
const ret = wasm.solitairegame_new(seed, draw_three);
|
||||
this.__wbg_ptr = ret;
|
||||
SolitaireGameFinalization.register(this, this.__wbg_ptr, this);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Returns replay moves encoded in the `solitaire_data::Replay` wire format
|
||||
* — a list of upstream [`KlondikeInstruction`]s.
|
||||
*
|
||||
* This is the deterministic instruction history; together with `seed()`
|
||||
* and the draw mode it replays cleanly via `apply_instruction`.
|
||||
* @returns {any}
|
||||
*/
|
||||
replay_moves() {
|
||||
const ret = wasm.solitairegame_replay_moves(this.__wbg_ptr);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
/**
|
||||
* The seed used to deal this game.
|
||||
* @returns {number}
|
||||
*/
|
||||
seed() {
|
||||
const ret = wasm.solitairegame_seed(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* Serialise the full game state as a JSON string for `localStorage`.
|
||||
*
|
||||
* Use [`SolitaireGame::from_saved`] to restore it. The returned string is
|
||||
* opaque — callers should treat it as a blob and store/restore it verbatim.
|
||||
* @returns {string}
|
||||
*/
|
||||
serialize() {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ret = wasm.solitairegame_serialize(this.__wbg_ptr);
|
||||
var ptr1 = ret[0];
|
||||
var len1 = ret[1];
|
||||
if (ret[3]) {
|
||||
ptr1 = 0; len1 = 0;
|
||||
throw takeFromExternrefTable0(ret[2]);
|
||||
}
|
||||
deferred2_0 = ptr1;
|
||||
deferred2_1 = len1;
|
||||
return getStringFromWasm0(ptr1, len1);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Full pile snapshot as a JS object.
|
||||
*
|
||||
* Throws a JS string exception on serialisation failure.
|
||||
* @returns {any}
|
||||
*/
|
||||
state() {
|
||||
const ret = wasm.solitairegame_state(this.__wbg_ptr);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
/**
|
||||
* Undo the last move. Returns `{ok, error?, snapshot?}`.
|
||||
* @returns {any}
|
||||
*/
|
||||
undo() {
|
||||
const ret = wasm.solitairegame_undo(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
if (Symbol.dispose) SolitaireGame.prototype[Symbol.dispose] = SolitaireGame.prototype.free;
|
||||
function __wbg_get_imports() {
|
||||
const import0 = {
|
||||
__proto__: null,
|
||||
__wbg_Error_3639a60ed15f87e7: function(arg0, arg1) {
|
||||
const ret = Error(getStringFromWasm0(arg0, arg1));
|
||||
return ret;
|
||||
},
|
||||
__wbg_String_8564e559799eccda: function(arg0, arg1) {
|
||||
const ret = String(arg1);
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
},
|
||||
__wbg___wbindgen_throw_9c75d47bf9e7731e: function(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
},
|
||||
__wbg_error_48655ee7e4756f8b: function(arg0) {
|
||||
console.error(arg0);
|
||||
},
|
||||
__wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
try {
|
||||
deferred0_0 = arg0;
|
||||
deferred0_1 = arg1;
|
||||
console.error(getStringFromWasm0(arg0, arg1));
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
|
||||
}
|
||||
},
|
||||
__wbg_new_227d7c05414eb861: function() {
|
||||
const ret = new Error();
|
||||
return ret;
|
||||
},
|
||||
__wbg_new_2fad8ca02fd00684: function() {
|
||||
const ret = new Object();
|
||||
return ret;
|
||||
},
|
||||
__wbg_new_3baa8d9866155c79: function() {
|
||||
const ret = new Array();
|
||||
return ret;
|
||||
},
|
||||
__wbg_set_6be42768c690e380: function(arg0, arg1, arg2) {
|
||||
arg0[arg1] = arg2;
|
||||
},
|
||||
__wbg_set_f614f6a0608d1d1d: function(arg0, arg1, arg2) {
|
||||
arg0[arg1 >>> 0] = arg2;
|
||||
},
|
||||
__wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) {
|
||||
const ret = arg1.stack;
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
},
|
||||
__wbindgen_cast_0000000000000001: function(arg0) {
|
||||
// Cast intrinsic for `F64 -> Externref`.
|
||||
const ret = arg0;
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Ref(String) -> Externref`.
|
||||
const ret = getStringFromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000003: function(arg0) {
|
||||
// Cast intrinsic for `U64 -> Externref`.
|
||||
const ret = BigInt.asUintN(64, arg0);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_init_externref_table: function() {
|
||||
const table = wasm.__wbindgen_externrefs;
|
||||
const offset = table.grow(4);
|
||||
table.set(0, undefined);
|
||||
table.set(offset + 0, undefined);
|
||||
table.set(offset + 1, null);
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
},
|
||||
};
|
||||
return {
|
||||
__proto__: null,
|
||||
"./solitaire_wasm_bg.js": import0,
|
||||
};
|
||||
}
|
||||
|
||||
const ReplayPlayerFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_replayplayer_free(ptr, 1));
|
||||
const SolitaireGameFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_solitairegame_free(ptr, 1));
|
||||
|
||||
let cachedDataViewMemory0 = null;
|
||||
function getDataViewMemory0() {
|
||||
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
||||
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
||||
}
|
||||
return cachedDataViewMemory0;
|
||||
}
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
return decodeText(ptr >>> 0, len);
|
||||
}
|
||||
|
||||
let cachedUint8ArrayMemory0 = null;
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||
}
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
function passStringToWasm0(arg, malloc, realloc) {
|
||||
if (realloc === undefined) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
const ptr = malloc(buf.length, 1) >>> 0;
|
||||
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||
WASM_VECTOR_LEN = buf.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let len = arg.length;
|
||||
let ptr = malloc(len, 1) >>> 0;
|
||||
|
||||
const mem = getUint8ArrayMemory0();
|
||||
|
||||
let offset = 0;
|
||||
|
||||
for (; offset < len; offset++) {
|
||||
const code = arg.charCodeAt(offset);
|
||||
if (code > 0x7F) break;
|
||||
mem[ptr + offset] = code;
|
||||
}
|
||||
if (offset !== len) {
|
||||
if (offset !== 0) {
|
||||
arg = arg.slice(offset);
|
||||
}
|
||||
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
||||
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||
const ret = cachedTextEncoder.encodeInto(arg, view);
|
||||
|
||||
offset += ret.written;
|
||||
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||
}
|
||||
|
||||
WASM_VECTOR_LEN = offset;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function takeFromExternrefTable0(idx) {
|
||||
const value = wasm.__wbindgen_externrefs.get(idx);
|
||||
wasm.__externref_table_dealloc(idx);
|
||||
return value;
|
||||
}
|
||||
|
||||
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||
let numBytesDecoded = 0;
|
||||
function decodeText(ptr, len) {
|
||||
numBytesDecoded += len;
|
||||
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
||||
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
numBytesDecoded = len;
|
||||
}
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
const cachedTextEncoder = new TextEncoder();
|
||||
|
||||
if (!('encodeInto' in cachedTextEncoder)) {
|
||||
cachedTextEncoder.encodeInto = function (arg, view) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
view.set(buf);
|
||||
return {
|
||||
read: arg.length,
|
||||
written: buf.length
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
let wasmModule, wasmInstance, wasm;
|
||||
function __wbg_finalize_init(instance, module) {
|
||||
wasmInstance = instance;
|
||||
wasm = instance.exports;
|
||||
wasmModule = module;
|
||||
cachedDataViewMemory0 = null;
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
wasm.__wbindgen_start();
|
||||
return wasm;
|
||||
}
|
||||
|
||||
async function __wbg_load(module, imports) {
|
||||
if (typeof Response === 'function' && module instanceof Response) {
|
||||
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||
try {
|
||||
return await WebAssembly.instantiateStreaming(module, imports);
|
||||
} catch (e) {
|
||||
const validResponse = module.ok && expectedResponseType(module.type);
|
||||
|
||||
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
|
||||
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||
|
||||
} else { throw e; }
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = await module.arrayBuffer();
|
||||
return await WebAssembly.instantiate(bytes, imports);
|
||||
} else {
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
|
||||
if (instance instanceof WebAssembly.Instance) {
|
||||
return { instance, module };
|
||||
} else {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
function expectedResponseType(type) {
|
||||
switch (type) {
|
||||
case 'basic': case 'cors': case 'default': return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function initSync(module) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (module !== undefined) {
|
||||
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||
({module} = module)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
const imports = __wbg_get_imports();
|
||||
if (!(module instanceof WebAssembly.Module)) {
|
||||
module = new WebAssembly.Module(module);
|
||||
}
|
||||
const instance = new WebAssembly.Instance(module, imports);
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
async function __wbg_init(module_or_path) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (module_or_path !== undefined) {
|
||||
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||
({module_or_path} = module_or_path)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
if (module_or_path === undefined) {
|
||||
module_or_path = new URL('solitaire_wasm_bg.wasm', import.meta.url);
|
||||
}
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||
module_or_path = fetch(module_or_path);
|
||||
}
|
||||
|
||||
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
export { initSync, __wbg_init as default };
|
||||
Binary file not shown.
@@ -159,16 +159,17 @@
|
||||
|
||||
function buildReplayPayload() {
|
||||
if (!game) return null;
|
||||
// Schema v4: the wasm side assembles the full payload,
|
||||
// including the session recording and the u64 seed.
|
||||
try {
|
||||
const moves = game.replay_moves();
|
||||
if (!Array.isArray(moves) || moves.length === 0) return null;
|
||||
return {
|
||||
schema_version: 2,
|
||||
seed: Math.round(game.seed()),
|
||||
draw_mode: game.debug_snapshot()?.draw_mode ?? "DrawOne",
|
||||
mode: "Classic",
|
||||
moves,
|
||||
};
|
||||
const json = game.replay_export(
|
||||
1,
|
||||
new Date().toISOString().slice(0, 10),
|
||||
);
|
||||
const payload = JSON.parse(json);
|
||||
if (!Array.isArray(payload?.recording?.instructions)
|
||||
|| payload.recording.instructions.length === 0) return null;
|
||||
return payload;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,18 @@ function resetPlayer() {
|
||||
playInterval = null;
|
||||
btnPlay.textContent = "▶ Play";
|
||||
}
|
||||
// Old replays (schema < 4) are rejected by the wasm player with a
|
||||
// descriptive error — surface it instead of leaving a dead board.
|
||||
try {
|
||||
player = new ReplayPlayer(replayJson);
|
||||
} catch (e) {
|
||||
captionEl.textContent = `Cannot play this replay: ${e}`;
|
||||
btnStep.disabled = true;
|
||||
btnPlay.disabled = true;
|
||||
btnPrev.disabled = true;
|
||||
btnRestart.disabled = true;
|
||||
return;
|
||||
}
|
||||
btnPrev.disabled = true;
|
||||
btnRestart.disabled = true;
|
||||
btnStep.disabled = false;
|
||||
@@ -304,10 +315,9 @@ btnPlay.addEventListener("click", () => {
|
||||
}, STEP_INTERVAL_MS);
|
||||
});
|
||||
|
||||
/// Step the player back one move. Re-creates the ReplayPlayer and fast-
|
||||
/// forwards to (step_idx - 1) without rendering intermediate frames, then
|
||||
/// renders once so the CSS transition animates each card to its previous
|
||||
/// position.
|
||||
/// Step the player back one move via the wasm-side seek (rewinds to the
|
||||
/// recorded deal and fast-forwards internally), then renders once so the
|
||||
/// CSS transition animates each card to its previous position.
|
||||
function stepBack() {
|
||||
if (!player || player.step_idx() === 0) return;
|
||||
if (playInterval) {
|
||||
@@ -315,12 +325,7 @@ function stepBack() {
|
||||
playInterval = null;
|
||||
btnPlay.textContent = "▶ Play";
|
||||
}
|
||||
const target = player.step_idx() - 1;
|
||||
player = new ReplayPlayer(replayJson);
|
||||
for (let i = 0; i < target; i++) {
|
||||
player.step();
|
||||
}
|
||||
render(player.state());
|
||||
render(player.seek(player.step_idx() - 1));
|
||||
btnPrev.disabled = player.step_idx() === 0;
|
||||
btnRestart.disabled = player.step_idx() === 0;
|
||||
btnStep.disabled = false;
|
||||
|
||||
@@ -13,7 +13,7 @@ pub mod stats;
|
||||
pub mod theme_store;
|
||||
|
||||
pub use achievements::AchievementRecord;
|
||||
pub use merge::{merge, merge_at};
|
||||
pub use merge::merge;
|
||||
pub use progress::{PlayerProgress, level_for_xp};
|
||||
pub use stats::StatsSnapshot;
|
||||
pub use theme_store::{ThemeCatalogEntry, ThemeCatalogResponse};
|
||||
@@ -98,30 +98,3 @@ pub struct LeaderboardEntry {
|
||||
/// When this entry was last recorded.
|
||||
pub recorded_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Errors returned by the sync server in `application/json` error bodies.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
|
||||
pub enum ApiError {
|
||||
/// The request could not be authenticated (missing or invalid JWT).
|
||||
#[error("unauthorized")]
|
||||
Unauthorized,
|
||||
/// The supplied credentials were incorrect.
|
||||
#[error("invalid credentials")]
|
||||
InvalidCredentials,
|
||||
/// A username that was requested for registration is already taken.
|
||||
#[error("username already taken")]
|
||||
UsernameTaken,
|
||||
/// The request payload was too large (> 1 MB).
|
||||
#[error("payload too large")]
|
||||
PayloadTooLarge,
|
||||
/// The request body could not be parsed.
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
/// An unexpected server-side error occurred.
|
||||
#[error("internal server error")]
|
||||
Internal,
|
||||
}
|
||||
|
||||
+237
-51
@@ -26,16 +26,21 @@ use solitaire_core::{
|
||||
DrawStockConfig,
|
||||
game_state::{GameMode, GameState},
|
||||
};
|
||||
use solitaire_core::{KlondikeInstruction, KlondikePile};
|
||||
use solitaire_core::{KlondikeInstruction, KlondikePile, SessionRecording};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Mirrors `solitaire_data::Replay` v3.
|
||||
/// Replay schema version this player understands. Mirrors
|
||||
/// `solitaire_data::REPLAY_SCHEMA_VERSION`; the loader rejects any
|
||||
/// other version with a descriptive error instead of desyncing.
|
||||
pub const REPLAY_SCHEMA_VERSION: u32 = 4;
|
||||
|
||||
/// Mirrors `solitaire_data::Replay` v4.
|
||||
///
|
||||
/// `moves` is a list of upstream [`KlondikeInstruction`]s — the same
|
||||
/// move-currency `solitaire_core` persists. A stock click is
|
||||
/// `KlondikeInstruction::RotateStock`; a card move is a
|
||||
/// `DstFoundation` / `DstTableau` instruction. Pile-position types are
|
||||
/// runtime-only and intentionally not part of the wire format.
|
||||
/// `recording` is the upstream `card_game` session serialisation
|
||||
/// (`{config, initial_state, instructions}`): the dealt board is stored
|
||||
/// explicitly, so playback rebuilds the exact deal instead of re-dealing
|
||||
/// from `seed` — schemas ≤ v3 did the latter and silently broke whenever
|
||||
/// an RNG or upstream upgrade changed the seed→deal mapping.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Replay {
|
||||
#[serde(default)]
|
||||
@@ -46,7 +51,9 @@ pub struct Replay {
|
||||
pub time_seconds: u64,
|
||||
pub final_score: i32,
|
||||
pub recorded_at: NaiveDate,
|
||||
pub moves: Vec<KlondikeInstruction>,
|
||||
pub recording: SessionRecording,
|
||||
#[serde(default)]
|
||||
pub win_move_index: Option<usize>,
|
||||
}
|
||||
|
||||
/// JS-friendly snapshot of a `GameState` at a particular replay step.
|
||||
@@ -103,6 +110,9 @@ impl From<&(Card, bool)> for CardSnapshot {
|
||||
#[wasm_bindgen]
|
||||
pub struct ReplayPlayer {
|
||||
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>,
|
||||
step_idx: usize,
|
||||
}
|
||||
@@ -125,14 +135,42 @@ impl ReplayPlayer {
|
||||
pub fn from_json(replay_json: &str) -> Result<Self, String> {
|
||||
let replay: Replay =
|
||||
serde_json::from_str(replay_json).map_err(|e| format!("invalid replay JSON: {e}"))?;
|
||||
let game = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
|
||||
if replay.schema_version != REPLAY_SCHEMA_VERSION {
|
||||
return Err(format!(
|
||||
"unsupported replay schema_version {} (this player requires {}); \
|
||||
replays recorded by older clients cannot be replayed",
|
||||
replay.schema_version, REPLAY_SCHEMA_VERSION
|
||||
));
|
||||
}
|
||||
// The recording carries the dealt board and session config, so the
|
||||
// rebuilt game is bit-identical to the recorded deal no matter how
|
||||
// the current build maps seeds to deals.
|
||||
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
|
||||
Ok(Self {
|
||||
initial: game.clone(),
|
||||
game,
|
||||
moves: replay.moves,
|
||||
moves,
|
||||
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.
|
||||
pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> {
|
||||
if self.step_idx >= self.moves.len() {
|
||||
@@ -219,6 +257,25 @@ 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.
|
||||
pub fn total_steps(&self) -> usize {
|
||||
self.moves.len()
|
||||
@@ -228,11 +285,6 @@ impl ReplayPlayer {
|
||||
pub fn step_idx(&self) -> usize {
|
||||
self.step_idx
|
||||
}
|
||||
|
||||
/// Returns `true` once every move has been applied.
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.step_idx >= self.moves.len()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -515,6 +567,36 @@ impl SolitaireGame {
|
||||
self.game.instruction_history()
|
||||
}
|
||||
|
||||
/// Builds the complete schema-v4 replay upload payload for the live
|
||||
/// game as a JSON string, ready to `POST /api/replays` verbatim.
|
||||
///
|
||||
/// The JS layer must not assemble this payload itself: the recording
|
||||
/// serialises through the upstream `card_game` serializers, and the
|
||||
/// `u64` seed exceeds JS number precision (`Math.round(game.seed())`
|
||||
/// silently corrupts it).
|
||||
///
|
||||
/// `recorded_at` is an ISO-8601 date (`YYYY-MM-DD`); the browser
|
||||
/// supplies it because the wasm build has no reliable local clock.
|
||||
fn replay_export_native(&self, time_seconds: u64, recorded_at: &str) -> Result<String, String> {
|
||||
let recorded_at: NaiveDate = recorded_at
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid recorded_at date '{recorded_at}': {e}"))?;
|
||||
let recording = self.game.recording();
|
||||
let win_move_index = recording.len().checked_sub(1);
|
||||
let replay = Replay {
|
||||
schema_version: REPLAY_SCHEMA_VERSION,
|
||||
seed: self.game.seed,
|
||||
draw_mode: self.game.draw_mode(),
|
||||
mode: self.game.mode,
|
||||
time_seconds,
|
||||
final_score: self.game.score(),
|
||||
recorded_at,
|
||||
recording,
|
||||
win_move_index,
|
||||
};
|
||||
serde_json::to_string(&replay).map_err(|e| format!("replay serialisation failed: {e}"))
|
||||
}
|
||||
|
||||
fn debug_snapshot_native(&self) -> DebugSnapshot {
|
||||
let legal_moves = self.legal_moves_native();
|
||||
let invariants = invariant_report_for_game(&self.game, &legal_moves);
|
||||
@@ -699,6 +781,17 @@ impl SolitaireGame {
|
||||
serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Complete schema-v4 replay payload for the live game, as a JSON
|
||||
/// string ready to `POST /api/replays` verbatim. See
|
||||
/// [`Self::replay_export_native`] for why JS must not assemble the
|
||||
/// payload itself. `recorded_at` is an ISO-8601 `YYYY-MM-DD` date.
|
||||
/// `time_seconds` is `u32` so JS can pass a plain number (a `u64`
|
||||
/// would demand a `BigInt`).
|
||||
pub fn replay_export(&self, time_seconds: u32, recorded_at: String) -> Result<String, JsValue> {
|
||||
self.replay_export_native(u64::from(time_seconds), &recorded_at)
|
||||
.map_err(|e| JsValue::from_str(&e))
|
||||
}
|
||||
|
||||
/// Returns all currently-legal debug moves as a JS array.
|
||||
///
|
||||
/// Includes [`DebugMove::StockClick`] when stock interaction is legal.
|
||||
@@ -902,44 +995,25 @@ mod tests {
|
||||
"progressed game must export a non-empty replay move list"
|
||||
);
|
||||
|
||||
let moves_json = match serde_json::to_value(&exported_moves) {
|
||||
Ok(value) => value,
|
||||
Err(err) => panic!("failed to serialise exported replay moves: {err}"),
|
||||
};
|
||||
assert!(
|
||||
moves_json.is_array(),
|
||||
"exported replay moves must serialise as a JSON array"
|
||||
);
|
||||
|
||||
let parsed_back: Vec<KlondikeInstruction> = match serde_json::from_value(moves_json) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
panic!("failed to parse replay move JSON as KlondikeInstruction list: {err}")
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
parsed_back, exported_moves,
|
||||
"replay move JSON must round-trip through KlondikeInstruction"
|
||||
);
|
||||
|
||||
let recorded_at = match NaiveDate::from_ymd_opt(2026, 6, 1) {
|
||||
Some(date) => date,
|
||||
None => panic!("invalid recorded_at date in test"),
|
||||
};
|
||||
let replay = Replay {
|
||||
schema_version: 3,
|
||||
seed,
|
||||
draw_mode,
|
||||
mode: GameMode::Classic,
|
||||
time_seconds: 120,
|
||||
final_score: game.game.score(),
|
||||
recorded_at,
|
||||
moves: exported_moves,
|
||||
};
|
||||
let replay_json = match serde_json::to_string(&replay) {
|
||||
let replay_json = match game.replay_export_native(120, "2026-06-01") {
|
||||
Ok(json) => json,
|
||||
Err(err) => panic!("failed to serialise replay JSON: {err}"),
|
||||
Err(err) => panic!("failed to export replay JSON: {err}"),
|
||||
};
|
||||
let parsed: Replay = match serde_json::from_str(&replay_json) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => panic!("exported replay JSON must parse back as Replay: {err}"),
|
||||
};
|
||||
assert_eq!(parsed.schema_version, REPLAY_SCHEMA_VERSION);
|
||||
assert_eq!(
|
||||
parsed.recording.instructions(),
|
||||
exported_moves,
|
||||
"exported recording must carry the exact instruction history"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.win_move_index,
|
||||
Some(exported_moves.len() - 1),
|
||||
"win_move_index must point at the last instruction"
|
||||
);
|
||||
|
||||
let mut player = match ReplayPlayer::from_json(&replay_json) {
|
||||
Ok(value) => value,
|
||||
@@ -967,6 +1041,118 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Pre-v4 replays re-dealt from the seed at playback time — the exact
|
||||
/// mechanism that broke when the seed→deal mapping changed. The player
|
||||
/// must refuse them with a version error, never desync silently.
|
||||
#[test]
|
||||
fn replay_player_rejects_pre_v4_schema_versions() {
|
||||
let v3_json = r#"{
|
||||
"schema_version": 3,
|
||||
"seed": 7,
|
||||
"draw_mode": "DrawOne",
|
||||
"mode": "Classic",
|
||||
"time_seconds": 60,
|
||||
"final_score": 100,
|
||||
"recorded_at": "2026-05-01",
|
||||
"recording": null,
|
||||
"moves": []
|
||||
}"#;
|
||||
// v3 files carry `moves`, not `recording`; either way the version
|
||||
// gate (or the missing field) must produce an error, not a player.
|
||||
let err = match ReplayPlayer::from_json(v3_json) {
|
||||
Err(err) => err,
|
||||
Ok(_) => panic!("v3 replay must be rejected"),
|
||||
};
|
||||
assert!(
|
||||
err.contains("schema_version") || err.contains("invalid replay JSON"),
|
||||
"error must name the version/format problem, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole point of v4: playback rebuilds the deal from the
|
||||
/// recording, so a replay stays correct even when the top-level
|
||||
/// `seed` no longer maps to the same deal (RNG upgrades, or a
|
||||
/// corrupted seed from the old JS `Math.round` path).
|
||||
#[test]
|
||||
fn replay_playback_ignores_seed_for_dealing() {
|
||||
let game = SolitaireGame {
|
||||
game: GameState::new_with_mode(51, DrawStockConfig::DrawOne, GameMode::Classic),
|
||||
};
|
||||
let replay_json = game
|
||||
.replay_export_native(60, "2026-06-01")
|
||||
.expect("export must succeed");
|
||||
// Corrupt the seed field only — playback must be unaffected.
|
||||
let mut value: serde_json::Value =
|
||||
serde_json::from_str(&replay_json).expect("parse exported JSON");
|
||||
value["seed"] = serde_json::Value::from(0_u64);
|
||||
let corrupted = serde_json::to_string(&value).expect("reserialise");
|
||||
|
||||
let player = ReplayPlayer::from_json(&corrupted).expect("player must construct");
|
||||
let original_deal = serde_json::to_string(&game.snap()).expect("serialise original deal");
|
||||
let replayed_deal =
|
||||
serde_json::to_string(&player.snapshot()).expect("serialise replayed deal");
|
||||
// Compare the board projections (piles), not the GameState wrapper
|
||||
// (whose serde includes the now-different seed metadata).
|
||||
let orig: serde_json::Value = serde_json::from_str(&original_deal).expect("parse");
|
||||
let repl: serde_json::Value = serde_json::from_str(&replayed_deal).expect("parse");
|
||||
assert_eq!(
|
||||
orig["tableaus"], repl["tableaus"],
|
||||
"tableau deal must come from the recording, not the seed"
|
||||
);
|
||||
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]
|
||||
fn debug_api_autonomous_seed_batch_smoke() {
|
||||
for seed in 0_u64..128_u64 {
|
||||
|
||||
Reference in New Issue
Block a user