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
+71 -69
View File
@@ -40,8 +40,8 @@
//! flag is threaded through, no every-callsite gate is added.
use bevy::prelude::*;
use solitaire_core::KlondikePile;
use solitaire_data::{Replay, ReplayMove};
use solitaire_core::KlondikeInstruction;
use solitaire_data::Replay;
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
use crate::game_plugin::{GameMutation, RecordingReplay};
@@ -94,7 +94,7 @@ pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0;
/// replay's recorded deal.
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
/// for each [`ReplayMove`].
/// for each [`KlondikeInstruction`].
/// 4. When `cursor == replay.moves.len()`, the state transitions to
/// [`Completed`](Self::Completed). It lingers for
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
@@ -251,6 +251,7 @@ pub fn toggle_pause_replay_playback(state: &mut ResMut<ReplayPlaybackState>) ->
/// normal advance loop takes.
pub fn step_replay_playback(
state: &mut ResMut<ReplayPlaybackState>,
game: Option<&GameStateResource>,
moves_writer: &mut MessageWriter<MoveRequestEvent>,
draws_writer: &mut MessageWriter<DrawRequestEvent>,
) -> bool {
@@ -266,31 +267,49 @@ pub fn step_replay_playback(
if *cursor >= replay.moves.len() {
return false;
}
match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => {
let (Ok(from), Ok(to)) = (KlondikePile::try_from(*from), KlondikePile::try_from(*to))
else {
warn!(
"skipping replay move with invalid pile encoding at cursor {}",
*cursor
);
*cursor += 1;
return false;
};
moves_writer.write(MoveRequestEvent {
from,
to,
count: *count,
});
}
ReplayMove::StockClick => {
draws_writer.write(DrawRequestEvent);
}
}
let instruction = replay.moves[*cursor];
dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer);
*cursor += 1;
true
}
/// Translates one recorded [`KlondikeInstruction`] into the canonical
/// engine event that drives the live animation pipeline.
///
/// `RotateStock` fires a [`DrawRequestEvent`]; a `Dst*` instruction is
/// decoded back to its runtime `(from, to, count)` pile coordinates via
/// [`GameState::instruction_to_piles`] against the *current* live state
/// (decoded before the event mutates it, so the source pile's face-up
/// run length is the one in effect when the move applies) and fires a
/// [`MoveRequestEvent`]. A decode that returns `None` (e.g. a malformed
/// instruction loaded from disk) is skipped with a warning rather than
/// panicking — the cursor still advances so playback never stalls.
///
/// `game` is `None` only in headless fixtures that install no
/// [`GameStateResource`]; in that case only `RotateStock` (which needs
/// no live state) is dispatched and `Dst*` instructions are skipped.
fn dispatch_instruction(
instruction: KlondikeInstruction,
cursor: usize,
game: Option<&GameStateResource>,
moves_writer: &mut MessageWriter<MoveRequestEvent>,
draws_writer: &mut MessageWriter<DrawRequestEvent>,
) {
match instruction {
KlondikeInstruction::RotateStock => {
draws_writer.write(DrawRequestEvent);
}
_ => match game.and_then(|g| g.0.instruction_to_piles(instruction)) {
Some((from, to, count)) => {
moves_writer.write(MoveRequestEvent { from, to, count });
}
None => {
warn!("skipping replay move that did not decode to piles at cursor {cursor}");
}
},
}
}
/// Steps the replay **backwards** by exactly one move while paused.
///
/// Strategy: the live game's undo system is the source of truth for
@@ -355,6 +374,7 @@ pub fn step_backwards_replay_playback(
fn tick_replay_playback(
time: Res<Time>,
settings: Option<Res<SettingsResource>>,
game: Option<Res<GameStateResource>>,
mut state: ResMut<ReplayPlaybackState>,
mut moves_writer: MessageWriter<MoveRequestEvent>,
mut draws_writer: MessageWriter<DrawRequestEvent>,
@@ -378,27 +398,14 @@ fn tick_replay_playback(
if !*paused {
*secs_to_next -= dt;
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => {
if let (Ok(from), Ok(to)) =
(KlondikePile::try_from(*from), KlondikePile::try_from(*to))
{
moves_writer.write(MoveRequestEvent {
from,
to,
count: *count,
});
} else {
warn!(
"skipping replay move with invalid pile encoding at cursor {}",
*cursor
);
}
}
ReplayMove::StockClick => {
draws_writer.write(DrawRequestEvent);
}
}
let instruction = replay.moves[*cursor];
dispatch_instruction(
instruction,
*cursor,
game.as_deref(),
&mut moves_writer,
&mut draws_writer,
);
*cursor += 1;
*secs_to_next += interval;
}
@@ -555,9 +562,8 @@ mod tests {
use crate::game_plugin::GamePlugin;
use bevy::time::TimeUpdateStrategy;
use chrono::NaiveDate;
use solitaire_core::{KlondikePile, Tableau};
use solitaire_core::KlondikeInstruction;
use solitaire_core::{DrawStockConfig, game_state::GameMode};
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
use std::time::Duration;
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
@@ -592,9 +598,12 @@ mod tests {
}
}
/// A 3-move replay covering both `Move` and `StockClick` variants.
/// Seed 12345 is arbitrary — the test asserts on event counts and
/// move shapes, not on board positions.
/// A 3-move replay of `RotateStock` inputs. Pile-position types are
/// runtime-only and intentionally not constructible from the engine
/// crate, so a `Dst*` fixture can't be hand-built here; `RotateStock`
/// exercises the dispatch path (it fires a `DrawRequestEvent` without
/// needing a live state to decode piles). Seed 12345 is arbitrary —
/// the test asserts on event counts, not board positions.
fn sample_replay_three_moves() -> Replay {
Replay::new(
12345,
@@ -604,13 +613,9 @@ mod tests {
500,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![
ReplayMove::StockClick,
ReplayMove::Move {
from: SavedKlondikePile::Stock,
to: SavedKlondikePile::Tableau(SavedTableau(3)),
count: 1,
},
ReplayMove::StockClick,
KlondikeInstruction::RotateStock,
KlondikeInstruction::RotateStock,
KlondikeInstruction::RotateStock,
],
)
}
@@ -748,20 +753,17 @@ mod tests {
let captured_moves = app.world().resource::<CapturedMoves>();
let captured_draws = app.world().resource::<CapturedDraws>();
// Sample replay: StockClick, Move { Waste -> Tableau(3), 1 }, StockClick.
// Sample replay: three `RotateStock` inputs — each dispatches a
// `DrawRequestEvent` and never a `MoveRequestEvent`.
assert_eq!(
captured_draws.0, 2,
"expected 2 DrawRequestEvent (two StockClicks)",
captured_draws.0, 3,
"expected 3 DrawRequestEvent (one per RotateStock)",
);
assert_eq!(
captured_moves.0.len(),
1,
"expected 1 MoveRequestEvent (the single Move variant)",
0,
"RotateStock inputs must not produce MoveRequestEvent",
);
let m = &captured_moves.0[0];
assert!(matches!(m.from, KlondikePile::Stock));
assert!(matches!(m.to, KlondikePile::Tableau(Tableau::Tableau4)));
assert_eq!(m.count, 1);
}
/// Driving past one interval on a single-move replay must
@@ -776,7 +778,7 @@ mod tests {
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick],
vec![KlondikeInstruction::RotateStock],
);
start_playback(&mut app, one_move);
app.update();
@@ -822,7 +824,7 @@ mod tests {
// Replay — their in-flight recording must not get clobbered.
{
let mut rec = app.world_mut().resource_mut::<RecordingReplay>();
rec.moves.push(ReplayMove::StockClick);
rec.moves.push(KlondikeInstruction::RotateStock);
}
start_playback(&mut app, sample_replay_three_moves());
app.update();
@@ -885,7 +887,7 @@ mod tests {
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick; 10],
vec![KlondikeInstruction::RotateStock; 10],
)
}