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:
@@ -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],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user