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
+37 -36
View File
@@ -15,11 +15,11 @@ use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use bevy::window::AppLifecycle;
use solitaire_core::KlondikePile;
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET};
use solitaire_core::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction};
#[allow(deprecated)]
use solitaire_data::latest_replay_path;
use solitaire_data::{
Replay, ReplayMove, SOLVER_DEAL_RETRY_CAP, append_replay_to_history, delete_game_state_at,
Replay, SOLVER_DEAL_RETRY_CAP, append_replay_to_history, delete_game_state_at,
game_state_file_path, load_game_state_from, migrate_legacy_latest_replay, replay_history_path,
save_game_state_to,
};
@@ -105,18 +105,21 @@ pub struct RestoreContinueButton;
#[derive(Component, Debug)]
pub struct RestoreNewGameButton;
/// In-memory accumulator for [`ReplayMove`] entries during the current
/// game. Cleared on every new-game start; frozen into a [`Replay`] and
/// flushed to disk by [`record_replay_on_win`] when the player wins.
/// In-memory accumulator for [`KlondikeInstruction`] entries during the
/// current game. Cleared on every new-game start; frozen into a [`Replay`]
/// and flushed to disk by [`record_replay_on_win`] when the player wins.
///
/// Recording captures only successful state-mutating events the player
/// drove (`MoveRequestEvent`, `DrawRequestEvent`). `UndoRequestEvent` is
/// intentionally not recorded — see [`solitaire_data::replay`] for the
/// design rationale.
/// design rationale. Each entry is the atomic player input as a
/// [`KlondikeInstruction`] (a stock click is
/// [`KlondikeInstruction::RotateStock`]); pile-position types are
/// runtime-only and never persisted.
#[derive(Resource, Debug, Default, Clone)]
pub struct RecordingReplay {
/// Ordered list of moves applied so far this game.
pub moves: Vec<ReplayMove>,
/// Ordered list of instructions applied so far this game.
pub moves: Vec<KlondikeInstruction>,
}
impl RecordingReplay {
@@ -851,7 +854,7 @@ fn handle_draw(
// the click happens — re-executing on the same starting
// deal produces the same effect, so the input alone is
// sufficient to recover the move on playback.
recording.moves.push(ReplayMove::StockClick);
recording.moves.push(KlondikeInstruction::RotateStock);
changed.write(StateChangedEvent);
}
Err(e) => warn!("draw rejected: {e}"),
@@ -889,11 +892,17 @@ fn handle_move(
// Record the move in the in-flight replay buffer. Done
// first so the entry is captured even if a subsequent
// event-write or pile-lookup happens to bail out below.
recording.moves.push(ReplayMove::Move {
from: ev.from.into(),
to: ev.to.into(),
count: ev.count,
});
// `move_cards` resolved the pile coordinates to a
// `KlondikeInstruction` and pushed it onto the session
// history; recover that exact instruction from the tail
// (no clone — the instruction is `Copy`). Pile-position
// types are runtime-only, so we persist the instruction
// rather than the (from, to, count) triple.
if let Some(instruction) =
game.0.session().history().last().map(|s| *s.instruction())
{
recording.moves.push(instruction);
}
// Fire flip event if the candidate card is now face-up.
if let Some(fcard) = flip_candidate
&& pile_cards(&game.0, &ev.from)
@@ -1301,7 +1310,6 @@ fn save_game_state_on_exit(
mod tests {
use super::*;
use solitaire_core::{Foundation, KlondikePile, Tableau};
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
/// Build a minimal headless `App` with just `GamePlugin` installed.
/// Disables persistence and overrides the seed so tests are deterministic
@@ -2102,7 +2110,7 @@ mod tests {
1,
"only the draw is recorded; the undo does not erase it nor add a new entry",
);
assert!(matches!(recording.moves[0], ReplayMove::StockClick));
assert!(matches!(recording.moves[0], KlondikeInstruction::RotateStock));
}
/// Starting a new game wipes the recording so the next deal begins
@@ -2154,16 +2162,16 @@ mod tests {
let mut app = test_app(7654);
app.insert_resource(ReplayPath(Some(path.clone())));
// Push two recorded moves manually so we can verify they survive
// the freeze/save round-trip without having to drive a real win.
// Push two recorded instructions manually so we can verify they
// survive the freeze/save round-trip without having to drive a
// real win. Both are `RotateStock` — the only instruction
// constructible without the runtime-only `klondike` pile-stack
// types (which the engine intentionally does not depend on); the
// round-trip shape is identical for any instruction variant.
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.push(ReplayMove::StockClick);
recording.moves.push(ReplayMove::Move {
from: SavedKlondikePile::Stock,
to: SavedKlondikePile::Tableau(SavedTableau(2)),
count: 1,
});
recording.moves.push(KlondikeInstruction::RotateStock);
recording.moves.push(KlondikeInstruction::RotateStock);
}
// Fire the win event the engine emits when the last foundation
@@ -2197,15 +2205,8 @@ mod tests {
"time_seconds must come from the win event"
);
assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip");
assert!(matches!(loaded.moves[0], ReplayMove::StockClick));
match &loaded.moves[1] {
ReplayMove::Move { from, to, count } => {
assert_eq!(*from, SavedKlondikePile::Stock);
assert_eq!(*to, SavedKlondikePile::Tableau(SavedTableau(2)));
assert_eq!(*count, 1);
}
other => panic!("second entry must be a Move, got {other:?}"),
}
assert!(matches!(loaded.moves[0], KlondikeInstruction::RotateStock));
assert!(matches!(loaded.moves[1], KlondikeInstruction::RotateStock));
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path);
@@ -2229,7 +2230,7 @@ mod tests {
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.clear();
recording.moves.push(ReplayMove::StockClick);
recording.moves.push(KlondikeInstruction::RotateStock);
}
app.world_mut().write_message(GameWonEvent {
score: 100,
@@ -2241,8 +2242,8 @@ mod tests {
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.clear();
recording.moves.push(ReplayMove::StockClick);
recording.moves.push(ReplayMove::StockClick);
recording.moves.push(KlondikeInstruction::RotateStock);
recording.moves.push(KlondikeInstruction::RotateStock);
}
app.world_mut().write_message(GameWonEvent {
score: 200,