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
+35 -138
View File
@@ -3,8 +3,8 @@
//! The web replay player at `<server>/replays/<id>` fetches a [`Replay`]
//! JSON via `GET /api/replays/:id`, hands it to [`ReplayPlayer::new`],
//! and then advances frame-by-frame with [`ReplayPlayer::step`]. Each
//! step applies one [`ReplayMove`] to the underlying `GameState` and
//! returns the resulting pile snapshot as JSON for the JS layer to
//! step applies one [`KlondikeInstruction`] to the underlying `GameState`
//! and returns the resulting pile snapshot as JSON for the JS layer to
//! render.
//!
//! The state machine is the same Rust [`solitaire_core::GameState`]
@@ -19,30 +19,20 @@
//! is the contract.
use chrono::NaiveDate;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use serde::{Deserialize, Serialize};
use solitaire_core::{Card, Deck, Rank, Suit};
use solitaire_core::error::MoveError;
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
use solitaire_core::klondike_adapter::{
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, tableau_from_index,
};
use wasm_bindgen::prelude::*;
/// Mirrors the variants of `solitaire_data::ReplayMove` v2 (atomic
/// player inputs, post-StockClick refinement). Only the JSON shape
/// matters for cross-crate compatibility.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReplayMove {
Move {
from: SavedKlondikePile,
to: SavedKlondikePile,
count: usize,
},
StockClick,
}
/// Mirrors `solitaire_data::Replay` v2.
/// Mirrors `solitaire_data::Replay` v3.
///
/// `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.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Replay {
#[serde(default)]
@@ -53,7 +43,7 @@ pub struct Replay {
pub time_seconds: u64,
pub final_score: i32,
pub recorded_at: NaiveDate,
pub moves: Vec<ReplayMove>,
pub moves: Vec<KlondikeInstruction>,
}
/// JS-friendly snapshot of a `GameState` at a particular replay step.
@@ -110,7 +100,7 @@ impl From<&(Card, bool)> for CardSnapshot {
#[wasm_bindgen]
pub struct ReplayPlayer {
game: GameState,
moves: Vec<ReplayMove>,
moves: Vec<KlondikeInstruction>,
step_idx: usize,
}
@@ -145,17 +135,8 @@ impl ReplayPlayer {
if self.step_idx >= self.moves.len() {
return Ok(None);
}
let mv = self.moves[self.step_idx].clone();
match mv {
ReplayMove::Move { from, to, count } => self.game.move_cards(
from.try_into()
.map_err(|_| MoveError::RuleViolation("invalid replay pile".into()))?,
to.try_into()
.map_err(|_| MoveError::RuleViolation("invalid replay pile".into()))?,
count,
)?,
ReplayMove::StockClick => self.game.draw()?,
}
let instruction = self.moves[self.step_idx];
self.game.apply_instruction(instruction)?;
self.step_idx += 1;
Ok(Some(self.snapshot()))
}
@@ -335,7 +316,7 @@ pub struct DebugSnapshot {
pub mode: GameMode,
pub state: GameSnapshot,
pub legal_moves: Vec<DebugMove>,
pub move_history: Vec<SavedInstruction>,
pub move_history: Vec<KlondikeInstruction>,
pub invariants: DebugInvariantReport,
pub state_json: String,
}
@@ -569,90 +550,15 @@ impl SolitaireGame {
legal_moves_for_game(&self.game)
}
fn move_history_native(&self) -> Vec<SavedInstruction> {
fn move_history_native(&self) -> Vec<KlondikeInstruction> {
self.game.instruction_history()
}
fn replay_moves_native(&self) -> Result<Vec<ReplayMove>, String> {
let mut replay_game =
GameState::new_with_mode(self.game.seed, self.game.draw_mode(), self.game.mode);
let mut replay_moves = Vec::new();
for instruction in self.game.instruction_history() {
let replay_move = match instruction {
SavedInstruction::RotateStock => ReplayMove::StockClick,
SavedInstruction::DstFoundation(dst) => ReplayMove::Move {
from: dst.src,
to: SavedKlondikePile::Foundation(dst.foundation),
count: 1,
},
SavedInstruction::DstTableau(dst) => {
let (from, count) = match dst.src {
SavedKlondikePileStack::Stock => (SavedKlondikePile::Stock, 1),
SavedKlondikePileStack::Foundation(foundation) => {
(SavedKlondikePile::Foundation(foundation), 1)
}
SavedKlondikePileStack::Tableau(tableau_stack) => {
let tableau =
tableau_from_index(tableau_stack.tableau.0 as usize).ok_or_else(
|| {
format!(
"invalid tableau index in move history: {}",
tableau_stack.tableau.0
)
},
)?;
let face_up_count = replay_game
.pile(KlondikePile::Tableau(tableau))
.iter()
.rev()
.take_while(|(_, face_up)| *face_up)
.count();
let skip = tableau_stack.skip_cards.0 as usize;
let count = face_up_count.checked_sub(skip).ok_or_else(|| {
format!(
"invalid tableau skip in move history: face_up={face_up_count}, skip={skip}"
)
})?;
if count == 0 {
return Err(
"invalid tableau move in move history: zero-card move".into()
);
}
(SavedKlondikePile::Tableau(tableau_stack.tableau), count)
}
};
ReplayMove::Move {
from,
to: SavedKlondikePile::Tableau(dst.tableau),
count,
}
}
};
match &replay_move {
ReplayMove::StockClick => replay_game
.draw()
.map_err(|e| format!("failed to apply stock click while exporting replay: {e}"))?,
ReplayMove::Move { from, to, count } => {
let src: KlondikePile = (*from)
.try_into()
.map_err(|e| format!("invalid replay source pile: {e}"))?;
let dst: KlondikePile = (*to)
.try_into()
.map_err(|e| format!("invalid replay destination pile: {e}"))?;
replay_game.move_cards(src, dst, *count).map_err(|e| {
format!(
"failed to apply move while exporting replay ({from:?} -> {to:?}, count={count}): {e}"
)
})?;
}
}
replay_moves.push(replay_move);
}
Ok(replay_moves)
fn replay_moves_native(&self) -> Vec<KlondikeInstruction> {
// The session's forward instruction history *is* the replayable
// move list: each entry replays cleanly via `apply_instruction`
// against a fresh game with the same seed/draw mode/mode.
self.game.instruction_history()
}
fn debug_snapshot_native(&self) -> DebugSnapshot {
@@ -829,15 +735,13 @@ impl SolitaireGame {
}
}
/// Returns replay moves encoded in the `solitaire_data::Replay` wire format.
/// Returns replay moves encoded in the `solitaire_data::Replay` wire format
/// — a list of upstream [`KlondikeInstruction`]s.
///
/// This derives move counts from the deterministic instruction history and
/// validates that the resulting move stream replays cleanly from the current
/// game's seed/draw mode.
/// This is the deterministic instruction history; together with `seed()`
/// and the draw mode it replays cleanly via `apply_instruction`.
pub fn replay_moves(&self) -> Result<JsValue, JsValue> {
let moves = self
.replay_moves_native()
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let moves = self.replay_moves_native();
serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string()))
}
@@ -1036,10 +940,7 @@ mod tests {
}
}
let exported_moves = match game.replay_moves_native() {
Ok(moves) => moves,
Err(err) => panic!("replay export failed: {err}"),
};
let exported_moves = game.replay_moves_native();
assert!(
!exported_moves.is_empty(),
"progressed game must export a non-empty replay move list"
@@ -1049,24 +950,20 @@ mod tests {
Ok(value) => value,
Err(err) => panic!("failed to serialise exported replay moves: {err}"),
};
let array = match moves_json.as_array() {
Some(values) => values,
None => panic!("exported replay moves must serialise as a JSON array"),
};
assert!(
array.iter().all(|entry| {
entry.as_str() == Some("StockClick") || entry.get("Move").is_some()
}),
"replay move JSON must match ReplayMove wire shape"
moves_json.is_array(),
"exported replay moves must serialise as a JSON array"
);
let parsed_back: Vec<ReplayMove> = match serde_json::from_value(moves_json) {
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 ReplayMove list: {err}"),
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 ReplayMove"
"replay move JSON must round-trip through KlondikeInstruction"
);
let recorded_at = match NaiveDate::from_ymd_opt(2026, 6, 1) {
@@ -1074,7 +971,7 @@ mod tests {
None => panic!("invalid recorded_at date in test"),
};
let replay = Replay {
schema_version: 2,
schema_version: 3,
seed,
draw_mode,
mode: GameMode::Classic,