Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48ad6b6618 | |||
| 81ac4a5383 | |||
| f51ee7b234 | |||
| d593b61af8 | |||
| fc87b13e5b | |||
| 560470b86b | |||
| 1fded5ff16 | |||
| 65585c61ad | |||
| 0583a8ffae | |||
| 4d9d710a02 | |||
| 3fbee9ce30 | |||
| fd98f46267 | |||
| 07044f439c |
@@ -2,6 +2,19 @@
|
|||||||
# locally, run on every master push and pull request. Until this workflow
|
# locally, run on every master push and pull request. Until this workflow
|
||||||
# existed, nothing in CI ran the test suite at all — a direct push to
|
# existed, nothing in CI ran the test suite at all — a direct push to
|
||||||
# master was entirely unguarded.
|
# master was entirely unguarded.
|
||||||
|
#
|
||||||
|
# Build caching (2026-07-13): the Gitea actions cache never restored on
|
||||||
|
# this instance — every run back through run 597 logged "No cache found"
|
||||||
|
# even for exact keys saved successfully ("Cache saved successfully") by
|
||||||
|
# a run an hour earlier, including master→master restores. Until the
|
||||||
|
# cache server on the runner host is fixed, Swatinem/rust-cache is pure
|
||||||
|
# overhead here. `rust-host` is a HOST executor (its filesystem persists
|
||||||
|
# between runs — ~/.cargo and the rustup toolchain already carry over),
|
||||||
|
# so we get warm builds by pointing CARGO_TARGET_DIR at a persistent
|
||||||
|
# path on the runner instead of tarring gigabytes through a cache API
|
||||||
|
# that never returns them. Concurrent runs are safe: cargo serialises
|
||||||
|
# on the target-dir lock.
|
||||||
|
|
||||||
name: Test
|
name: Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -28,8 +41,27 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Seconds-long formatting gate in its own job so a rustfmt slip fails
|
||||||
|
# here instead of after a 35-minute cold build (run 600 spent its
|
||||||
|
# whole build budget to report an unformatted file).
|
||||||
|
fmt:
|
||||||
|
runs-on: rust-host
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust 1.95.0
|
||||||
|
uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: 1.95.0
|
||||||
|
components: rustfmt
|
||||||
|
|
||||||
|
- name: Format check
|
||||||
|
run: cargo fmt --check
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: rust-host
|
runs-on: rust-host
|
||||||
|
needs: fmt
|
||||||
|
|
||||||
# Full debuginfo made the solitaire_engine test-binary link peak past the
|
# Full debuginfo made the solitaire_engine test-binary link peak past the
|
||||||
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
|
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
|
||||||
@@ -40,10 +72,19 @@ jobs:
|
|||||||
# test binaries concurrently; as the workspace grew (runs 514/516/519)
|
# test binaries concurrently; as the workspace grew (runs 514/516/519)
|
||||||
# two+ simultaneous ld processes OOM-killed the runner again even at
|
# two+ simultaneous ld processes OOM-killed the runner again even at
|
||||||
# line-tables-only. Two jobs keeps at most two links in flight — the
|
# line-tables-only. Two jobs keeps at most two links in flight — the
|
||||||
# compile-throughput cost is small next to the cache-warm build.
|
# compile-throughput cost is small next to the warm build.
|
||||||
|
#
|
||||||
|
# CARGO_INCREMENTAL=0: incremental artifacts bloat the persistent
|
||||||
|
# target dir for little benefit in CI (rust-cache used to set this
|
||||||
|
# for the same reason).
|
||||||
|
#
|
||||||
|
# CARGO_TARGET_DIR: persistent on the runner host — see the header
|
||||||
|
# comment. The prune step below keeps it from growing unbounded.
|
||||||
env:
|
env:
|
||||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||||
CARGO_BUILD_JOBS: '2'
|
CARGO_BUILD_JOBS: '2'
|
||||||
|
CARGO_INCREMENTAL: '0'
|
||||||
|
CARGO_TARGET_DIR: /home/runner/.cache/ferrous-solitaire/target
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -53,10 +94,21 @@ jobs:
|
|||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: 1.95.0
|
toolchain: 1.95.0
|
||||||
components: clippy, rustfmt
|
components: clippy
|
||||||
|
|
||||||
- name: Cache cargo build
|
# Toolchain or lockfile bumps strand stale artifacts nothing will
|
||||||
uses: Swatinem/rust-cache@v2
|
# ever reuse; reset the dir when it crosses 40 GiB rather than
|
||||||
|
# curating it (a cold rebuild every few weeks is cheaper than the
|
||||||
|
# bookkeeping).
|
||||||
|
- name: Prune persistent target dir when oversized
|
||||||
|
run: |
|
||||||
|
limit_kb=$((40 * 1024 * 1024))
|
||||||
|
used_kb=$(du -sk "$CARGO_TARGET_DIR" 2>/dev/null | cut -f1 || echo 0)
|
||||||
|
echo "persistent target dir: $((used_kb / 1024)) MiB (limit $((limit_kb / 1024)) MiB)"
|
||||||
|
if [ "${used_kb:-0}" -gt "$limit_kb" ]; then
|
||||||
|
echo "over limit — clearing for a fresh cold build"
|
||||||
|
rm -rf "$CARGO_TARGET_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
# Native link deps for the Bevy crates (engine/app/web) on a bare
|
# Native link deps for the Bevy crates (engine/app/web) on a bare
|
||||||
# ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit.
|
# ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit.
|
||||||
@@ -67,9 +119,6 @@ jobs:
|
|||||||
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
|
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
|
||||||
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev
|
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev
|
||||||
|
|
||||||
- name: Format check
|
|
||||||
run: cargo fmt --check
|
|
||||||
|
|
||||||
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
|
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
|
||||||
# same as the web-e2e workflow's server prebuild.
|
# same as the web-e2e workflow's server prebuild.
|
||||||
- name: Clippy (deny warnings)
|
- name: Clippy (deny warnings)
|
||||||
|
|||||||
@@ -6,6 +6,36 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.44.0] — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Home is now a real home (menu redesign Phase B).** The mode picker became
|
||||||
|
a hierarchy: a **Continue** card (mode · elapsed · score) appears while a
|
||||||
|
game is in progress and returns to the table; a hero **New Game** button
|
||||||
|
replays your last mode with the current deal options in one tap; deal
|
||||||
|
options (draw 1/3, a new **winnable-only** toggle, difficulty tiers) moved
|
||||||
|
into a disclosure under the hero; the six modes sit in a compact symmetric
|
||||||
|
2×3 grid with descriptions on wide screens; and the stats strip moved to
|
||||||
|
the bottom. Wide viewports (desktop, unfolded foldables) get a two-pane
|
||||||
|
layout — launch surfaces left, Continue/daily/stats right. Cancel is now
|
||||||
|
**Back to table** and only appears while a live game exists. (#175)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Save files are self-contained (schema v6).** Like replays in 0.43.3, a
|
||||||
|
saved game now stores the dealt board via the upstream session serializers
|
||||||
|
instead of re-dealing from the seed on load; v4/v5 saves still load through
|
||||||
|
the legacy path and rewrite as v6 on next save. (#171)
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- **CI is ~9× faster.** Root-caused why the actions cache never restored on
|
||||||
|
the host-executor runner (per-run workdir paths poisoned the cache version
|
||||||
|
hash); the test workflow now uses a persistent on-runner target dir, and a
|
||||||
|
seconds-long `fmt` gate fails formatting mistakes before the build. Warm
|
||||||
|
full-gate runs: 4m29s, down from ~40 min. (#176)
|
||||||
|
|
||||||
## [0.43.3] — 2026-07-10
|
## [0.43.3] — 2026-07-10
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
# Menu UX Redesign — July 2026
|
# Menu UX Redesign — July 2026
|
||||||
|
|
||||||
Status: PLANNING. Visual identity (Terminal / base16-eighties) is settled and
|
Status: IN PROGRESS. A / E / C / G shipped in v0.43.0; B implemented on
|
||||||
out of scope — this is about **structure and interaction**, not colors or type.
|
`feat/home-hierarchy` (2026-07-13); F / H and the I–M backlog remain open.
|
||||||
|
Visual identity (Terminal / base16-eighties) is settled and out of
|
||||||
|
scope — this is about **structure and interaction**, not colors or type.
|
||||||
|
|
||||||
## Diagnosis (from code survey, 2026-07-07)
|
## Diagnosis (from code survey, 2026-07-07)
|
||||||
|
|
||||||
@@ -197,8 +199,10 @@ slimming), then **B**, then **F/G/H** as independent follow-ups.
|
|||||||
## Open decisions
|
## Open decisions
|
||||||
|
|
||||||
1. ~~Settings: tabs vs. sub-pages~~ — **DECIDED 2026-07-07: tabs.**
|
1. ~~Settings: tabs vs. sub-pages~~ — **DECIDED 2026-07-07: tabs.**
|
||||||
2. Deal options: disclosure on Classic card (proposed) vs. keep global row?
|
2. ~~Deal options: disclosure on Classic card vs. keep global row~~ —
|
||||||
3. Time Attack + Seed: top-level cards (proposed, grid stays symmetric) vs.
|
**DECIDED 2026-07-09: disclosure on the Classic card / New Game hero.**
|
||||||
tucked under a "More" card?
|
3. ~~Time Attack + Seed: top-level cards vs. "More" card~~ —
|
||||||
4. Stitch mockups for Phase B, or iterate directly in-engine?
|
**DECIDED 2026-07-09: top-level cards, symmetric 2×3 grid.**
|
||||||
|
4. ~~Stitch mockups for Phase B, or iterate directly in-engine~~ —
|
||||||
|
**DECIDED 2026-07-09: directly in-engine.**
|
||||||
5. Phase F bottom bar: touch-only (proposed) or also desktop?
|
5. Phase F bottom bar: touch-only (proposed) or also desktop?
|
||||||
|
|||||||
@@ -22,11 +22,17 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|||||||
/// indices for enum variants. No longer loadable — v3 files are discarded.
|
/// indices for enum variants. No longer loadable — v3 files are discarded.
|
||||||
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
|
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
|
||||||
/// variants (e.g. `"Foundation1"` instead of `0`).
|
/// variants (e.g. `"Foundation1"` instead of `0`).
|
||||||
/// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer
|
/// - v5: `score`, `undo_count`, and `recycle_count` are no longer
|
||||||
/// persisted. They are derived from the upstream `card_game`/`klondike` session
|
/// persisted. They are derived from the upstream `card_game`/`klondike` session
|
||||||
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
|
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
|
||||||
/// still carry those keys load fine — the extra fields are ignored.
|
/// still carry those keys load fine — the extra fields are ignored.
|
||||||
pub const GAME_STATE_SCHEMA_VERSION: u32 = 5;
|
/// - v6 (current): `saved_moves` replaced by `recording`, the upstream
|
||||||
|
/// `card_game` session serialisation carrying the dealt board explicitly.
|
||||||
|
/// Loading no longer re-deals from `seed`, so in-progress saves survive
|
||||||
|
/// RNG/upstream upgrades that change the seed→deal mapping (the failure
|
||||||
|
/// class that PR #170 fixed for replays). v4/v5 files still load through
|
||||||
|
/// the legacy seed path and are rewritten as v6 on next save.
|
||||||
|
pub const GAME_STATE_SCHEMA_VERSION: u32 = 6;
|
||||||
|
|
||||||
/// Default move budget for a solvability check. Matches the winnable-deal retry
|
/// Default move budget for a solvability check. Matches the winnable-deal retry
|
||||||
/// loop in the engine.
|
/// loop in the engine.
|
||||||
@@ -95,8 +101,8 @@ pub enum GameMode {
|
|||||||
Difficulty(DifficultyLevel),
|
Difficulty(DifficultyLevel),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Output struct for schema v4 serialisation. `saved_moves` uses upstream
|
/// Output struct for schema v6 serialisation. `recording` is the upstream
|
||||||
/// `KlondikeInstruction` serde, which produces named enum variants.
|
/// session serialisation (config + dealt board + instruction list).
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
struct PersistedGameState {
|
struct PersistedGameState {
|
||||||
pub draw_mode: DrawStockConfig,
|
pub draw_mode: DrawStockConfig,
|
||||||
@@ -105,15 +111,16 @@ struct PersistedGameState {
|
|||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub saved_moves: Vec<KlondikeInstruction>,
|
pub recording: SessionRecording,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Input struct that accepts schema v4 and v5 `saved_moves` formats.
|
/// Input struct that accepts schema v4, v5, and v6 save formats.
|
||||||
///
|
///
|
||||||
/// `saved_moves` is deserialised directly as upstream `KlondikeInstruction`
|
/// v6 files carry `recording` (the upstream session serialisation, deal
|
||||||
/// (named-variant serde). `score`, `undo_count`, and `recycle_count` are
|
/// included); v4/v5 files carry `saved_moves` and rebuild the deal from
|
||||||
/// intentionally absent: all three are rebuilt by replaying the instruction
|
/// `seed`. `score`, `undo_count`, and `recycle_count` are intentionally
|
||||||
/// history through the upstream session stats. Older v4 save files still carry
|
/// absent: all three are rebuilt by replaying the instruction history
|
||||||
|
/// through the upstream session stats. Older v4 save files still carry
|
||||||
/// those keys; serde ignores them.
|
/// those keys; serde ignores them.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
struct PersistedGameStateIn {
|
struct PersistedGameStateIn {
|
||||||
@@ -126,7 +133,12 @@ struct PersistedGameStateIn {
|
|||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
#[serde(default = "schema_v1")]
|
#[serde(default = "schema_v1")]
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
|
/// v4/v5 only. Replayed against a seed-dealt board.
|
||||||
|
#[serde(default)]
|
||||||
pub saved_moves: Vec<KlondikeInstruction>,
|
pub saved_moves: Vec<KlondikeInstruction>,
|
||||||
|
/// v6 only. Carries the dealt board explicitly.
|
||||||
|
#[serde(default)]
|
||||||
|
pub recording: Option<SessionRecording>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
@@ -222,7 +234,7 @@ impl Serialize for GameState {
|
|||||||
seed: self.seed,
|
seed: self.seed,
|
||||||
take_from_foundation: self.take_from_foundation,
|
take_from_foundation: self.take_from_foundation,
|
||||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||||
saved_moves: self.saved_moves(),
|
recording: self.recording(),
|
||||||
}
|
}
|
||||||
.serialize(serializer)
|
.serialize(serializer)
|
||||||
}
|
}
|
||||||
@@ -232,34 +244,51 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
||||||
|
|
||||||
// Accept v4 (upstream named-variant serde) and v5 (current, derived
|
// Accept v6 (current, recording-based), plus v4/v5 (legacy seed-dealt
|
||||||
// stats). v3 (legacy u8-index format) and all others are rejected.
|
// saves — loadable while the seed→deal mapping they were written
|
||||||
match persisted.schema_version {
|
// under still holds; rewritten as v6 on the next save). v3 (legacy
|
||||||
4 | 5 => {}
|
// u8-index format) and all others are rejected.
|
||||||
|
let (mut game, instructions) = match persisted.schema_version {
|
||||||
|
6 => {
|
||||||
|
let Some(recording) = persisted.recording else {
|
||||||
|
return Err(serde::de::Error::custom(
|
||||||
|
"v6 save file is missing the session recording",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
// The dealt board and session config come from the recording
|
||||||
|
// itself; `seed` is presentation metadata only.
|
||||||
|
Self::from_recording(&recording, persisted.seed, persisted.mode)
|
||||||
|
}
|
||||||
|
4 | 5 => (
|
||||||
|
Self {
|
||||||
|
mode: persisted.mode,
|
||||||
|
elapsed_seconds: 0,
|
||||||
|
seed: persisted.seed,
|
||||||
|
take_from_foundation: true,
|
||||||
|
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
test_pile_state: None,
|
||||||
|
},
|
||||||
|
persisted.saved_moves,
|
||||||
|
),
|
||||||
v => {
|
v => {
|
||||||
return Err(serde::de::Error::custom(format!(
|
return Err(serde::de::Error::custom(format!(
|
||||||
"unsupported GameState schema version {v}"
|
"unsupported GameState schema version {v}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let mut game = Self {
|
|
||||||
mode: persisted.mode,
|
|
||||||
elapsed_seconds: persisted.elapsed_seconds,
|
|
||||||
seed: persisted.seed,
|
|
||||||
take_from_foundation: persisted.take_from_foundation,
|
|
||||||
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
|
||||||
#[cfg(feature = "test-support")]
|
|
||||||
test_pile_state: None,
|
|
||||||
};
|
};
|
||||||
|
game.mode = persisted.mode;
|
||||||
|
game.elapsed_seconds = persisted.elapsed_seconds;
|
||||||
|
game.take_from_foundation = persisted.take_from_foundation;
|
||||||
|
|
||||||
// Replay the saved instruction history. The upstream session tracks
|
// Replay the saved instruction history with validation — a tampered
|
||||||
// score components and recycle_count as it processes each move, so the
|
// or corrupt file must surface an error, not a silently wrong board.
|
||||||
// derived stats are correct once replay completes. `undo_count()` resets
|
// The upstream session tracks score components and recycle_count as
|
||||||
// to 0 across save/load because undone moves are not part of the saved
|
// it processes each move, so the derived stats are correct once
|
||||||
// forward history.
|
// replay completes. `undo_count()` resets to 0 across save/load
|
||||||
|
// because undone moves are not part of the saved forward history.
|
||||||
let replay_config = Self::replay_config(persisted.draw_mode);
|
let replay_config = Self::replay_config(persisted.draw_mode);
|
||||||
for instruction in persisted.saved_moves {
|
for instruction in instructions {
|
||||||
if !game
|
if !game
|
||||||
.session
|
.session
|
||||||
.state()
|
.state()
|
||||||
@@ -524,16 +553,6 @@ impl GameState {
|
|||||||
KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation)
|
KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collects the session instruction history as upstream types for schema v4
|
|
||||||
/// serialisation.
|
|
||||||
fn saved_moves(&self) -> Vec<KlondikeInstruction> {
|
|
||||||
self.session
|
|
||||||
.history()
|
|
||||||
.iter()
|
|
||||||
.map(|snapshot| *snapshot.instruction())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the deterministic instruction history for the current deal as
|
/// Returns the deterministic instruction history for the current deal as
|
||||||
/// upstream [`KlondikeInstruction`] values.
|
/// upstream [`KlondikeInstruction`] values.
|
||||||
///
|
///
|
||||||
@@ -1697,6 +1716,91 @@ mod tests {
|
|||||||
assert_eq!(replayed.move_count(), game.move_count());
|
assert_eq!(replayed.move_count(), game.move_count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v6 save files carry the deal in the recording; the `seed` field is
|
||||||
|
/// presentation metadata. Corrupting it must not change the loaded board.
|
||||||
|
#[test]
|
||||||
|
fn v6_save_load_ignores_seed_for_dealing() {
|
||||||
|
let game = game_with_some_moves(51);
|
||||||
|
let mut value = serde_json::to_value(&game).expect("serialise");
|
||||||
|
assert_eq!(value["schema_version"], 6);
|
||||||
|
value["seed"] = serde_json::Value::from(0xDEAD_BEEF_u64);
|
||||||
|
let loaded: GameState =
|
||||||
|
serde_json::from_value(value).expect("v6 save with wrong seed must still load");
|
||||||
|
assert_eq!(loaded.stock_cards(), game.stock_cards());
|
||||||
|
assert_eq!(loaded.waste_cards(), game.waste_cards());
|
||||||
|
for index in 0..7 {
|
||||||
|
let t = GameState::tableau_from_index(index).expect("tableau index");
|
||||||
|
assert_eq!(
|
||||||
|
loaded.pile(KlondikePile::Tableau(t)),
|
||||||
|
game.pile(KlondikePile::Tableau(t)),
|
||||||
|
"tableau {index} differs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(loaded.move_count(), game.move_count());
|
||||||
|
assert_eq!(loaded.score(), game.score());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy v5 files (seed + saved_moves, no recording) must still load
|
||||||
|
/// through the seed-dealt path until players resave as v6.
|
||||||
|
#[test]
|
||||||
|
fn v5_legacy_save_still_loads() {
|
||||||
|
let game = game_with_some_moves(145);
|
||||||
|
let v5 = serde_json::json!({
|
||||||
|
"draw_mode": game.draw_mode(),
|
||||||
|
"mode": game.mode,
|
||||||
|
"elapsed_seconds": 12,
|
||||||
|
"seed": game.seed,
|
||||||
|
"take_from_foundation": true,
|
||||||
|
"schema_version": 5,
|
||||||
|
"saved_moves": game.instruction_history(),
|
||||||
|
});
|
||||||
|
let loaded: GameState =
|
||||||
|
serde_json::from_value(v5).expect("v5 save must load via the legacy seed path");
|
||||||
|
assert_eq!(loaded.move_count(), game.move_count());
|
||||||
|
assert_eq!(loaded.stock_cards(), game.stock_cards());
|
||||||
|
assert_eq!(loaded.elapsed_seconds, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A v6 file without the recording payload is malformed and must be
|
||||||
|
/// rejected with an error rather than silently re-dealt from the seed.
|
||||||
|
#[test]
|
||||||
|
fn v6_save_without_recording_is_rejected() {
|
||||||
|
let game = GameState::new(7, DrawStockConfig::DrawOne);
|
||||||
|
let mut value = serde_json::to_value(&game).expect("serialise");
|
||||||
|
value.as_object_mut().expect("object").remove("recording");
|
||||||
|
let err = serde_json::from_value::<GameState>(value)
|
||||||
|
.expect_err("v6 save without recording must fail");
|
||||||
|
assert!(err.to_string().contains("recording"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A recording whose instruction list is invalid for its own deal must
|
||||||
|
/// be rejected on load — tampered or corrupt files surface an error,
|
||||||
|
/// never a silently wrong board.
|
||||||
|
#[test]
|
||||||
|
fn v6_save_with_invalid_history_is_rejected() {
|
||||||
|
use klondike::{DstFoundation, Foundation, KlondikePile};
|
||||||
|
// A foundation move from an empty tableau is never legal on a fresh
|
||||||
|
// deal's first instruction slot for this source shape.
|
||||||
|
let bogus = KlondikeInstruction::DstFoundation(DstFoundation {
|
||||||
|
src: KlondikePile::Foundation(Foundation::Foundation1),
|
||||||
|
foundation: Foundation::Foundation2,
|
||||||
|
});
|
||||||
|
let recording =
|
||||||
|
SessionRecording::from_instructions_unchecked(7, DrawStockConfig::DrawOne, [bogus]);
|
||||||
|
let v6 = serde_json::json!({
|
||||||
|
"draw_mode": DrawStockConfig::DrawOne,
|
||||||
|
"mode": GameMode::Classic,
|
||||||
|
"elapsed_seconds": 0,
|
||||||
|
"seed": 7,
|
||||||
|
"take_from_foundation": true,
|
||||||
|
"schema_version": 6,
|
||||||
|
"recording": recording,
|
||||||
|
});
|
||||||
|
let err =
|
||||||
|
serde_json::from_value::<GameState>(v6).expect_err("invalid history must be rejected");
|
||||||
|
assert!(err.to_string().contains("invalid"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_recording_of_fresh_deal_returns_empty_instructions() {
|
fn from_recording_of_fresh_deal_returns_empty_instructions() {
|
||||||
let game = GameState::new(7, DrawStockConfig::DrawThree);
|
let game = GameState::new(7, DrawStockConfig::DrawThree);
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ use std::io;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
|
use solitaire_core::{
|
||||||
|
DrawStockConfig,
|
||||||
|
game_state::{DifficultyLevel, GameMode},
|
||||||
|
};
|
||||||
|
|
||||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||||
|
|
||||||
@@ -248,6 +251,12 @@ pub struct Settings {
|
|||||||
/// cleanly to `None` via `#[serde(default)]`.
|
/// cleanly to `None` via `#[serde(default)]`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_difficulty: Option<DifficultyLevel>,
|
pub last_difficulty: Option<DifficultyLevel>,
|
||||||
|
/// Mode of the last game the player launched from the home overlay.
|
||||||
|
/// The home hero "New Game" button replays this mode with one tap.
|
||||||
|
/// Older `settings.json` files written before this field existed
|
||||||
|
/// deserialize cleanly to `GameMode::Classic` via `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_mode: GameMode,
|
||||||
/// Custom public name displayed on the leaderboard. When `None`, the
|
/// Custom public name displayed on the leaderboard. When `None`, the
|
||||||
/// player's server `username` is used instead. Trimmed to 32 characters
|
/// player's server `username` is used instead. Trimmed to 32 characters
|
||||||
/// before submission. Older `settings.json` files written before this
|
/// before submission. Older `settings.json` files written before this
|
||||||
@@ -415,6 +424,7 @@ impl Default for Settings {
|
|||||||
disable_smart_default_size: false,
|
disable_smart_default_size: false,
|
||||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||||
last_difficulty: None,
|
last_difficulty: None,
|
||||||
|
last_mode: GameMode::Classic,
|
||||||
leaderboard_display_name: None,
|
leaderboard_display_name: None,
|
||||||
leaderboard_opted_in: false,
|
leaderboard_opted_in: false,
|
||||||
take_from_foundation: true,
|
take_from_foundation: true,
|
||||||
|
|||||||
@@ -477,7 +477,7 @@ mod tests {
|
|||||||
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets
|
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets
|
||||||
/// to 0 on load because only the forward instruction history is persisted.
|
/// to 0 on load because only the forward instruction history is persisted.
|
||||||
#[test]
|
#[test]
|
||||||
fn game_state_v5_mid_game_round_trip() {
|
fn game_state_v6_mid_game_round_trip() {
|
||||||
use solitaire_core::KlondikeInstruction;
|
use solitaire_core::KlondikeInstruction;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
@@ -518,11 +518,16 @@ mod tests {
|
|||||||
|
|
||||||
save_game_state_to(&path, &gs).expect("save");
|
save_game_state_to(&path, &gs).expect("save");
|
||||||
|
|
||||||
// Verify the file carries the v5 schema marker.
|
// Verify the file carries the v6 schema marker and the recording.
|
||||||
let json = fs::read_to_string(&path).expect("read json");
|
let json = fs::read_to_string(&path).expect("read json");
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse saved json");
|
||||||
|
assert_eq!(
|
||||||
|
parsed["schema_version"], 6,
|
||||||
|
"saved file must use schema version 6",
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
json.contains("\"schema_version\"") && json.contains('5'),
|
parsed["recording"].is_object(),
|
||||||
"saved file must use schema version 5",
|
"saved file must embed the session recording",
|
||||||
);
|
);
|
||||||
|
|
||||||
let loaded =
|
let loaded =
|
||||||
@@ -530,7 +535,7 @@ mod tests {
|
|||||||
|
|
||||||
// The forward instruction history round-trips, so the reconstructed board
|
// The forward instruction history round-trips, so the reconstructed board
|
||||||
// re-serialises to byte-identical JSON.
|
// re-serialises to byte-identical JSON.
|
||||||
let path_reload = gs_path("v5_mid_game_reload");
|
let path_reload = gs_path("v6_mid_game_reload");
|
||||||
let _ = fs::remove_file(&path_reload);
|
let _ = fs::remove_file(&path_reload);
|
||||||
save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
|
save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+1119
-307
File diff suppressed because it is too large
Load Diff
@@ -178,6 +178,32 @@ pub fn spawn_modal<M: Component, F>(
|
|||||||
z_panel: i32,
|
z_panel: i32,
|
||||||
build_card: F,
|
build_card: F,
|
||||||
) -> Entity
|
) -> Entity
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut ChildSpawnerCommands),
|
||||||
|
{
|
||||||
|
spawn_modal_sized(
|
||||||
|
commands,
|
||||||
|
plugin_marker,
|
||||||
|
z_panel,
|
||||||
|
MODAL_CARD_MAX_WIDTH,
|
||||||
|
build_card,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default maximum card width in logical pixels — every [`spawn_modal`]
|
||||||
|
/// caller gets this. Surfaces that lay out side-by-side panes (e.g. the
|
||||||
|
/// two-pane Home on wide viewports) can raise it via [`spawn_modal_sized`].
|
||||||
|
pub const MODAL_CARD_MAX_WIDTH: f32 = 720.0;
|
||||||
|
|
||||||
|
/// [`spawn_modal`] with an explicit card `max_width`. Behaviour is
|
||||||
|
/// otherwise identical — same scrim, enter animation, and card chrome.
|
||||||
|
pub fn spawn_modal_sized<M: Component, F>(
|
||||||
|
commands: &mut Commands,
|
||||||
|
plugin_marker: M,
|
||||||
|
z_panel: i32,
|
||||||
|
max_width: f32,
|
||||||
|
build_card: F,
|
||||||
|
) -> Entity
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut ChildSpawnerCommands),
|
F: FnOnce(&mut ChildSpawnerCommands),
|
||||||
{
|
{
|
||||||
@@ -235,7 +261,7 @@ where
|
|||||||
padding: UiRect::all(VAL_SPACE_5),
|
padding: UiRect::all(VAL_SPACE_5),
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_LG)),
|
border_radius: BorderRadius::all(Val::Px(RADIUS_LG)),
|
||||||
max_width: Val::Px(720.0),
|
max_width: Val::Px(max_width),
|
||||||
align_items: AlignItems::Stretch,
|
align_items: AlignItems::Stretch,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -206,7 +206,9 @@ async function main() {
|
|||||||
invariant_ok: !!snap?.invariants?.state_ok,
|
invariant_ok: !!snap?.invariants?.state_ok,
|
||||||
history_len: Array.isArray(snap?.move_history) ? snap.move_history.length : null,
|
history_len: Array.isArray(snap?.move_history) ? snap.move_history.length : null,
|
||||||
replay_payload_present: payload !== null,
|
replay_payload_present: payload !== null,
|
||||||
replay_moves_len: Array.isArray(payload?.moves) ? payload.moves.length : 0,
|
replay_moves_len: Array.isArray(payload?.recording?.instructions)
|
||||||
|
? payload.recording.instructions.length
|
||||||
|
: 0,
|
||||||
};
|
};
|
||||||
}, { stepCap: maxSteps, policyName: policy, maxVisits: maxVisitsPerState });
|
}, { stepCap: maxSteps, policyName: policy, maxVisits: maxVisitsPerState });
|
||||||
|
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ test("draw-mode toggle affects replay payload draw_mode", async ({ page }) => {
|
|||||||
|
|
||||||
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
|
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
|
||||||
expect(payload.draw_mode).toBe("DrawThree");
|
expect(payload.draw_mode).toBe("DrawThree");
|
||||||
expect(payload.schema_version).toBe(2);
|
expect(payload.schema_version).toBe(4);
|
||||||
expect(Array.isArray(payload.moves)).toBeTruthy();
|
expect(Array.isArray(payload.recording?.instructions)).toBeTruthy();
|
||||||
expect(payload.moves.length).toBeGreaterThan(0);
|
expect(payload.recording.instructions.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("autonomous play keeps invariants stable across seed batch", async ({ page }) => {
|
test("autonomous play keeps invariants stable across seed batch", async ({ page }) => {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ test("debug failure report contains replay diagnostics", async ({ page }) => {
|
|||||||
expect(report.invariants).toBeTruthy();
|
expect(report.invariants).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("replay payload builder exports schema-v2 moves", async ({ page }) => {
|
test("replay payload builder exports a schema-v4 recording", async ({ page }) => {
|
||||||
await page.goto("/play-classic?seed=42");
|
await page.goto("/play-classic?seed=42");
|
||||||
await page.waitForFunction(() => typeof window.__FERROUS_DEBUG__ === "object");
|
await page.waitForFunction(() => typeof window.__FERROUS_DEBUG__ === "object");
|
||||||
|
|
||||||
@@ -57,10 +57,14 @@ test("replay payload builder exports schema-v2 moves", async ({ page }) => {
|
|||||||
.poll(async () => await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload() !== null))
|
.poll(async () => await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload() !== null))
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
|
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
|
||||||
expect(payload.schema_version).toBe(2);
|
// Schema v4: the wasm layer assembles the whole payload; the deal is
|
||||||
|
// embedded in `recording` and moves live at recording.instructions.
|
||||||
|
expect(payload.schema_version).toBe(4);
|
||||||
expect(payload.draw_mode).toMatch(/Draw(One|Three)/);
|
expect(payload.draw_mode).toMatch(/Draw(One|Three)/);
|
||||||
expect(payload.mode).toBe("Classic");
|
expect(payload.mode).toBe("Classic");
|
||||||
expect(Array.isArray(payload.moves)).toBeTruthy();
|
expect(payload.recording).toBeTruthy();
|
||||||
expect(payload.moves.length).toBeGreaterThan(0);
|
expect(payload.recording.initial_state).toBeTruthy();
|
||||||
expect(payload.win_move_index).toBe(payload.moves.length - 1);
|
expect(Array.isArray(payload.recording.instructions)).toBeTruthy();
|
||||||
|
expect(payload.recording.instructions.length).toBeGreaterThan(0);
|
||||||
|
expect(payload.win_move_index).toBe(payload.recording.instructions.length - 1);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user