refactor: persist replay/save moves as KlondikeInstruction, not pile coords (#89)

Pile-position types (Tableau, Foundation, KlondikePile, KlondikePileStack)
are runtime-only and have no serde upstream. Per Rhys's guidance, the
persistence layer now stores the moves (KlondikeInstruction) rather than
board coordinates, decoding back to runtime pile positions on demand.

Core / data:
- game_state: instruction_history() -> Vec<KlondikeInstruction>; add
  instruction_to_piles() and apply_instruction(); drop AnyInstruction.
- klondike_adapter: delete the entire Saved* serde mirror section
  (SavedTableau/Foundation/SkipCards/KlondikePile/TableauStack/
  KlondikePileStack/DstFoundation/DstTableau/SavedInstruction).
- replay: drop the bespoke ReplayMove serde mirror; Replay.moves is now
  Vec<KlondikeInstruction>; REPLAY_SCHEMA_VERSION 2 -> 3.
- storage: game_state save format v3 rejected (v4/v5 only).

Engine / wasm consumers:
- record via KlondikeInstruction (stock click = RotateStock).
- playback decodes each instruction to (from, to, count) against the
  live state via instruction_to_piles, then fires the canonical event;
  undecodable instructions are skipped with a warning, never panic.
- remove all use solitaire_data::ReplayMove and Saved* imports.

Workspace check, clippy -D warnings, and the full test suite all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-12 12:43:47 -07:00
parent e0a858d4e8
commit 9bbb57134f
14 changed files with 311 additions and 810 deletions
+1 -1
View File
@@ -163,7 +163,7 @@ pub use sync_client::{SolitaireServerClient, provider_for_backend};
pub mod replay;
pub use replay::{
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from,
ReplayHistory, append_replay_to_history, load_replay_history_from,
migrate_legacy_latest_replay, replay_history_path, save_replay_history_to,
};
// `latest_replay_path` is still consumed by the engine's one-shot legacy
+53 -62
View File
@@ -12,13 +12,22 @@
//! carries any other version so older replays are silently dropped instead
//! of crashing the loader.
//!
//! The recording is intentionally minimal — only [`ReplayMove`] entries
//! 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) +
//! 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.
//!
//! 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.
use std::fs;
use std::io;
@@ -26,8 +35,7 @@ use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::klondike_adapter::SavedKlondikePile;
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
@@ -65,14 +73,17 @@ fn history_schema_v0() -> u32 {
/// seeing a broken one.
///
/// History:
/// - v1: initial release. `ReplayMove` had separate `Draw` and `Recycle`
/// - v1: initial release. The move type had separate `Draw` and `Recycle`
/// variants which carried the *outcome* of a stock interaction rather
/// than the player's atomic input.
/// - v2 (current): `Draw` + `Recycle` collapsed into a single `StockClick`
/// variant. The engine resolves draw-vs-recycle deterministically from
/// the current stock state, so the input alone is sufficient and the
/// replay model now stores atomic player inputs end-to-end.
pub const REPLAY_SCHEMA_VERSION: u32 = 2;
/// - 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;
/// Default value for [`Replay::schema_version`] when deserialising files
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
@@ -81,32 +92,6 @@ fn schema_v0() -> u32 {
0
}
/// One atomic player input recorded during a winning game, in the order
/// it was applied to the live `GameState`.
///
/// `Undo` is intentionally absent — see the module-level docs.
///
/// The variants represent *inputs*, not outcomes. `StockClick` covers
/// every player click on the stock pile; the engine then resolves
/// draw-vs-recycle deterministically from the current state during both
/// recording and playback, so the same input always produces the same
/// effect on the same starting deal.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReplayMove {
/// A successful `move_cards(from, to, count)` call.
Move {
/// Source pile.
from: SavedKlondikePile,
/// Destination pile.
to: SavedKlondikePile,
/// Number of cards moved.
count: usize,
},
/// A click on the stock pile. Resolves to a draw when stock is
/// non-empty and to a waste→stock recycle when stock is empty.
StockClick,
}
/// A complete recording of a single winning game.
///
/// Replays are reconstructed by rebuilding a fresh
@@ -134,9 +119,11 @@ pub struct Replay {
pub final_score: i32,
/// ISO-8601 date the win was recorded.
pub recorded_at: NaiveDate,
/// Ordered move list. Each entry is what the player did, replayable
/// against a fresh `GameState` constructed from the seed.
pub moves: Vec<ReplayMove>,
/// 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>,
/// 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
@@ -185,7 +172,7 @@ impl Replay {
time_seconds: u64,
final_score: i32,
recorded_at: NaiveDate,
moves: Vec<ReplayMove>,
moves: Vec<KlondikeInstruction>,
) -> Self {
Self {
schema_version: REPLAY_SCHEMA_VERSION,
@@ -442,7 +429,9 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
#[allow(deprecated)]
mod tests {
use super::*;
use solitaire_core::klondike_adapter::{SavedFoundation, SavedTableau};
use klondike::{
DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau,
};
use std::env;
fn tmp_path(name: &str) -> PathBuf {
@@ -459,18 +448,16 @@ mod tests {
5_120,
date,
vec![
ReplayMove::StockClick,
ReplayMove::Move {
from: SavedKlondikePile::Stock,
to: SavedKlondikePile::Tableau(SavedTableau(3)),
count: 1,
},
ReplayMove::StockClick,
ReplayMove::Move {
from: SavedKlondikePile::Tableau(SavedTableau(3)),
to: SavedKlondikePile::Foundation(SavedFoundation(0)),
count: 1,
},
KlondikeInstruction::RotateStock,
KlondikeInstruction::DstTableau(DstTableau {
src: KlondikePileStack::Stock,
tableau: Tableau::Tableau4,
}),
KlondikeInstruction::RotateStock,
KlondikeInstruction::DstFoundation(DstFoundation {
src: KlondikePile::Tableau(Tableau::Tableau4),
foundation: Foundation::Foundation1,
}),
],
)
}
@@ -601,7 +588,7 @@ mod tests {
60,
id,
date,
vec![ReplayMove::StockClick],
vec![KlondikeInstruction::RotateStock],
)
}
@@ -837,9 +824,11 @@ mod tests {
let path = tmp_path("legacy_no_win_move_index");
let _ = fs::remove_file(&path);
// Hand-rolled minimal v2 replay JSON with no win_move_index field.
let v2_no_field = r#"{
"schema_version": 2,
// 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",
@@ -847,8 +836,10 @@ mod tests {
"final_score": 100,
"recorded_at": "2026-05-02",
"moves": []
}"#;
fs::write(&path, v2_no_field).expect("write fixture");
}}"#,
schema = REPLAY_SCHEMA_VERSION,
);
fs::write(&path, no_field).expect("write fixture");
let loaded = load_latest_replay_from(&path).expect("load");
assert_eq!(loaded.win_move_index, None);
+11 -20
View File
@@ -583,24 +583,16 @@ mod tests {
);
}
/// A schema v3 save (instruction history using u8 indices) must load
/// successfully and be transparently migrated to schema v4.
///
/// This verifies the `AnyInstruction` untagged deserialization migration
/// path. v3 files with `RotateStock` (unit variant, format-identical in
/// v3 and v4) load correctly and report `schema_version == 4` after load.
/// The `SavedInstruction` boundary tests in `proptest_tests.rs` cover the
/// u8-to-named conversion for `DstFoundation` / `DstTableau` indices.
/// A schema v3 save (instruction history using the old u8-index mirror
/// types) is no longer loadable. The legacy migration path was dropped,
/// so any file claiming `schema_version: 3` must be rejected and the
/// player started on a fresh game.
#[test]
fn game_state_v3_migrates_to_v4() {
use solitaire_core::game_state::GameState;
let path = gs_path("v3_migrate");
fn game_state_v3_is_rejected() {
let path = gs_path("v3_reject");
let _ = fs::remove_file(&path);
// Hand-crafted schema v3 JSON: one RotateStock (draw) instruction.
// RotateStock serialises as the string "RotateStock" in both v3 and v4,
// so this exercises the schema version acceptance code path.
let v3_json = r#"{
"draw_mode": "DrawOne",
"mode": "Classic",
@@ -615,13 +607,12 @@ mod tests {
}"#;
fs::write(&path, v3_json).expect("write v3 fixture");
let loaded = load_game_state_from(&path)
.expect("schema v3 must be accepted and migrated to v4");
assert!(
load_game_state_from(&path).is_none(),
"schema v3 must be rejected (no migration path)",
);
// The loaded game should match a fresh game that had one draw applied.
let mut expected = GameState::new(42, DrawStockConfig::DrawOne);
expected.draw().expect("draw must succeed on a fresh game");
assert_eq!(loaded, expected, "migrated v3 game state must match equivalent v4 state");
let _ = fs::remove_file(&path);
}
/// Schema v2 stored raw pile arrays and undo snapshots (no instruction