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:
+35
-138
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user