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
+16 -18
View File
@@ -1,13 +1,12 @@
use super::*;
use chrono::NaiveDate;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::{Rank, Suit};
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
use solitaire_data::{Replay, ReplayMove};
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
use solitaire_core::{Rank, Suit};
use solitaire_data::Replay;
/// Build a minimal but well-formed [`Replay`] with `move_count` no-op
/// `StockClick` entries. Tests only ever read `replay.moves.len()`
/// `RotateStock` entries. Tests only ever read `replay.moves.len()`
/// (denominator of the progress indicator), so the move kind is
/// irrelevant beyond producing the right count.
fn synthetic_replay(move_count: usize) -> Replay {
@@ -18,7 +17,9 @@ fn synthetic_replay(move_count: usize) -> Replay {
120,
1_000,
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
(0..move_count).map(|_| ReplayMove::StockClick).collect(),
(0..move_count)
.map(|_| KlondikeInstruction::RotateStock)
.collect(),
)
}
@@ -1123,20 +1124,17 @@ fn format_pile_uses_one_indexed_lowercase_names() {
);
}
/// Move-body formatter renders `StockClick` as a label and
/// `Move` as a `from → to` arrow. The `count` field is
/// deliberately omitted — at row scale it adds noise.
/// Move-body formatter renders `RotateStock` as a label. The
/// `Dst*` variants render as a `→ to` arrow, but their pile-stack
/// source types are runtime-only and not constructible from this
/// crate, so only the stock-cycle label is asserted here; the
/// arrow path is exercised end-to-end through the move-log
/// integration tests.
#[test]
fn format_move_body_handles_both_variants() {
assert_eq!(format_move_body(&ReplayMove::StockClick), "stock cycle");
fn format_move_body_handles_stock_cycle() {
assert_eq!(
format_move_body(&ReplayMove::Move {
from: SavedKlondikePile::Stock,
to: SavedKlondikePile::Tableau(SavedTableau(4)),
count: 1,
}),
"waste \u{2192} tableau 5",
"Move variant must render as `{{from}} → {{to}}` with 1-indexed pile numbers",
format_move_body(&KlondikeInstruction::RotateStock),
"stock cycle"
);
}