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
+10 -6
View File
@@ -8,8 +8,7 @@ use super::*;
use crate::layout::LayoutResource;
use crate::replay_playback::ReplayPlaybackState;
use crate::resources::GameStateResource;
use solitaire_core::KlondikePile;
use solitaire_data::ReplayMove;
use solitaire_core::{KlondikeInstruction, KlondikePile};
/// Overwrites the banner label whenever the resource changes — covers the
/// `Playing → Completed` transition by swapping "▌ replay" for
@@ -85,9 +84,15 @@ pub(crate) fn update_floating_progress_chip(
// the most-recently-applied move sits at `cursor - 1`.
let dest_pile = match state.as_ref() {
ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
// The destination pile is recoverable directly from the
// instruction — no live state needed. `RotateStock` has no
// destination (the chip hides over the stock pile).
match &replay.moves[cursor - 1] {
ReplayMove::Move { to, .. } => Some(*to),
ReplayMove::StockClick => None,
KlondikeInstruction::DstFoundation(dst) => {
Some(KlondikePile::Foundation(dst.foundation))
}
KlondikeInstruction::DstTableau(dst) => Some(KlondikePile::Tableau(dst.tableau)),
KlondikeInstruction::RotateStock => None,
}
}
_ => None,
@@ -95,8 +100,7 @@ pub(crate) fn update_floating_progress_chip(
let Some(world_pos) = dest_pile
.as_ref()
.and_then(|p| KlondikePile::try_from(*p).ok())
.and_then(|p| layout.0.pile_positions.get(&p).copied())
.and_then(|p| layout.0.pile_positions.get(p).copied())
else {
// Nothing to point at — hide every chip and exit.
for (_, mut visibility, _) in chips.iter_mut() {