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:
@@ -1,7 +1,6 @@
|
|||||||
use crate::error::MoveError;
|
use crate::error::MoveError;
|
||||||
use crate::klondike_adapter::{
|
use crate::klondike_adapter::{
|
||||||
KlondikeAdapter, SavedInstruction,
|
KlondikeAdapter, foundation_from_slot as adapter_foundation_from_slot,
|
||||||
foundation_from_slot as adapter_foundation_from_slot,
|
|
||||||
skip_cards_from_count as adapter_skip_cards_from_count,
|
skip_cards_from_count as adapter_skip_cards_from_count,
|
||||||
tableau_from_index as adapter_tableau_from_index,
|
tableau_from_index as adapter_tableau_from_index,
|
||||||
};
|
};
|
||||||
@@ -19,11 +18,10 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|||||||
/// History:
|
/// History:
|
||||||
/// - v1: `Foundation(Suit)` keys.
|
/// - v1: `Foundation(Suit)` keys.
|
||||||
/// - v2: `Foundation(u8)` slot keys; claimed suit derived from the bottom card.
|
/// - v2: `Foundation(u8)` slot keys; claimed suit derived from the bottom card.
|
||||||
/// - v3: session-backed save files using local `SavedInstruction` mirror types
|
/// - v3 (rejected): session-backed save files using local mirror types with u8
|
||||||
/// with u8 indices for enum variants.
|
/// indices for enum variants. No longer loadable — v3 files are discarded.
|
||||||
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
|
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
|
||||||
/// variants (e.g. `"Foundation1"` instead of `0`). v3 files are auto-migrated
|
/// variants (e.g. `"Foundation1"` instead of `0`).
|
||||||
/// on load via `AnyInstruction` transparent deserialization.
|
|
||||||
/// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer
|
/// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer
|
||||||
/// persisted. They are derived from the upstream `card_game`/`klondike` session
|
/// persisted. They are derived from the upstream `card_game`/`klondike` session
|
||||||
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
|
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
|
||||||
@@ -110,28 +108,13 @@ struct PersistedGameState {
|
|||||||
pub saved_moves: Vec<KlondikeInstruction>,
|
pub saved_moves: Vec<KlondikeInstruction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transparent migration wrapper for deserialisation.
|
/// Input struct that accepts schema v4 and v5 `saved_moves` formats.
|
||||||
///
|
///
|
||||||
/// Tries `KlondikeInstruction` (schema v4, named variants) first; if that
|
/// `saved_moves` is deserialised directly as upstream `KlondikeInstruction`
|
||||||
/// fails (because the value uses u8 indices), falls back to `SavedInstruction`
|
/// (named-variant serde). `score`, `undo_count`, and `recycle_count` are
|
||||||
/// (schema v3). Converting the V3 variant yields a `KlondikeInstruction` via
|
/// intentionally absent: all three are rebuilt by replaying the instruction
|
||||||
/// the existing `TryFrom` impl.
|
/// history through the upstream session stats. Older v4 save files still carry
|
||||||
///
|
/// those keys; serde ignores them.
|
||||||
/// `SavedInstruction` remains `pub` in `klondike_adapter` because
|
|
||||||
/// `solitaire_data::ReplayMove` and the WASM replay layer depend on it.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
enum AnyInstruction {
|
|
||||||
V4(KlondikeInstruction),
|
|
||||||
V3(SavedInstruction),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Input struct that accepts schema v3, v4, and v5 `saved_moves` formats.
|
|
||||||
///
|
|
||||||
/// `score`, `undo_count`, and `recycle_count` are intentionally absent: all
|
|
||||||
/// three are rebuilt by replaying the instruction history through the upstream
|
|
||||||
/// session stats. Older save files (v3/v4) still carry those keys; serde ignores
|
|
||||||
/// them.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
struct PersistedGameStateIn {
|
struct PersistedGameStateIn {
|
||||||
pub draw_mode: DrawStockConfig,
|
pub draw_mode: DrawStockConfig,
|
||||||
@@ -143,7 +126,7 @@ struct PersistedGameStateIn {
|
|||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
#[serde(default = "schema_v1")]
|
#[serde(default = "schema_v1")]
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub saved_moves: Vec<AnyInstruction>,
|
pub saved_moves: Vec<KlondikeInstruction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
@@ -249,10 +232,10 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
||||||
|
|
||||||
// Accept v3 (legacy u8-index format, auto-migrated), v4 (upstream
|
// Accept v4 (upstream named-variant serde) and v5 (current, derived
|
||||||
// named-variant serde), and v5 (current, derived stats). Reject the rest.
|
// stats). v3 (legacy u8-index format) and all others are rejected.
|
||||||
match persisted.schema_version {
|
match persisted.schema_version {
|
||||||
3..=5 => {}
|
4 | 5 => {}
|
||||||
v => {
|
v => {
|
||||||
return Err(serde::de::Error::custom(format!(
|
return Err(serde::de::Error::custom(format!(
|
||||||
"unsupported GameState schema version {v}"
|
"unsupported GameState schema version {v}"
|
||||||
@@ -276,17 +259,7 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
// to 0 across save/load because undone moves are not part of the saved
|
// to 0 across save/load because undone moves are not part of the saved
|
||||||
// forward history.
|
// forward history.
|
||||||
let replay_config = Self::replay_config(persisted.draw_mode);
|
let replay_config = Self::replay_config(persisted.draw_mode);
|
||||||
for any in persisted.saved_moves {
|
for instruction in persisted.saved_moves {
|
||||||
// AnyInstruction::V4 arrives directly from upstream serde (schema v4+).
|
|
||||||
// AnyInstruction::V3 was serialised with u8 indices (schema v3) and is
|
|
||||||
// converted here via the existing TryFrom impl.
|
|
||||||
let instruction = match any {
|
|
||||||
AnyInstruction::V4(i) => i,
|
|
||||||
AnyInstruction::V3(s) => {
|
|
||||||
KlondikeInstruction::try_from(s).map_err(serde::de::Error::custom)?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !game
|
if !game
|
||||||
.session
|
.session
|
||||||
.state()
|
.state()
|
||||||
@@ -440,20 +413,17 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the deterministic instruction history for the current deal as
|
/// Returns the deterministic instruction history for the current deal as
|
||||||
/// legacy mirror types.
|
/// upstream [`KlondikeInstruction`] values.
|
||||||
///
|
///
|
||||||
/// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this
|
/// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this
|
||||||
/// sequence is sufficient to replay the game state exactly.
|
/// sequence is sufficient to replay the game state exactly. Consumers
|
||||||
///
|
/// record these directly (they serialise via `KlondikeInstruction`'s
|
||||||
/// Returns [`SavedInstruction`] (u8-index mirror types) for backward
|
/// compact serde) and play them back via [`GameState::apply_instruction`].
|
||||||
/// compatibility with the WASM replay layer and `solitaire_data::ReplayMove`
|
pub fn instruction_history(&self) -> Vec<KlondikeInstruction> {
|
||||||
/// format. New code that does not need serde should prefer
|
|
||||||
/// `session().history()` directly.
|
|
||||||
pub fn instruction_history(&self) -> Vec<SavedInstruction> {
|
|
||||||
self.session
|
self.session
|
||||||
.history()
|
.history()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|snapshot| SavedInstruction::from(*snapshot.instruction()))
|
.map(|snapshot| *snapshot.instruction())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,9 @@
|
|||||||
//! (`card`, `pile`, etc.) remain free of upstream dependencies.
|
//! (`card`, `pile`, etc.) remain free of upstream dependencies.
|
||||||
|
|
||||||
use klondike::{
|
use klondike::{
|
||||||
DrawStockConfig, DstFoundation, DstTableau, Foundation, KlondikeConfig, KlondikeInstruction,
|
DrawStockConfig, Foundation, KlondikeConfig, MoveFromFoundationConfig, ScoringConfig,
|
||||||
KlondikePile, KlondikePileStack, MoveFromFoundationConfig, ScoringConfig, SkipCards, Tableau,
|
SkipCards, Tableau,
|
||||||
TableauStack,
|
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
|
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
|
||||||
///
|
///
|
||||||
@@ -84,266 +82,6 @@ pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Legacy serde mirror types (kept for backward compatibility) ───────────────
|
|
||||||
//
|
|
||||||
// These types were introduced when upstream `klondike` had no serde feature.
|
|
||||||
// Mainline `klondike` now provides full serde support (with a hand-written
|
|
||||||
// compact `KlondikeInstruction` impl), and `GameState` serialises
|
|
||||||
// `saved_moves` directly as `Vec<KlondikeInstruction>` (schema v4).
|
|
||||||
//
|
|
||||||
// The mirror types are retained for three reasons:
|
|
||||||
// 1. Schema v3 migration: `AnyInstruction` in `game_state.rs` uses
|
|
||||||
// `TryFrom<SavedInstruction> for KlondikeInstruction` to parse old save
|
|
||||||
// files with u8 indices and replay them.
|
|
||||||
// 2. `solitaire_data::ReplayMove` uses `SavedKlondikePile` as its serde
|
|
||||||
// type; changing it would break the on-disk replay format (schema v2).
|
|
||||||
// 3. `solitaire_wasm` mirrors `ReplayMove` using the same types so that
|
|
||||||
// replay JSON is cross-compatible between the desktop and browser builds.
|
|
||||||
//
|
|
||||||
// These types should not be used for new serialisation concerns. If the
|
|
||||||
// ReplayMove format is ever bumped to a new schema, migrate those callers to
|
|
||||||
// `KlondikePile` / `KlondikePileStack` and the types here can then be deleted.
|
|
||||||
|
|
||||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::Tableau`] (0 = Tableau1 … 6 = Tableau7).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct SavedTableau(pub u8);
|
|
||||||
|
|
||||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::Foundation`] (0 = Foundation1 … 3 = Foundation4).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct SavedFoundation(pub u8);
|
|
||||||
|
|
||||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::SkipCards`] (0 = Skip0 … 12 = Skip12).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct SavedSkipCards(pub u8);
|
|
||||||
|
|
||||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePile`].
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum SavedKlondikePile {
|
|
||||||
Tableau(SavedTableau),
|
|
||||||
Stock,
|
|
||||||
Foundation(SavedFoundation),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::TableauStack`].
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct SavedTableauStack {
|
|
||||||
pub tableau: SavedTableau,
|
|
||||||
pub skip_cards: SavedSkipCards,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikePileStack`].
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum SavedKlondikePileStack {
|
|
||||||
Tableau(SavedTableauStack),
|
|
||||||
Stock,
|
|
||||||
Foundation(SavedFoundation),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstFoundation`].
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct SavedDstFoundation {
|
|
||||||
pub src: SavedKlondikePile,
|
|
||||||
pub foundation: SavedFoundation,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::DstTableau`].
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct SavedDstTableau {
|
|
||||||
pub src: SavedKlondikePileStack,
|
|
||||||
pub tableau: SavedTableau,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `Serialize` + `Deserialize` mirror of [`klondike::KlondikeInstruction`].
|
|
||||||
///
|
|
||||||
/// Convert to/from the upstream type with:
|
|
||||||
/// ```ignore
|
|
||||||
/// let saved = SavedInstruction::from(instruction);
|
|
||||||
/// let instruction = KlondikeInstruction::try_from(saved)?;
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum SavedInstruction {
|
|
||||||
DstFoundation(SavedDstFoundation),
|
|
||||||
DstTableau(SavedDstTableau),
|
|
||||||
RotateStock,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Error returned when a [`SavedInstruction`] contains an out-of-range numeric value
|
|
||||||
/// and cannot be converted back to a [`klondike::KlondikeInstruction`].
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
||||||
pub enum InvalidSavedInstruction {
|
|
||||||
#[error("invalid tableau index {0} (expected 0–6)")]
|
|
||||||
Tableau(u8),
|
|
||||||
#[error("invalid foundation index {0} (expected 0–3)")]
|
|
||||||
Foundation(u8),
|
|
||||||
#[error("invalid skip_cards value {0} (expected 0–12)")]
|
|
||||||
SkipCards(u8),
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── From impls: KlondikeInstruction → Saved* ─────────────────────────────────
|
|
||||||
|
|
||||||
impl From<Tableau> for SavedTableau {
|
|
||||||
fn from(t: Tableau) -> Self {
|
|
||||||
Self(t as u8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Foundation> for SavedFoundation {
|
|
||||||
fn from(f: Foundation) -> Self {
|
|
||||||
Self(f as u8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<SkipCards> for SavedSkipCards {
|
|
||||||
fn from(s: SkipCards) -> Self {
|
|
||||||
Self(s as u8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<KlondikePile> for SavedKlondikePile {
|
|
||||||
fn from(p: KlondikePile) -> Self {
|
|
||||||
match p {
|
|
||||||
KlondikePile::Tableau(t) => Self::Tableau(t.into()),
|
|
||||||
KlondikePile::Stock => Self::Stock,
|
|
||||||
KlondikePile::Foundation(f) => Self::Foundation(f.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<TableauStack> for SavedTableauStack {
|
|
||||||
fn from(ts: TableauStack) -> Self {
|
|
||||||
Self {
|
|
||||||
tableau: ts.tableau.into(),
|
|
||||||
skip_cards: ts.skip_cards.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<KlondikePileStack> for SavedKlondikePileStack {
|
|
||||||
fn from(ps: KlondikePileStack) -> Self {
|
|
||||||
match ps {
|
|
||||||
KlondikePileStack::Tableau(ts) => Self::Tableau(ts.into()),
|
|
||||||
KlondikePileStack::Stock => Self::Stock,
|
|
||||||
KlondikePileStack::Foundation(f) => Self::Foundation(f.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<DstFoundation> for SavedDstFoundation {
|
|
||||||
fn from(df: DstFoundation) -> Self {
|
|
||||||
Self {
|
|
||||||
src: df.src.into(),
|
|
||||||
foundation: df.foundation.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<DstTableau> for SavedDstTableau {
|
|
||||||
fn from(dt: DstTableau) -> Self {
|
|
||||||
Self {
|
|
||||||
src: dt.src.into(),
|
|
||||||
tableau: dt.tableau.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<KlondikeInstruction> for SavedInstruction {
|
|
||||||
fn from(i: KlondikeInstruction) -> Self {
|
|
||||||
match i {
|
|
||||||
KlondikeInstruction::RotateStock => Self::RotateStock,
|
|
||||||
KlondikeInstruction::DstFoundation(df) => Self::DstFoundation(df.into()),
|
|
||||||
KlondikeInstruction::DstTableau(dt) => Self::DstTableau(dt.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── TryFrom impls: Saved* → KlondikeInstruction ──────────────────────────────
|
|
||||||
|
|
||||||
impl TryFrom<SavedTableau> for Tableau {
|
|
||||||
type Error = InvalidSavedInstruction;
|
|
||||||
fn try_from(s: SavedTableau) -> Result<Self, Self::Error> {
|
|
||||||
tableau_from_index(s.0 as usize).ok_or(InvalidSavedInstruction::Tableau(s.0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<SavedFoundation> for Foundation {
|
|
||||||
type Error = InvalidSavedInstruction;
|
|
||||||
fn try_from(s: SavedFoundation) -> Result<Self, Self::Error> {
|
|
||||||
foundation_from_slot(s.0).ok_or(InvalidSavedInstruction::Foundation(s.0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<SavedSkipCards> for SkipCards {
|
|
||||||
type Error = InvalidSavedInstruction;
|
|
||||||
fn try_from(s: SavedSkipCards) -> Result<Self, Self::Error> {
|
|
||||||
skip_cards_from_count(s.0 as usize).ok_or(InvalidSavedInstruction::SkipCards(s.0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<SavedKlondikePile> for KlondikePile {
|
|
||||||
type Error = InvalidSavedInstruction;
|
|
||||||
fn try_from(s: SavedKlondikePile) -> Result<Self, Self::Error> {
|
|
||||||
Ok(match s {
|
|
||||||
SavedKlondikePile::Tableau(t) => KlondikePile::Tableau(t.try_into()?),
|
|
||||||
SavedKlondikePile::Stock => KlondikePile::Stock,
|
|
||||||
SavedKlondikePile::Foundation(f) => KlondikePile::Foundation(f.try_into()?),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<SavedTableauStack> for TableauStack {
|
|
||||||
type Error = InvalidSavedInstruction;
|
|
||||||
fn try_from(s: SavedTableauStack) -> Result<Self, Self::Error> {
|
|
||||||
Ok(TableauStack {
|
|
||||||
tableau: s.tableau.try_into()?,
|
|
||||||
skip_cards: s.skip_cards.try_into()?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<SavedKlondikePileStack> for KlondikePileStack {
|
|
||||||
type Error = InvalidSavedInstruction;
|
|
||||||
fn try_from(s: SavedKlondikePileStack) -> Result<Self, Self::Error> {
|
|
||||||
Ok(match s {
|
|
||||||
SavedKlondikePileStack::Tableau(ts) => KlondikePileStack::Tableau(ts.try_into()?),
|
|
||||||
SavedKlondikePileStack::Stock => KlondikePileStack::Stock,
|
|
||||||
SavedKlondikePileStack::Foundation(f) => KlondikePileStack::Foundation(f.try_into()?),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<SavedDstFoundation> for DstFoundation {
|
|
||||||
type Error = InvalidSavedInstruction;
|
|
||||||
fn try_from(s: SavedDstFoundation) -> Result<Self, Self::Error> {
|
|
||||||
Ok(DstFoundation {
|
|
||||||
src: s.src.try_into()?,
|
|
||||||
foundation: s.foundation.try_into()?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<SavedDstTableau> for DstTableau {
|
|
||||||
type Error = InvalidSavedInstruction;
|
|
||||||
fn try_from(s: SavedDstTableau) -> Result<Self, Self::Error> {
|
|
||||||
Ok(DstTableau {
|
|
||||||
src: s.src.try_into()?,
|
|
||||||
tableau: s.tableau.try_into()?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<SavedInstruction> for KlondikeInstruction {
|
|
||||||
type Error = InvalidSavedInstruction;
|
|
||||||
fn try_from(s: SavedInstruction) -> Result<Self, Self::Error> {
|
|
||||||
Ok(match s {
|
|
||||||
SavedInstruction::RotateStock => KlondikeInstruction::RotateStock,
|
|
||||||
SavedInstruction::DstFoundation(df) => {
|
|
||||||
KlondikeInstruction::DstFoundation(df.try_into()?)
|
|
||||||
}
|
|
||||||
SavedInstruction::DstTableau(dt) => KlondikeInstruction::DstTableau(dt.try_into()?),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
|
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
|
||||||
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
|
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
|
||||||
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
|
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
use card_game::{Card, Game};
|
use card_game::{Card, Game};
|
||||||
use klondike::{DrawStockConfig, Foundation, KlondikePile, KlondikeInstruction, SkipCards, Tableau};
|
use klondike::{DrawStockConfig, Foundation, KlondikePile, Tableau};
|
||||||
use proptest::prelude::*;
|
use proptest::prelude::*;
|
||||||
|
|
||||||
use crate::game_state::GameState;
|
use crate::game_state::GameState;
|
||||||
use crate::klondike_adapter::{
|
|
||||||
InvalidSavedInstruction, SavedDstFoundation, SavedDstTableau, SavedFoundation,
|
|
||||||
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, SavedSkipCards, SavedTableau,
|
|
||||||
SavedTableauStack,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared helpers
|
// 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`: 0–6
|
|
||||||
/// - `SavedFoundation`: 0–3
|
|
||||||
/// - `SavedSkipCards`: 0–12
|
|
||||||
#[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)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ pub use sync_client::{SolitaireServerClient, provider_for_backend};
|
|||||||
pub mod replay;
|
pub mod replay;
|
||||||
pub use replay::{
|
pub use replay::{
|
||||||
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
|
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
|
||||||
ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from,
|
ReplayHistory, append_replay_to_history, load_replay_history_from,
|
||||||
migrate_legacy_latest_replay, replay_history_path, save_replay_history_to,
|
migrate_legacy_latest_replay, replay_history_path, save_replay_history_to,
|
||||||
};
|
};
|
||||||
// `latest_replay_path` is still consumed by the engine's one-shot legacy
|
// `latest_replay_path` is still consumed by the engine's one-shot legacy
|
||||||
|
|||||||
@@ -12,13 +12,22 @@
|
|||||||
//! carries any other version so older replays are silently dropped instead
|
//! carries any other version so older replays are silently dropped instead
|
||||||
//! of crashing the loader.
|
//! of crashing the loader.
|
||||||
//!
|
//!
|
||||||
//! The recording is intentionally minimal — only [`ReplayMove`] entries
|
//! The recording is intentionally minimal — only the
|
||||||
//! that successfully advanced the game. `Undo` is **not** recorded: a
|
//! [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) inputs that
|
||||||
//! replay represents the canonical path the player ultimately took to win,
|
//! successfully advanced the game. `Undo` is **not** recorded: a replay
|
||||||
//! so backed-out missteps simply do not appear in the move list. The
|
//! represents the canonical path the player ultimately took to win, so
|
||||||
//! starting deal is not stored either — the [`seed`](Replay::seed) +
|
//! backed-out missteps simply do not appear in the move list. The starting
|
||||||
|
//! deal is not stored either — the [`seed`](Replay::seed) +
|
||||||
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
|
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
|
||||||
//! for `GameState::new_with_mode` to rebuild the identical layout.
|
//! for `GameState::new_with_mode` to rebuild the identical layout.
|
||||||
|
//!
|
||||||
|
//! Each recorded move is the player's atomic *input*, not its outcome.
|
||||||
|
//! `KlondikeInstruction::RotateStock` covers every click on the stock pile;
|
||||||
|
//! the engine resolves draw-vs-recycle deterministically from the current
|
||||||
|
//! stock state during playback, so the same input always produces the same
|
||||||
|
//! effect on the same starting deal. Runtime-only pile-position types are
|
||||||
|
//! never serialised — the instruction itself serialises via its compact
|
||||||
|
//! upstream serde representation.
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
@@ -26,8 +35,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
||||||
use solitaire_core::klondike_adapter::SavedKlondikePile;
|
|
||||||
|
|
||||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||||
@@ -65,14 +73,17 @@ fn history_schema_v0() -> u32 {
|
|||||||
/// seeing a broken one.
|
/// seeing a broken one.
|
||||||
///
|
///
|
||||||
/// History:
|
/// History:
|
||||||
/// - v1: initial release. `ReplayMove` had separate `Draw` and `Recycle`
|
/// - v1: initial release. The move type had separate `Draw` and `Recycle`
|
||||||
/// variants which carried the *outcome* of a stock interaction rather
|
/// variants which carried the *outcome* of a stock interaction rather
|
||||||
/// than the player's atomic input.
|
/// than the player's atomic input.
|
||||||
/// - v2 (current): `Draw` + `Recycle` collapsed into a single `StockClick`
|
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
|
||||||
/// variant. The engine resolves draw-vs-recycle deterministically from
|
/// - v3 (current): the bespoke `ReplayMove` serde mirror was dropped. Moves
|
||||||
/// the current stock state, so the input alone is sufficient and the
|
/// are now stored directly as upstream
|
||||||
/// replay model now stores atomic player inputs end-to-end.
|
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) (compact
|
||||||
pub const REPLAY_SCHEMA_VERSION: u32 = 2;
|
/// int serde); `StockClick` is now `RotateStock`. Pile-position types are
|
||||||
|
/// runtime-only and are never serialised. v1/v2 files fail to deserialise
|
||||||
|
/// and are discarded by the loader.
|
||||||
|
pub const REPLAY_SCHEMA_VERSION: u32 = 3;
|
||||||
|
|
||||||
/// Default value for [`Replay::schema_version`] when deserialising files
|
/// Default value for [`Replay::schema_version`] when deserialising files
|
||||||
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
|
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
|
||||||
@@ -81,32 +92,6 @@ fn schema_v0() -> u32 {
|
|||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One atomic player input recorded during a winning game, in the order
|
|
||||||
/// it was applied to the live `GameState`.
|
|
||||||
///
|
|
||||||
/// `Undo` is intentionally absent — see the module-level docs.
|
|
||||||
///
|
|
||||||
/// The variants represent *inputs*, not outcomes. `StockClick` covers
|
|
||||||
/// every player click on the stock pile; the engine then resolves
|
|
||||||
/// draw-vs-recycle deterministically from the current state during both
|
|
||||||
/// recording and playback, so the same input always produces the same
|
|
||||||
/// effect on the same starting deal.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum ReplayMove {
|
|
||||||
/// A successful `move_cards(from, to, count)` call.
|
|
||||||
Move {
|
|
||||||
/// Source pile.
|
|
||||||
from: SavedKlondikePile,
|
|
||||||
/// Destination pile.
|
|
||||||
to: SavedKlondikePile,
|
|
||||||
/// Number of cards moved.
|
|
||||||
count: usize,
|
|
||||||
},
|
|
||||||
/// A click on the stock pile. Resolves to a draw when stock is
|
|
||||||
/// non-empty and to a waste→stock recycle when stock is empty.
|
|
||||||
StockClick,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A complete recording of a single winning game.
|
/// A complete recording of a single winning game.
|
||||||
///
|
///
|
||||||
/// Replays are reconstructed by rebuilding a fresh
|
/// Replays are reconstructed by rebuilding a fresh
|
||||||
@@ -134,9 +119,11 @@ pub struct Replay {
|
|||||||
pub final_score: i32,
|
pub final_score: i32,
|
||||||
/// ISO-8601 date the win was recorded.
|
/// ISO-8601 date the win was recorded.
|
||||||
pub recorded_at: NaiveDate,
|
pub recorded_at: NaiveDate,
|
||||||
/// Ordered move list. Each entry is what the player did, replayable
|
/// Ordered move list. Each entry is the atomic
|
||||||
/// against a fresh `GameState` constructed from the seed.
|
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) the player
|
||||||
pub moves: Vec<ReplayMove>,
|
/// issued, replayable against a fresh `GameState` constructed from the
|
||||||
|
/// seed via `GameState::apply_instruction`.
|
||||||
|
pub moves: Vec<KlondikeInstruction>,
|
||||||
/// Public share URL for this replay on the active sync backend, set
|
/// Public share URL for this replay on the active sync backend, set
|
||||||
/// by `sync_plugin::poll_replay_upload_result` when the upload
|
/// by `sync_plugin::poll_replay_upload_result` when the upload
|
||||||
/// task resolves. `None` when the player won on a local-only
|
/// task resolves. `None` when the player won on a local-only
|
||||||
@@ -185,7 +172,7 @@ impl Replay {
|
|||||||
time_seconds: u64,
|
time_seconds: u64,
|
||||||
final_score: i32,
|
final_score: i32,
|
||||||
recorded_at: NaiveDate,
|
recorded_at: NaiveDate,
|
||||||
moves: Vec<ReplayMove>,
|
moves: Vec<KlondikeInstruction>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
schema_version: REPLAY_SCHEMA_VERSION,
|
schema_version: REPLAY_SCHEMA_VERSION,
|
||||||
@@ -442,7 +429,9 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
|
|||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use solitaire_core::klondike_adapter::{SavedFoundation, SavedTableau};
|
use klondike::{
|
||||||
|
DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau,
|
||||||
|
};
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
fn tmp_path(name: &str) -> PathBuf {
|
fn tmp_path(name: &str) -> PathBuf {
|
||||||
@@ -459,18 +448,16 @@ mod tests {
|
|||||||
5_120,
|
5_120,
|
||||||
date,
|
date,
|
||||||
vec![
|
vec![
|
||||||
ReplayMove::StockClick,
|
KlondikeInstruction::RotateStock,
|
||||||
ReplayMove::Move {
|
KlondikeInstruction::DstTableau(DstTableau {
|
||||||
from: SavedKlondikePile::Stock,
|
src: KlondikePileStack::Stock,
|
||||||
to: SavedKlondikePile::Tableau(SavedTableau(3)),
|
tableau: Tableau::Tableau4,
|
||||||
count: 1,
|
}),
|
||||||
},
|
KlondikeInstruction::RotateStock,
|
||||||
ReplayMove::StockClick,
|
KlondikeInstruction::DstFoundation(DstFoundation {
|
||||||
ReplayMove::Move {
|
src: KlondikePile::Tableau(Tableau::Tableau4),
|
||||||
from: SavedKlondikePile::Tableau(SavedTableau(3)),
|
foundation: Foundation::Foundation1,
|
||||||
to: SavedKlondikePile::Foundation(SavedFoundation(0)),
|
}),
|
||||||
count: 1,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -601,7 +588,7 @@ mod tests {
|
|||||||
60,
|
60,
|
||||||
id,
|
id,
|
||||||
date,
|
date,
|
||||||
vec![ReplayMove::StockClick],
|
vec![KlondikeInstruction::RotateStock],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,9 +824,11 @@ mod tests {
|
|||||||
let path = tmp_path("legacy_no_win_move_index");
|
let path = tmp_path("legacy_no_win_move_index");
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
// Hand-rolled minimal v2 replay JSON with no win_move_index field.
|
// Hand-rolled minimal current-schema replay JSON with no
|
||||||
let v2_no_field = r#"{
|
// win_move_index field — the additive field must still default to None.
|
||||||
"schema_version": 2,
|
let no_field = format!(
|
||||||
|
r#"{{
|
||||||
|
"schema_version": {schema},
|
||||||
"seed": 1,
|
"seed": 1,
|
||||||
"draw_mode": "DrawOne",
|
"draw_mode": "DrawOne",
|
||||||
"mode": "Classic",
|
"mode": "Classic",
|
||||||
@@ -847,8 +836,10 @@ mod tests {
|
|||||||
"final_score": 100,
|
"final_score": 100,
|
||||||
"recorded_at": "2026-05-02",
|
"recorded_at": "2026-05-02",
|
||||||
"moves": []
|
"moves": []
|
||||||
}"#;
|
}}"#,
|
||||||
fs::write(&path, v2_no_field).expect("write fixture");
|
schema = REPLAY_SCHEMA_VERSION,
|
||||||
|
);
|
||||||
|
fs::write(&path, no_field).expect("write fixture");
|
||||||
|
|
||||||
let loaded = load_latest_replay_from(&path).expect("load");
|
let loaded = load_latest_replay_from(&path).expect("load");
|
||||||
assert_eq!(loaded.win_move_index, None);
|
assert_eq!(loaded.win_move_index, None);
|
||||||
|
|||||||
@@ -583,24 +583,16 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A schema v3 save (instruction history using u8 indices) must load
|
/// A schema v3 save (instruction history using the old u8-index mirror
|
||||||
/// successfully and be transparently migrated to schema v4.
|
/// types) is no longer loadable. The legacy migration path was dropped,
|
||||||
///
|
/// so any file claiming `schema_version: 3` must be rejected and the
|
||||||
/// This verifies the `AnyInstruction` untagged deserialization migration
|
/// player started on a fresh game.
|
||||||
/// path. v3 files with `RotateStock` (unit variant, format-identical in
|
|
||||||
/// v3 and v4) load correctly and report `schema_version == 4` after load.
|
|
||||||
/// The `SavedInstruction` boundary tests in `proptest_tests.rs` cover the
|
|
||||||
/// u8-to-named conversion for `DstFoundation` / `DstTableau` indices.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn game_state_v3_migrates_to_v4() {
|
fn game_state_v3_is_rejected() {
|
||||||
use solitaire_core::game_state::GameState;
|
let path = gs_path("v3_reject");
|
||||||
|
|
||||||
let path = gs_path("v3_migrate");
|
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
// Hand-crafted schema v3 JSON: one RotateStock (draw) instruction.
|
// Hand-crafted schema v3 JSON: one RotateStock (draw) instruction.
|
||||||
// RotateStock serialises as the string "RotateStock" in both v3 and v4,
|
|
||||||
// so this exercises the schema version acceptance code path.
|
|
||||||
let v3_json = r#"{
|
let v3_json = r#"{
|
||||||
"draw_mode": "DrawOne",
|
"draw_mode": "DrawOne",
|
||||||
"mode": "Classic",
|
"mode": "Classic",
|
||||||
@@ -615,13 +607,12 @@ mod tests {
|
|||||||
}"#;
|
}"#;
|
||||||
fs::write(&path, v3_json).expect("write v3 fixture");
|
fs::write(&path, v3_json).expect("write v3 fixture");
|
||||||
|
|
||||||
let loaded = load_game_state_from(&path)
|
assert!(
|
||||||
.expect("schema v3 must be accepted and migrated to v4");
|
load_game_state_from(&path).is_none(),
|
||||||
|
"schema v3 must be rejected (no migration path)",
|
||||||
|
);
|
||||||
|
|
||||||
// The loaded game should match a fresh game that had one draw applied.
|
let _ = fs::remove_file(&path);
|
||||||
let mut expected = GameState::new(42, DrawStockConfig::DrawOne);
|
|
||||||
expected.draw().expect("draw must succeed on a fresh game");
|
|
||||||
assert_eq!(loaded, expected, "migrated v3 game state must match equivalent v4 state");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Schema v2 stored raw pile arrays and undo snapshots (no instruction
|
/// Schema v2 stored raw pile arrays and undo snapshots (no instruction
|
||||||
|
|||||||
@@ -1393,8 +1393,8 @@ mod tests {
|
|||||||
|
|
||||||
use crate::replay_playback::ReplayPlaybackState;
|
use crate::replay_playback::ReplayPlaybackState;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
||||||
use solitaire_data::{Replay, ReplayMove};
|
use solitaire_data::Replay;
|
||||||
|
|
||||||
/// Headless app variant that injects a default `ReplayPlaybackState`
|
/// Headless app variant that injects a default `ReplayPlaybackState`
|
||||||
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
|
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
|
||||||
@@ -1414,7 +1414,7 @@ mod tests {
|
|||||||
10,
|
10,
|
||||||
100,
|
100,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
vec![ReplayMove::StockClick],
|
vec![KlondikeInstruction::RotateStock],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
|||||||
use bevy::window::AppLifecycle;
|
use bevy::window::AppLifecycle;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
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)]
|
#[allow(deprecated)]
|
||||||
use solitaire_data::latest_replay_path;
|
use solitaire_data::latest_replay_path;
|
||||||
use solitaire_data::{
|
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,
|
game_state_file_path, load_game_state_from, migrate_legacy_latest_replay, replay_history_path,
|
||||||
save_game_state_to,
|
save_game_state_to,
|
||||||
};
|
};
|
||||||
@@ -105,18 +105,21 @@ pub struct RestoreContinueButton;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct RestoreNewGameButton;
|
pub struct RestoreNewGameButton;
|
||||||
|
|
||||||
/// In-memory accumulator for [`ReplayMove`] entries during the current
|
/// In-memory accumulator for [`KlondikeInstruction`] entries during the
|
||||||
/// game. Cleared on every new-game start; frozen into a [`Replay`] and
|
/// current game. Cleared on every new-game start; frozen into a [`Replay`]
|
||||||
/// flushed to disk by [`record_replay_on_win`] when the player wins.
|
/// and flushed to disk by [`record_replay_on_win`] when the player wins.
|
||||||
///
|
///
|
||||||
/// Recording captures only successful state-mutating events the player
|
/// Recording captures only successful state-mutating events the player
|
||||||
/// drove (`MoveRequestEvent`, `DrawRequestEvent`). `UndoRequestEvent` is
|
/// drove (`MoveRequestEvent`, `DrawRequestEvent`). `UndoRequestEvent` is
|
||||||
/// intentionally not recorded — see [`solitaire_data::replay`] for the
|
/// 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)]
|
#[derive(Resource, Debug, Default, Clone)]
|
||||||
pub struct RecordingReplay {
|
pub struct RecordingReplay {
|
||||||
/// Ordered list of moves applied so far this game.
|
/// Ordered list of instructions applied so far this game.
|
||||||
pub moves: Vec<ReplayMove>,
|
pub moves: Vec<KlondikeInstruction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RecordingReplay {
|
impl RecordingReplay {
|
||||||
@@ -851,7 +854,7 @@ fn handle_draw(
|
|||||||
// the click happens — re-executing on the same starting
|
// the click happens — re-executing on the same starting
|
||||||
// deal produces the same effect, so the input alone is
|
// deal produces the same effect, so the input alone is
|
||||||
// sufficient to recover the move on playback.
|
// sufficient to recover the move on playback.
|
||||||
recording.moves.push(ReplayMove::StockClick);
|
recording.moves.push(KlondikeInstruction::RotateStock);
|
||||||
changed.write(StateChangedEvent);
|
changed.write(StateChangedEvent);
|
||||||
}
|
}
|
||||||
Err(e) => warn!("draw rejected: {e}"),
|
Err(e) => warn!("draw rejected: {e}"),
|
||||||
@@ -889,11 +892,17 @@ fn handle_move(
|
|||||||
// Record the move in the in-flight replay buffer. Done
|
// Record the move in the in-flight replay buffer. Done
|
||||||
// first so the entry is captured even if a subsequent
|
// first so the entry is captured even if a subsequent
|
||||||
// event-write or pile-lookup happens to bail out below.
|
// event-write or pile-lookup happens to bail out below.
|
||||||
recording.moves.push(ReplayMove::Move {
|
// `move_cards` resolved the pile coordinates to a
|
||||||
from: ev.from.into(),
|
// `KlondikeInstruction` and pushed it onto the session
|
||||||
to: ev.to.into(),
|
// history; recover that exact instruction from the tail
|
||||||
count: ev.count,
|
// (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.
|
// Fire flip event if the candidate card is now face-up.
|
||||||
if let Some(fcard) = flip_candidate
|
if let Some(fcard) = flip_candidate
|
||||||
&& pile_cards(&game.0, &ev.from)
|
&& pile_cards(&game.0, &ev.from)
|
||||||
@@ -1301,7 +1310,6 @@ fn save_game_state_on_exit(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
|
|
||||||
|
|
||||||
/// Build a minimal headless `App` with just `GamePlugin` installed.
|
/// Build a minimal headless `App` with just `GamePlugin` installed.
|
||||||
/// Disables persistence and overrides the seed so tests are deterministic
|
/// Disables persistence and overrides the seed so tests are deterministic
|
||||||
@@ -2102,7 +2110,7 @@ mod tests {
|
|||||||
1,
|
1,
|
||||||
"only the draw is recorded; the undo does not erase it nor add a new entry",
|
"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
|
/// Starting a new game wipes the recording so the next deal begins
|
||||||
@@ -2154,16 +2162,16 @@ mod tests {
|
|||||||
let mut app = test_app(7654);
|
let mut app = test_app(7654);
|
||||||
app.insert_resource(ReplayPath(Some(path.clone())));
|
app.insert_resource(ReplayPath(Some(path.clone())));
|
||||||
|
|
||||||
// Push two recorded moves manually so we can verify they survive
|
// Push two recorded instructions manually so we can verify they
|
||||||
// the freeze/save round-trip without having to drive a real win.
|
// 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>();
|
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
|
||||||
recording.moves.push(ReplayMove::StockClick);
|
recording.moves.push(KlondikeInstruction::RotateStock);
|
||||||
recording.moves.push(ReplayMove::Move {
|
recording.moves.push(KlondikeInstruction::RotateStock);
|
||||||
from: SavedKlondikePile::Stock,
|
|
||||||
to: SavedKlondikePile::Tableau(SavedTableau(2)),
|
|
||||||
count: 1,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire the win event the engine emits when the last foundation
|
// 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"
|
"time_seconds must come from the win event"
|
||||||
);
|
);
|
||||||
assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip");
|
assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip");
|
||||||
assert!(matches!(loaded.moves[0], ReplayMove::StockClick));
|
assert!(matches!(loaded.moves[0], KlondikeInstruction::RotateStock));
|
||||||
match &loaded.moves[1] {
|
assert!(matches!(loaded.moves[1], KlondikeInstruction::RotateStock));
|
||||||
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:?}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
@@ -2229,7 +2230,7 @@ mod tests {
|
|||||||
{
|
{
|
||||||
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
|
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
|
||||||
recording.moves.clear();
|
recording.moves.clear();
|
||||||
recording.moves.push(ReplayMove::StockClick);
|
recording.moves.push(KlondikeInstruction::RotateStock);
|
||||||
}
|
}
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 100,
|
score: 100,
|
||||||
@@ -2241,8 +2242,8 @@ mod tests {
|
|||||||
{
|
{
|
||||||
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
|
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
|
||||||
recording.moves.clear();
|
recording.moves.clear();
|
||||||
recording.moves.push(ReplayMove::StockClick);
|
recording.moves.push(KlondikeInstruction::RotateStock);
|
||||||
recording.moves.push(ReplayMove::StockClick);
|
recording.moves.push(KlondikeInstruction::RotateStock);
|
||||||
}
|
}
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 200,
|
score: 200,
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
use super::ReplayPlaybackState;
|
use super::ReplayPlaybackState;
|
||||||
use chrono::Datelike;
|
use chrono::Datelike;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
use solitaire_core::{Card, Rank, Suit};
|
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
use solitaire_core::klondike_adapter::SavedKlondikePile;
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
use solitaire_data::ReplayMove;
|
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||||
|
|
||||||
/// Pure helper — formats the `GAME #YYYY-DDD` caption for the given
|
/// Pure helper — formats the `GAME #YYYY-DDD` caption for the given
|
||||||
/// state. Returns `None` for `Inactive` / `Completed` (the replay is
|
/// state. Returns `None` for `Inactive` / `Completed` (the replay is
|
||||||
@@ -60,12 +58,6 @@ pub(crate) fn format_pile(p: &KlondikePile) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn format_saved_pile(p: &SavedKlondikePile) -> String {
|
|
||||||
KlondikePile::try_from(*p)
|
|
||||||
.map(|pile| format_pile(&pile))
|
|
||||||
.unwrap_or_else(|_| "unknown pile".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn foundation_number(foundation: Foundation) -> u8 {
|
fn foundation_number(foundation: Foundation) -> u8 {
|
||||||
match foundation {
|
match foundation {
|
||||||
Foundation::Foundation1 => 1,
|
Foundation::Foundation1 => 1,
|
||||||
@@ -87,20 +79,36 @@ fn tableau_number(tableau: Tableau) -> u8 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure helper — formats a [`ReplayMove`] as the body of a
|
/// Pure helper — formats a [`KlondikeInstruction`] as the body of a
|
||||||
/// move-log row. `StockClick` reads as `"stock cycle"`; `Move`
|
/// move-log row. `RotateStock` reads as `"stock cycle"`; a `Dst*`
|
||||||
/// reads as `"{from} → {to}"` using [`format_pile`] for both
|
/// instruction reads as `"{from} → {to}"` using [`format_pile`] for
|
||||||
/// endpoints. The `count` field is omitted from the row body —
|
/// each nameable endpoint. The card count is omitted from the row
|
||||||
/// at row scale it adds visual noise without meaningful
|
/// body — at row scale it adds visual noise without meaningful
|
||||||
/// information for the typical 1-card moves.
|
/// information for the typical 1-card moves.
|
||||||
pub(crate) fn format_move_body(m: &ReplayMove) -> String {
|
///
|
||||||
match m {
|
/// The destination pile is always recoverable directly from the
|
||||||
ReplayMove::StockClick => "stock cycle".to_string(),
|
/// instruction. The source pile is shown when it is statically
|
||||||
ReplayMove::Move { from, to, .. } => {
|
/// nameable (a `DstFoundation` carries a [`KlondikePile`] source);
|
||||||
|
/// a `DstTableau`'s source is the runtime-only `KlondikePileStack`
|
||||||
|
/// type — not re-exported across the `solitaire_core` boundary and so
|
||||||
|
/// not pattern-matchable here — so its row renders `"→ {to}"` without
|
||||||
|
/// a leading source label. Faithful full-coordinate decoding lives in
|
||||||
|
/// [`GameState::instruction_to_piles`] on the playback path; the
|
||||||
|
/// move-log is a display-only digest.
|
||||||
|
pub(crate) fn format_move_body(instruction: &KlondikeInstruction) -> String {
|
||||||
|
match instruction {
|
||||||
|
KlondikeInstruction::RotateStock => "stock cycle".to_string(),
|
||||||
|
KlondikeInstruction::DstFoundation(dst) => {
|
||||||
format!(
|
format!(
|
||||||
"{} \u{2192} {}",
|
"{} \u{2192} {}",
|
||||||
format_saved_pile(from),
|
format_pile(&dst.src),
|
||||||
format_saved_pile(to)
|
format_pile(&KlondikePile::Foundation(dst.foundation))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
KlondikeInstruction::DstTableau(dst) => {
|
||||||
|
format!(
|
||||||
|
"\u{2192} {}",
|
||||||
|
format_pile(&KlondikePile::Tableau(dst.tableau))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::replay_playback::{
|
|||||||
ReplayPlaybackState, step_backwards_replay_playback, step_replay_playback,
|
ReplayPlaybackState, step_backwards_replay_playback, step_replay_playback,
|
||||||
stop_replay_playback, toggle_pause_replay_playback,
|
stop_replay_playback, toggle_pause_replay_playback,
|
||||||
};
|
};
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
|
||||||
/// Per-arrow-key time-since-last-fire accumulators that drive the
|
/// Per-arrow-key time-since-last-fire accumulators that drive the
|
||||||
/// continuous-scrub repeat behaviour for held arrow keys. Each
|
/// continuous-scrub repeat behaviour for held arrow keys. Each
|
||||||
@@ -1033,6 +1034,7 @@ pub(crate) fn handle_pause_button(
|
|||||||
/// guard lives inside `step_replay_playback`.
|
/// guard lives inside `step_replay_playback`.
|
||||||
pub(crate) fn handle_step_button(
|
pub(crate) fn handle_step_button(
|
||||||
mut state: ResMut<ReplayPlaybackState>,
|
mut state: ResMut<ReplayPlaybackState>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||||||
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
||||||
buttons: Query<&Interaction, (With<ReplayStepButton>, Changed<Interaction>)>,
|
buttons: Query<&Interaction, (With<ReplayStepButton>, Changed<Interaction>)>,
|
||||||
@@ -1040,7 +1042,12 @@ pub(crate) fn handle_step_button(
|
|||||||
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer);
|
step_replay_playback(
|
||||||
|
&mut state,
|
||||||
|
game.as_deref(),
|
||||||
|
&mut moves_writer,
|
||||||
|
&mut draws_writer,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Repaints the Pause / Resume button's label whenever
|
/// Repaints the Pause / Resume button's label whenever
|
||||||
@@ -1112,6 +1119,7 @@ pub(crate) fn handle_pause_keyboard(
|
|||||||
pub(crate) fn handle_arrow_keyboard(
|
pub(crate) fn handle_arrow_keyboard(
|
||||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
mut hold: ResMut<ReplayScrubKeyHold>,
|
mut hold: ResMut<ReplayScrubKeyHold>,
|
||||||
mut state: ResMut<ReplayPlaybackState>,
|
mut state: ResMut<ReplayPlaybackState>,
|
||||||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||||||
@@ -1136,12 +1144,22 @@ pub(crate) fn handle_arrow_keyboard(
|
|||||||
// Right (forward step) — initial press fires immediately;
|
// Right (forward step) — initial press fires immediately;
|
||||||
// held repeats fire when the accumulator crosses the interval.
|
// held repeats fire when the accumulator crosses the interval.
|
||||||
if keys.just_pressed(KeyCode::ArrowRight) {
|
if keys.just_pressed(KeyCode::ArrowRight) {
|
||||||
step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer);
|
step_replay_playback(
|
||||||
|
&mut state,
|
||||||
|
game.as_deref(),
|
||||||
|
&mut moves_writer,
|
||||||
|
&mut draws_writer,
|
||||||
|
);
|
||||||
hold.right_held_secs = 0.0;
|
hold.right_held_secs = 0.0;
|
||||||
} else if keys.pressed(KeyCode::ArrowRight) {
|
} else if keys.pressed(KeyCode::ArrowRight) {
|
||||||
hold.right_held_secs += dt;
|
hold.right_held_secs += dt;
|
||||||
if hold.right_held_secs >= SCRUB_REPEAT_INTERVAL_SECS {
|
if hold.right_held_secs >= SCRUB_REPEAT_INTERVAL_SECS {
|
||||||
step_replay_playback(&mut state, &mut moves_writer, &mut draws_writer);
|
step_replay_playback(
|
||||||
|
&mut state,
|
||||||
|
game.as_deref(),
|
||||||
|
&mut moves_writer,
|
||||||
|
&mut draws_writer,
|
||||||
|
);
|
||||||
hold.right_held_secs = 0.0;
|
hold.right_held_secs = 0.0;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
use solitaire_core::{Rank, Suit};
|
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
|
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||||
use solitaire_data::{Replay, ReplayMove};
|
use solitaire_core::{Rank, Suit};
|
||||||
|
use solitaire_data::Replay;
|
||||||
|
|
||||||
/// Build a minimal but well-formed [`Replay`] with `move_count` no-op
|
/// Build a minimal but well-formed [`Replay`] with `move_count` no-op
|
||||||
/// `StockClick` entries. Tests only ever read `replay.moves.len()`
|
/// `RotateStock` entries. Tests only ever read `replay.moves.len()`
|
||||||
/// (denominator of the progress indicator), so the move kind is
|
/// (denominator of the progress indicator), so the move kind is
|
||||||
/// irrelevant beyond producing the right count.
|
/// irrelevant beyond producing the right count.
|
||||||
fn synthetic_replay(move_count: usize) -> Replay {
|
fn synthetic_replay(move_count: usize) -> Replay {
|
||||||
@@ -18,7 +17,9 @@ fn synthetic_replay(move_count: usize) -> Replay {
|
|||||||
120,
|
120,
|
||||||
1_000,
|
1_000,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
|
||||||
(0..move_count).map(|_| ReplayMove::StockClick).collect(),
|
(0..move_count)
|
||||||
|
.map(|_| KlondikeInstruction::RotateStock)
|
||||||
|
.collect(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1123,20 +1124,17 @@ fn format_pile_uses_one_indexed_lowercase_names() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move-body formatter renders `StockClick` as a label and
|
/// Move-body formatter renders `RotateStock` as a label. The
|
||||||
/// `Move` as a `from → to` arrow. The `count` field is
|
/// `Dst*` variants render as a `→ to` arrow, but their pile-stack
|
||||||
/// deliberately omitted — at row scale it adds noise.
|
/// source types are runtime-only and not constructible from this
|
||||||
|
/// crate, so only the stock-cycle label is asserted here; the
|
||||||
|
/// arrow path is exercised end-to-end through the move-log
|
||||||
|
/// integration tests.
|
||||||
#[test]
|
#[test]
|
||||||
fn format_move_body_handles_both_variants() {
|
fn format_move_body_handles_stock_cycle() {
|
||||||
assert_eq!(format_move_body(&ReplayMove::StockClick), "stock cycle");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_move_body(&ReplayMove::Move {
|
format_move_body(&KlondikeInstruction::RotateStock),
|
||||||
from: SavedKlondikePile::Stock,
|
"stock cycle"
|
||||||
to: SavedKlondikePile::Tableau(SavedTableau(4)),
|
|
||||||
count: 1,
|
|
||||||
}),
|
|
||||||
"waste \u{2192} tableau 5",
|
|
||||||
"Move variant must render as `{{from}} → {{to}}` with 1-indexed pile numbers",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ use super::*;
|
|||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
use crate::replay_playback::ReplayPlaybackState;
|
use crate::replay_playback::ReplayPlaybackState;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::{KlondikeInstruction, KlondikePile};
|
||||||
use solitaire_data::ReplayMove;
|
|
||||||
|
|
||||||
/// Overwrites the banner label whenever the resource changes — covers the
|
/// Overwrites the banner label whenever the resource changes — covers the
|
||||||
/// `Playing → Completed` transition by swapping "▌ replay" for
|
/// `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`.
|
// the most-recently-applied move sits at `cursor - 1`.
|
||||||
let dest_pile = match state.as_ref() {
|
let dest_pile = match state.as_ref() {
|
||||||
ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
|
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] {
|
match &replay.moves[cursor - 1] {
|
||||||
ReplayMove::Move { to, .. } => Some(*to),
|
KlondikeInstruction::DstFoundation(dst) => {
|
||||||
ReplayMove::StockClick => None,
|
Some(KlondikePile::Foundation(dst.foundation))
|
||||||
|
}
|
||||||
|
KlondikeInstruction::DstTableau(dst) => Some(KlondikePile::Tableau(dst.tableau)),
|
||||||
|
KlondikeInstruction::RotateStock => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -95,8 +100,7 @@ pub(crate) fn update_floating_progress_chip(
|
|||||||
|
|
||||||
let Some(world_pos) = dest_pile
|
let Some(world_pos) = dest_pile
|
||||||
.as_ref()
|
.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 {
|
else {
|
||||||
// Nothing to point at — hide every chip and exit.
|
// Nothing to point at — hide every chip and exit.
|
||||||
for (_, mut visibility, _) in chips.iter_mut() {
|
for (_, mut visibility, _) in chips.iter_mut() {
|
||||||
|
|||||||
@@ -40,8 +40,8 @@
|
|||||||
//! flag is threaded through, no every-callsite gate is added.
|
//! flag is threaded through, no every-callsite gate is added.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikeInstruction;
|
||||||
use solitaire_data::{Replay, ReplayMove};
|
use solitaire_data::Replay;
|
||||||
|
|
||||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
|
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
|
||||||
use crate::game_plugin::{GameMutation, RecordingReplay};
|
use crate::game_plugin::{GameMutation, RecordingReplay};
|
||||||
@@ -94,7 +94,7 @@ pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0;
|
|||||||
/// replay's recorded deal.
|
/// replay's recorded deal.
|
||||||
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
|
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
|
||||||
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
|
/// 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
|
/// 4. When `cursor == replay.moves.len()`, the state transitions to
|
||||||
/// [`Completed`](Self::Completed). It lingers for
|
/// [`Completed`](Self::Completed). It lingers for
|
||||||
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
|
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
|
||||||
@@ -251,6 +251,7 @@ pub fn toggle_pause_replay_playback(state: &mut ResMut<ReplayPlaybackState>) ->
|
|||||||
/// normal advance loop takes.
|
/// normal advance loop takes.
|
||||||
pub fn step_replay_playback(
|
pub fn step_replay_playback(
|
||||||
state: &mut ResMut<ReplayPlaybackState>,
|
state: &mut ResMut<ReplayPlaybackState>,
|
||||||
|
game: Option<&GameStateResource>,
|
||||||
moves_writer: &mut MessageWriter<MoveRequestEvent>,
|
moves_writer: &mut MessageWriter<MoveRequestEvent>,
|
||||||
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
@@ -266,31 +267,49 @@ pub fn step_replay_playback(
|
|||||||
if *cursor >= replay.moves.len() {
|
if *cursor >= replay.moves.len() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
match &replay.moves[*cursor] {
|
let instruction = replay.moves[*cursor];
|
||||||
ReplayMove::Move { from, to, count } => {
|
dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*cursor += 1;
|
*cursor += 1;
|
||||||
true
|
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.
|
/// Steps the replay **backwards** by exactly one move while paused.
|
||||||
///
|
///
|
||||||
/// Strategy: the live game's undo system is the source of truth for
|
/// 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(
|
fn tick_replay_playback(
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
mut state: ResMut<ReplayPlaybackState>,
|
mut state: ResMut<ReplayPlaybackState>,
|
||||||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||||||
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
||||||
@@ -378,27 +398,14 @@ fn tick_replay_playback(
|
|||||||
if !*paused {
|
if !*paused {
|
||||||
*secs_to_next -= dt;
|
*secs_to_next -= dt;
|
||||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
||||||
match &replay.moves[*cursor] {
|
let instruction = replay.moves[*cursor];
|
||||||
ReplayMove::Move { from, to, count } => {
|
dispatch_instruction(
|
||||||
if let (Ok(from), Ok(to)) =
|
instruction,
|
||||||
(KlondikePile::try_from(*from), KlondikePile::try_from(*to))
|
*cursor,
|
||||||
{
|
game.as_deref(),
|
||||||
moves_writer.write(MoveRequestEvent {
|
&mut moves_writer,
|
||||||
from,
|
&mut draws_writer,
|
||||||
to,
|
|
||||||
count: *count,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
warn!(
|
|
||||||
"skipping replay move with invalid pile encoding at cursor {}",
|
|
||||||
*cursor
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
ReplayMove::StockClick => {
|
|
||||||
draws_writer.write(DrawRequestEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*cursor += 1;
|
*cursor += 1;
|
||||||
*secs_to_next += interval;
|
*secs_to_next += interval;
|
||||||
}
|
}
|
||||||
@@ -555,9 +562,8 @@ mod tests {
|
|||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use bevy::time::TimeUpdateStrategy;
|
use bevy::time::TimeUpdateStrategy;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{KlondikePile, Tableau};
|
use solitaire_core::KlondikeInstruction;
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
use solitaire_core::klondike_adapter::{SavedKlondikePile, SavedTableau};
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
||||||
@@ -592,9 +598,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A 3-move replay covering both `Move` and `StockClick` variants.
|
/// A 3-move replay of `RotateStock` inputs. Pile-position types are
|
||||||
/// Seed 12345 is arbitrary — the test asserts on event counts and
|
/// runtime-only and intentionally not constructible from the engine
|
||||||
/// move shapes, not on board positions.
|
/// 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 {
|
fn sample_replay_three_moves() -> Replay {
|
||||||
Replay::new(
|
Replay::new(
|
||||||
12345,
|
12345,
|
||||||
@@ -604,13 +613,9 @@ mod tests {
|
|||||||
500,
|
500,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
vec![
|
vec![
|
||||||
ReplayMove::StockClick,
|
KlondikeInstruction::RotateStock,
|
||||||
ReplayMove::Move {
|
KlondikeInstruction::RotateStock,
|
||||||
from: SavedKlondikePile::Stock,
|
KlondikeInstruction::RotateStock,
|
||||||
to: SavedKlondikePile::Tableau(SavedTableau(3)),
|
|
||||||
count: 1,
|
|
||||||
},
|
|
||||||
ReplayMove::StockClick,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -748,20 +753,17 @@ mod tests {
|
|||||||
let captured_moves = app.world().resource::<CapturedMoves>();
|
let captured_moves = app.world().resource::<CapturedMoves>();
|
||||||
let captured_draws = app.world().resource::<CapturedDraws>();
|
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!(
|
assert_eq!(
|
||||||
captured_draws.0, 2,
|
captured_draws.0, 3,
|
||||||
"expected 2 DrawRequestEvent (two StockClicks)",
|
"expected 3 DrawRequestEvent (one per RotateStock)",
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
captured_moves.0.len(),
|
captured_moves.0.len(),
|
||||||
1,
|
0,
|
||||||
"expected 1 MoveRequestEvent (the single Move variant)",
|
"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
|
/// Driving past one interval on a single-move replay must
|
||||||
@@ -776,7 +778,7 @@ mod tests {
|
|||||||
10,
|
10,
|
||||||
100,
|
100,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
vec![ReplayMove::StockClick],
|
vec![KlondikeInstruction::RotateStock],
|
||||||
);
|
);
|
||||||
start_playback(&mut app, one_move);
|
start_playback(&mut app, one_move);
|
||||||
app.update();
|
app.update();
|
||||||
@@ -822,7 +824,7 @@ mod tests {
|
|||||||
// Replay — their in-flight recording must not get clobbered.
|
// Replay — their in-flight recording must not get clobbered.
|
||||||
{
|
{
|
||||||
let mut rec = app.world_mut().resource_mut::<RecordingReplay>();
|
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());
|
start_playback(&mut app, sample_replay_three_moves());
|
||||||
app.update();
|
app.update();
|
||||||
@@ -885,7 +887,7 @@ mod tests {
|
|||||||
10,
|
10,
|
||||||
100,
|
100,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
vec![ReplayMove::StockClick; 10],
|
vec![KlondikeInstruction::RotateStock; 10],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-138
@@ -3,8 +3,8 @@
|
|||||||
//! The web replay player at `<server>/replays/<id>` fetches a [`Replay`]
|
//! The web replay player at `<server>/replays/<id>` fetches a [`Replay`]
|
||||||
//! JSON via `GET /api/replays/:id`, hands it to [`ReplayPlayer::new`],
|
//! JSON via `GET /api/replays/:id`, hands it to [`ReplayPlayer::new`],
|
||||||
//! and then advances frame-by-frame with [`ReplayPlayer::step`]. Each
|
//! and then advances frame-by-frame with [`ReplayPlayer::step`]. Each
|
||||||
//! step applies one [`ReplayMove`] to the underlying `GameState` and
|
//! step applies one [`KlondikeInstruction`] to the underlying `GameState`
|
||||||
//! returns the resulting pile snapshot as JSON for the JS layer to
|
//! and returns the resulting pile snapshot as JSON for the JS layer to
|
||||||
//! render.
|
//! render.
|
||||||
//!
|
//!
|
||||||
//! The state machine is the same Rust [`solitaire_core::GameState`]
|
//! The state machine is the same Rust [`solitaire_core::GameState`]
|
||||||
@@ -19,30 +19,20 @@
|
|||||||
//! is the contract.
|
//! is the contract.
|
||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
use solitaire_core::error::MoveError;
|
use solitaire_core::error::MoveError;
|
||||||
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
||||||
use solitaire_core::klondike_adapter::{
|
|
||||||
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, tableau_from_index,
|
|
||||||
};
|
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
/// Mirrors the variants of `solitaire_data::ReplayMove` v2 (atomic
|
/// Mirrors `solitaire_data::Replay` v3.
|
||||||
/// player inputs, post-StockClick refinement). Only the JSON shape
|
///
|
||||||
/// matters for cross-crate compatibility.
|
/// `moves` is a list of upstream [`KlondikeInstruction`]s — the same
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
/// move-currency `solitaire_core` persists. A stock click is
|
||||||
pub enum ReplayMove {
|
/// `KlondikeInstruction::RotateStock`; a card move is a
|
||||||
Move {
|
/// `DstFoundation` / `DstTableau` instruction. Pile-position types are
|
||||||
from: SavedKlondikePile,
|
/// runtime-only and intentionally not part of the wire format.
|
||||||
to: SavedKlondikePile,
|
|
||||||
count: usize,
|
|
||||||
},
|
|
||||||
StockClick,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mirrors `solitaire_data::Replay` v2.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Replay {
|
pub struct Replay {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -53,7 +43,7 @@ pub struct Replay {
|
|||||||
pub time_seconds: u64,
|
pub time_seconds: u64,
|
||||||
pub final_score: i32,
|
pub final_score: i32,
|
||||||
pub recorded_at: NaiveDate,
|
pub recorded_at: NaiveDate,
|
||||||
pub moves: Vec<ReplayMove>,
|
pub moves: Vec<KlondikeInstruction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// JS-friendly snapshot of a `GameState` at a particular replay step.
|
/// JS-friendly snapshot of a `GameState` at a particular replay step.
|
||||||
@@ -110,7 +100,7 @@ impl From<&(Card, bool)> for CardSnapshot {
|
|||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub struct ReplayPlayer {
|
pub struct ReplayPlayer {
|
||||||
game: GameState,
|
game: GameState,
|
||||||
moves: Vec<ReplayMove>,
|
moves: Vec<KlondikeInstruction>,
|
||||||
step_idx: usize,
|
step_idx: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,17 +135,8 @@ impl ReplayPlayer {
|
|||||||
if self.step_idx >= self.moves.len() {
|
if self.step_idx >= self.moves.len() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let mv = self.moves[self.step_idx].clone();
|
let instruction = self.moves[self.step_idx];
|
||||||
match mv {
|
self.game.apply_instruction(instruction)?;
|
||||||
ReplayMove::Move { from, to, count } => self.game.move_cards(
|
|
||||||
from.try_into()
|
|
||||||
.map_err(|_| MoveError::RuleViolation("invalid replay pile".into()))?,
|
|
||||||
to.try_into()
|
|
||||||
.map_err(|_| MoveError::RuleViolation("invalid replay pile".into()))?,
|
|
||||||
count,
|
|
||||||
)?,
|
|
||||||
ReplayMove::StockClick => self.game.draw()?,
|
|
||||||
}
|
|
||||||
self.step_idx += 1;
|
self.step_idx += 1;
|
||||||
Ok(Some(self.snapshot()))
|
Ok(Some(self.snapshot()))
|
||||||
}
|
}
|
||||||
@@ -335,7 +316,7 @@ pub struct DebugSnapshot {
|
|||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
pub state: GameSnapshot,
|
pub state: GameSnapshot,
|
||||||
pub legal_moves: Vec<DebugMove>,
|
pub legal_moves: Vec<DebugMove>,
|
||||||
pub move_history: Vec<SavedInstruction>,
|
pub move_history: Vec<KlondikeInstruction>,
|
||||||
pub invariants: DebugInvariantReport,
|
pub invariants: DebugInvariantReport,
|
||||||
pub state_json: String,
|
pub state_json: String,
|
||||||
}
|
}
|
||||||
@@ -569,90 +550,15 @@ impl SolitaireGame {
|
|||||||
legal_moves_for_game(&self.game)
|
legal_moves_for_game(&self.game)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn move_history_native(&self) -> Vec<SavedInstruction> {
|
fn move_history_native(&self) -> Vec<KlondikeInstruction> {
|
||||||
self.game.instruction_history()
|
self.game.instruction_history()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replay_moves_native(&self) -> Result<Vec<ReplayMove>, String> {
|
fn replay_moves_native(&self) -> Vec<KlondikeInstruction> {
|
||||||
let mut replay_game =
|
// The session's forward instruction history *is* the replayable
|
||||||
GameState::new_with_mode(self.game.seed, self.game.draw_mode(), self.game.mode);
|
// move list: each entry replays cleanly via `apply_instruction`
|
||||||
let mut replay_moves = Vec::new();
|
// against a fresh game with the same seed/draw mode/mode.
|
||||||
|
self.game.instruction_history()
|
||||||
for instruction in self.game.instruction_history() {
|
|
||||||
let replay_move = match instruction {
|
|
||||||
SavedInstruction::RotateStock => ReplayMove::StockClick,
|
|
||||||
SavedInstruction::DstFoundation(dst) => ReplayMove::Move {
|
|
||||||
from: dst.src,
|
|
||||||
to: SavedKlondikePile::Foundation(dst.foundation),
|
|
||||||
count: 1,
|
|
||||||
},
|
|
||||||
SavedInstruction::DstTableau(dst) => {
|
|
||||||
let (from, count) = match dst.src {
|
|
||||||
SavedKlondikePileStack::Stock => (SavedKlondikePile::Stock, 1),
|
|
||||||
SavedKlondikePileStack::Foundation(foundation) => {
|
|
||||||
(SavedKlondikePile::Foundation(foundation), 1)
|
|
||||||
}
|
|
||||||
SavedKlondikePileStack::Tableau(tableau_stack) => {
|
|
||||||
let tableau =
|
|
||||||
tableau_from_index(tableau_stack.tableau.0 as usize).ok_or_else(
|
|
||||||
|| {
|
|
||||||
format!(
|
|
||||||
"invalid tableau index in move history: {}",
|
|
||||||
tableau_stack.tableau.0
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
let face_up_count = replay_game
|
|
||||||
.pile(KlondikePile::Tableau(tableau))
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.take_while(|(_, face_up)| *face_up)
|
|
||||||
.count();
|
|
||||||
let skip = tableau_stack.skip_cards.0 as usize;
|
|
||||||
let count = face_up_count.checked_sub(skip).ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"invalid tableau skip in move history: face_up={face_up_count}, skip={skip}"
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if count == 0 {
|
|
||||||
return Err(
|
|
||||||
"invalid tableau move in move history: zero-card move".into()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
(SavedKlondikePile::Tableau(tableau_stack.tableau), count)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ReplayMove::Move {
|
|
||||||
from,
|
|
||||||
to: SavedKlondikePile::Tableau(dst.tableau),
|
|
||||||
count,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match &replay_move {
|
|
||||||
ReplayMove::StockClick => replay_game
|
|
||||||
.draw()
|
|
||||||
.map_err(|e| format!("failed to apply stock click while exporting replay: {e}"))?,
|
|
||||||
ReplayMove::Move { from, to, count } => {
|
|
||||||
let src: KlondikePile = (*from)
|
|
||||||
.try_into()
|
|
||||||
.map_err(|e| format!("invalid replay source pile: {e}"))?;
|
|
||||||
let dst: KlondikePile = (*to)
|
|
||||||
.try_into()
|
|
||||||
.map_err(|e| format!("invalid replay destination pile: {e}"))?;
|
|
||||||
replay_game.move_cards(src, dst, *count).map_err(|e| {
|
|
||||||
format!(
|
|
||||||
"failed to apply move while exporting replay ({from:?} -> {to:?}, count={count}): {e}"
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
replay_moves.push(replay_move);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(replay_moves)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn debug_snapshot_native(&self) -> DebugSnapshot {
|
fn debug_snapshot_native(&self) -> DebugSnapshot {
|
||||||
@@ -829,15 +735,13 @@ impl SolitaireGame {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns replay moves encoded in the `solitaire_data::Replay` wire format.
|
/// Returns replay moves encoded in the `solitaire_data::Replay` wire format
|
||||||
|
/// — a list of upstream [`KlondikeInstruction`]s.
|
||||||
///
|
///
|
||||||
/// This derives move counts from the deterministic instruction history and
|
/// This is the deterministic instruction history; together with `seed()`
|
||||||
/// validates that the resulting move stream replays cleanly from the current
|
/// and the draw mode it replays cleanly via `apply_instruction`.
|
||||||
/// game's seed/draw mode.
|
|
||||||
pub fn replay_moves(&self) -> Result<JsValue, JsValue> {
|
pub fn replay_moves(&self) -> Result<JsValue, JsValue> {
|
||||||
let moves = self
|
let moves = self.replay_moves_native();
|
||||||
.replay_moves_native()
|
|
||||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
||||||
serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string()))
|
serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1036,10 +940,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let exported_moves = match game.replay_moves_native() {
|
let exported_moves = game.replay_moves_native();
|
||||||
Ok(moves) => moves,
|
|
||||||
Err(err) => panic!("replay export failed: {err}"),
|
|
||||||
};
|
|
||||||
assert!(
|
assert!(
|
||||||
!exported_moves.is_empty(),
|
!exported_moves.is_empty(),
|
||||||
"progressed game must export a non-empty replay move list"
|
"progressed game must export a non-empty replay move list"
|
||||||
@@ -1049,24 +950,20 @@ mod tests {
|
|||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(err) => panic!("failed to serialise exported replay moves: {err}"),
|
Err(err) => panic!("failed to serialise exported replay moves: {err}"),
|
||||||
};
|
};
|
||||||
let array = match moves_json.as_array() {
|
|
||||||
Some(values) => values,
|
|
||||||
None => panic!("exported replay moves must serialise as a JSON array"),
|
|
||||||
};
|
|
||||||
assert!(
|
assert!(
|
||||||
array.iter().all(|entry| {
|
moves_json.is_array(),
|
||||||
entry.as_str() == Some("StockClick") || entry.get("Move").is_some()
|
"exported replay moves must serialise as a JSON array"
|
||||||
}),
|
|
||||||
"replay move JSON must match ReplayMove wire shape"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let parsed_back: Vec<ReplayMove> = match serde_json::from_value(moves_json) {
|
let parsed_back: Vec<KlondikeInstruction> = match serde_json::from_value(moves_json) {
|
||||||
Ok(parsed) => parsed,
|
Ok(parsed) => parsed,
|
||||||
Err(err) => panic!("failed to parse replay move JSON as ReplayMove list: {err}"),
|
Err(err) => {
|
||||||
|
panic!("failed to parse replay move JSON as KlondikeInstruction list: {err}")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed_back, exported_moves,
|
parsed_back, exported_moves,
|
||||||
"replay move JSON must round-trip through ReplayMove"
|
"replay move JSON must round-trip through KlondikeInstruction"
|
||||||
);
|
);
|
||||||
|
|
||||||
let recorded_at = match NaiveDate::from_ymd_opt(2026, 6, 1) {
|
let recorded_at = match NaiveDate::from_ymd_opt(2026, 6, 1) {
|
||||||
@@ -1074,7 +971,7 @@ mod tests {
|
|||||||
None => panic!("invalid recorded_at date in test"),
|
None => panic!("invalid recorded_at date in test"),
|
||||||
};
|
};
|
||||||
let replay = Replay {
|
let replay = Replay {
|
||||||
schema_version: 2,
|
schema_version: 3,
|
||||||
seed,
|
seed,
|
||||||
draw_mode,
|
draw_mode,
|
||||||
mode: GameMode::Classic,
|
mode: GameMode::Classic,
|
||||||
|
|||||||
Reference in New Issue
Block a user