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
+29 -21
View File
@@ -1,10 +1,8 @@
use super::ReplayPlaybackState;
use chrono::Datelike;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Card, Rank, Suit};
use solitaire_core::game_state::GameState;
use solitaire_core::klondike_adapter::SavedKlondikePile;
use solitaire_data::ReplayMove;
use solitaire_core::{Card, Rank, Suit};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
/// Pure helper — formats the `GAME #YYYY-DDD` caption for the given
/// state. Returns `None` for `Inactive` / `Completed` (the replay is
@@ -60,12 +58,6 @@ pub(crate) fn format_pile(p: &KlondikePile) -> String {
}
}
pub(crate) fn format_saved_pile(p: &SavedKlondikePile) -> String {
KlondikePile::try_from(*p)
.map(|pile| format_pile(&pile))
.unwrap_or_else(|_| "unknown pile".to_string())
}
fn foundation_number(foundation: Foundation) -> u8 {
match foundation {
Foundation::Foundation1 => 1,
@@ -87,20 +79,36 @@ fn tableau_number(tableau: Tableau) -> u8 {
}
}
/// Pure helper — formats a [`ReplayMove`] as the body of a
/// move-log row. `StockClick` reads as `"stock cycle"`; `Move`
/// reads as `"{from} → {to}"` using [`format_pile`] for both
/// endpoints. The `count` field is omitted from the row body —
/// at row scale it adds visual noise without meaningful
/// Pure helper — formats a [`KlondikeInstruction`] as the body of a
/// move-log row. `RotateStock` reads as `"stock cycle"`; a `Dst*`
/// instruction reads as `"{from} → {to}"` using [`format_pile`] for
/// each nameable endpoint. The card count is omitted from the row
/// body — at row scale it adds visual noise without meaningful
/// information for the typical 1-card moves.
pub(crate) fn format_move_body(m: &ReplayMove) -> String {
match m {
ReplayMove::StockClick => "stock cycle".to_string(),
ReplayMove::Move { from, to, .. } => {
///
/// The destination pile is always recoverable directly from the
/// instruction. The source pile is shown when it is statically
/// nameable (a `DstFoundation` carries a [`KlondikePile`] source);
/// a `DstTableau`'s source is the runtime-only `KlondikePileStack`
/// type — not re-exported across the `solitaire_core` boundary and so
/// not pattern-matchable here — so its row renders `"→ {to}"` without
/// a leading source label. Faithful full-coordinate decoding lives in
/// [`GameState::instruction_to_piles`] on the playback path; the
/// move-log is a display-only digest.
pub(crate) fn format_move_body(instruction: &KlondikeInstruction) -> String {
match instruction {
KlondikeInstruction::RotateStock => "stock cycle".to_string(),
KlondikeInstruction::DstFoundation(dst) => {
format!(
"{} \u{2192} {}",
format_saved_pile(from),
format_saved_pile(to)
format_pile(&dst.src),
format_pile(&KlondikePile::Foundation(dst.foundation))
)
}
KlondikeInstruction::DstTableau(dst) => {
format!(
"\u{2192} {}",
format_pile(&KlondikePile::Tableau(dst.tableau))
)
}
}