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
+1 -118
View File
@@ -1,13 +1,8 @@
use card_game::{Card, Game};
use klondike::{DrawStockConfig, Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau};
use klondike::{DrawStockConfig, Foundation, KlondikePile, Tableau};
use proptest::prelude::*;
use crate::game_state::GameState;
use crate::klondike_adapter::{
InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation,
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau,
SavedTableauStack,
};
// ---------------------------------------------------------------------------
// Shared helpers
@@ -261,116 +256,4 @@ proptest! {
}
}
// -------------------------------------------------------------------------
// SavedInstruction ↔ KlondikeInstruction round-trip
// -------------------------------------------------------------------------
/// Every valid `SavedInstruction` survives a round-trip through
/// `KlondikeInstruction::try_from(SavedInstruction::from(original))`.
///
/// Covers all three variants (`RotateStock`, `DstFoundation`, `DstTableau`)
/// and all legal sub-field ranges:
/// - `SavedTableau`: 06
/// - `SavedFoundation`: 03
/// - `SavedSkipCards`: 012
#[test]
fn saved_instruction_round_trip(
instruction in saved_instruction_strategy(),
) {
let klondike = KlondikeInstruction::try_from(instruction);
prop_assert!(
klondike.is_ok(),
"TryFrom failed for valid SavedInstruction {instruction:?}: {:?}",
klondike.err(),
);
let saved_again = SavedInstruction::from(klondike.expect("checked above"));
prop_assert_eq!(
saved_again,
instruction,
"round-trip produced a different SavedInstruction",
);
}
}
// ---------------------------------------------------------------------------
// Proptest strategies for SavedInstruction and its sub-types
// ---------------------------------------------------------------------------
fn saved_tableau_strategy() -> impl Strategy<Value = SavedTableau> {
(0u8..=6).prop_map(SavedTableau)
}
fn saved_foundation_strategy() -> impl Strategy<Value = SavedFoundation> {
(0u8..=3).prop_map(SavedFoundation)
}
fn saved_skip_cards_strategy() -> impl Strategy<Value = SavedSkipCards> {
(0u8..=12).prop_map(SavedSkipCards)
}
fn saved_klondike_pile_strategy() -> impl Strategy<Value = SavedKlondikePile> {
prop_oneof![
saved_tableau_strategy().prop_map(SavedKlondikePile::Tableau),
Just(SavedKlondikePile::Stock),
saved_foundation_strategy().prop_map(SavedKlondikePile::Foundation),
]
}
fn saved_klondike_pile_stack_strategy() -> impl Strategy<Value = SavedKlondikePileStack> {
prop_oneof![
(saved_tableau_strategy(), saved_skip_cards_strategy()).prop_map(|(tableau, skip_cards)| {
SavedKlondikePileStack::Tableau(SavedTableauStack { tableau, skip_cards })
}),
Just(SavedKlondikePileStack::Stock),
saved_foundation_strategy().prop_map(SavedKlondikePileStack::Foundation),
]
}
fn saved_instruction_strategy() -> impl Strategy<Value = SavedInstruction> {
prop_oneof![
Just(SavedInstruction::RotateStock),
(saved_klondike_pile_strategy(), saved_foundation_strategy()).prop_map(
|(src, foundation)| {
SavedInstruction::DstFoundation(SavedDstFoundation { src, foundation })
}
),
(saved_klondike_pile_stack_strategy(), saved_tableau_strategy()).prop_map(
|(src, tableau)| {
SavedInstruction::DstTableau(SavedDstTableau { src, tableau })
}
),
]
}
// ---------------------------------------------------------------------------
// Boundary error unit tests (exact out-of-range values)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod saved_instruction_boundary_tests {
use super::*;
#[test]
fn saved_tableau_7_is_invalid() {
let result = Tableau::try_from(SavedTableau(7));
assert_eq!(result, Err(InvalidSavedInstruction::Tableau(7)));
}
#[test]
fn saved_tableau_255_is_invalid() {
let result = Tableau::try_from(SavedTableau(255));
assert_eq!(result, Err(InvalidSavedInstruction::Tableau(255)));
}
#[test]
fn saved_foundation_4_is_invalid() {
let result = Foundation::try_from(SavedFoundation(4));
assert_eq!(result, Err(InvalidSavedInstruction::Foundation(4)));
}
#[test]
fn saved_skip_cards_13_is_invalid() {
let result = SkipCards::try_from(SavedSkipCards(13));
assert_eq!(result, Err(InvalidSavedInstruction::SkipCards(13)));
}
}