feat(data): replay storage layer with atomic StockClick input
New `solitaire_data::replay` module:
- `Replay` struct: seed + draw_mode + mode + ordered move list +
presentation metadata (time / score / date). Replays are
reconstructed by rebuilding `GameState::new_with_mode` and applying
the move list in order — a deterministic state machine driven by
atomic player inputs, no per-step snapshots stored.
- `ReplayMove`: one variant per atomic player input. `Move {from, to,
count}` covers card moves; `StockClick` covers every click on the
stock (the engine resolves draw-vs-recycle deterministically from
current state during both record and playback).
- Schema-versioned (`REPLAY_SCHEMA_VERSION = 2`); legacy files are
rejected via the version gate so older replays just disappear from
the UI rather than half-loading.
- Atomic save (.tmp -> rename), `dirs::data_dir()`-based path
resolution. 5 round-trip / atomic / version-gate / corruption tests.
Sync trait extension:
- `SyncProvider::push_replay(&Replay)` — default returns
`UnsupportedPlatform` so `LocalOnlyProvider` is silently no-op'd by
the future push-on-win path. Mirrors the existing `pull` / `push`
default-impl pattern.
- `SolitaireServerClient::push_replay` — `POST /api/replays`, same
401-refresh-and-retry shape as `push`.
The wire format is the contract: `solitaire_wasm` (added in a later
commit) parses the JSON via its own minimal mirror struct so it can
compile to wasm32 without pulling the desktop client's transitive
deps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,15 @@ pub trait SyncProvider: Send + Sync {
|
|||||||
async fn delete_account(&self) -> Result<(), SyncError> {
|
async fn delete_account(&self) -> Result<(), SyncError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
/// Upload a winning replay to the backend so it's available for web
|
||||||
|
/// playback at `<server>/replays/<id>`. Default returns
|
||||||
|
/// `UnsupportedPlatform` so backends without a server (e.g.
|
||||||
|
/// `LocalOnlyProvider`) are silently no-op'd by the engine's
|
||||||
|
/// push-on-win system, matching the same pattern `pull` / `push`
|
||||||
|
/// follow.
|
||||||
|
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<(), SyncError> {
|
||||||
|
Err(SyncError::UnsupportedPlatform)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by
|
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by
|
||||||
@@ -92,6 +101,9 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
|
|||||||
async fn delete_account(&self) -> Result<(), SyncError> {
|
async fn delete_account(&self) -> Result<(), SyncError> {
|
||||||
(**self).delete_account().await
|
(**self).delete_account().await
|
||||||
}
|
}
|
||||||
|
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<(), SyncError> {
|
||||||
|
(**self).push_replay(replay).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
@@ -139,3 +151,9 @@ pub use auth_tokens::{
|
|||||||
|
|
||||||
pub mod sync_client;
|
pub mod sync_client;
|
||||||
pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient};
|
pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient};
|
||||||
|
|
||||||
|
pub mod replay;
|
||||||
|
pub use replay::{
|
||||||
|
latest_replay_path, load_latest_replay_from, save_latest_replay_to, Replay, ReplayMove,
|
||||||
|
REPLAY_SCHEMA_VERSION,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
//! Win-game replay recording + storage.
|
||||||
|
//!
|
||||||
|
//! When a player wins, the engine freezes the in-memory recording into a
|
||||||
|
//! [`Replay`] and persists it to `<data_dir>/solitaire_quest/latest_replay.json`
|
||||||
|
//! via [`save_latest_replay_to`]. The Stats screen offers a "Watch replay"
|
||||||
|
//! action that loads it via [`load_latest_replay_from`] so the player can
|
||||||
|
//! revisit (or, in a future build, watch the engine re-execute) the path
|
||||||
|
//! they took to victory.
|
||||||
|
//!
|
||||||
|
//! Schema versioning: bump [`REPLAY_SCHEMA_VERSION`] whenever the on-disk
|
||||||
|
//! shape changes. [`load_latest_replay_from`] returns `None` when the file
|
||||||
|
//! carries any other version so older replays are silently dropped instead
|
||||||
|
//! of crashing the loader.
|
||||||
|
//!
|
||||||
|
//! The recording is intentionally minimal — only [`ReplayMove`] entries
|
||||||
|
//! that successfully advanced the game. `Undo` is **not** recorded: a
|
||||||
|
//! replay represents the canonical path the player ultimately took to win,
|
||||||
|
//! so 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
|
||||||
|
//! for `GameState::new_with_mode` to rebuild the identical layout.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||||
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
|
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||||
|
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||||
|
|
||||||
|
/// Save-file schema version for [`Replay`]. Increment when the on-disk
|
||||||
|
/// representation changes incompatibly so [`load_latest_replay_from`] can
|
||||||
|
/// reject older formats and the player simply has no replay rather than
|
||||||
|
/// seeing a broken one.
|
||||||
|
///
|
||||||
|
/// History:
|
||||||
|
/// - v1: initial release. `ReplayMove` had separate `Draw` and `Recycle`
|
||||||
|
/// variants which carried the *outcome* of a stock interaction rather
|
||||||
|
/// than the player's atomic input.
|
||||||
|
/// - v2 (current): `Draw` + `Recycle` collapsed into a single `StockClick`
|
||||||
|
/// variant. The engine resolves draw-vs-recycle deterministically from
|
||||||
|
/// the current stock state, so the input alone is sufficient and the
|
||||||
|
/// replay model now stores atomic player inputs end-to-end.
|
||||||
|
pub const REPLAY_SCHEMA_VERSION: u32 = 2;
|
||||||
|
|
||||||
|
/// Default value for [`Replay::schema_version`] when deserialising files
|
||||||
|
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
|
||||||
|
/// causes [`load_latest_replay_from`] to return `None`.
|
||||||
|
fn schema_v0() -> u32 {
|
||||||
|
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: PileType,
|
||||||
|
/// Destination pile.
|
||||||
|
to: PileType,
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// Replays are reconstructed by rebuilding a fresh
|
||||||
|
/// `GameState::new_with_mode(seed, draw_mode, mode)` and applying the
|
||||||
|
/// [`moves`](Self::moves) in order. The presentation fields
|
||||||
|
/// ([`time_seconds`](Self::time_seconds), [`final_score`](Self::final_score),
|
||||||
|
/// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as
|
||||||
|
/// "Replay (2:14 win on 2026-05-02)".
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Replay {
|
||||||
|
/// Schema version. See [`REPLAY_SCHEMA_VERSION`].
|
||||||
|
#[serde(default = "schema_v0")]
|
||||||
|
pub schema_version: u32,
|
||||||
|
/// Seed used for the deal — replay rasterises the deck via
|
||||||
|
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
|
||||||
|
pub seed: u64,
|
||||||
|
/// Draw mode the recorded game was played in.
|
||||||
|
pub draw_mode: DrawMode,
|
||||||
|
/// Game mode the recorded game was played in.
|
||||||
|
pub mode: GameMode,
|
||||||
|
/// Total wall-clock seconds the win took. Used for the Stats UI
|
||||||
|
/// "Replay (2:14 win on 2026-05-02)" caption.
|
||||||
|
pub time_seconds: u64,
|
||||||
|
/// Final score at the moment of the win.
|
||||||
|
pub final_score: i32,
|
||||||
|
/// ISO-8601 date the win was recorded.
|
||||||
|
pub recorded_at: NaiveDate,
|
||||||
|
/// Ordered move list. Each entry is what the player did, replayable
|
||||||
|
/// against a fresh `GameState` constructed from the seed.
|
||||||
|
pub moves: Vec<ReplayMove>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Replay {
|
||||||
|
/// Construct a fresh replay with the current schema version. The
|
||||||
|
/// caller fills in the recorded fields; this is the canonical
|
||||||
|
/// constructor used by the engine on win.
|
||||||
|
pub fn new(
|
||||||
|
seed: u64,
|
||||||
|
draw_mode: DrawMode,
|
||||||
|
mode: GameMode,
|
||||||
|
time_seconds: u64,
|
||||||
|
final_score: i32,
|
||||||
|
recorded_at: NaiveDate,
|
||||||
|
moves: Vec<ReplayMove>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
schema_version: REPLAY_SCHEMA_VERSION,
|
||||||
|
seed,
|
||||||
|
draw_mode,
|
||||||
|
mode,
|
||||||
|
time_seconds,
|
||||||
|
final_score,
|
||||||
|
recorded_at,
|
||||||
|
moves,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the platform-specific path to `latest_replay.json`, or `None`
|
||||||
|
/// if `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
|
||||||
|
pub fn latest_replay_path() -> Option<PathBuf> {
|
||||||
|
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a [`Replay`] atomically to `path` using the standard `.tmp` →
|
||||||
|
/// rename contract that the rest of `storage.rs` uses.
|
||||||
|
///
|
||||||
|
/// Overwrites any existing replay — only the most recent winning replay
|
||||||
|
/// is retained on disk.
|
||||||
|
pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(replay).map_err(io::Error::other)?;
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
fs::write(&tmp, json.as_bytes())?;
|
||||||
|
fs::rename(&tmp, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a [`Replay`] from `path`, returning `None` when the file is
|
||||||
|
/// missing, corrupt, or carries a [`schema_version`](Replay::schema_version)
|
||||||
|
/// other than [`REPLAY_SCHEMA_VERSION`].
|
||||||
|
///
|
||||||
|
/// Schema-mismatch is treated as "no replay" so the player just sees the
|
||||||
|
/// "No replay recorded yet" caption rather than a half-loaded broken
|
||||||
|
/// replay. Bumping [`REPLAY_SCHEMA_VERSION`] therefore invalidates every
|
||||||
|
/// older save without further migration code.
|
||||||
|
pub fn load_latest_replay_from(path: &Path) -> Option<Replay> {
|
||||||
|
let data = fs::read(path).ok()?;
|
||||||
|
let replay: Replay = serde_json::from_slice(&data).ok()?;
|
||||||
|
if replay.schema_version != REPLAY_SCHEMA_VERSION {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(replay)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
fn tmp_path(name: &str) -> PathBuf {
|
||||||
|
env::temp_dir().join(format!("solitaire_test_replay_{name}.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_replay() -> Replay {
|
||||||
|
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
|
||||||
|
Replay::new(
|
||||||
|
12345,
|
||||||
|
DrawMode::DrawThree,
|
||||||
|
GameMode::Classic,
|
||||||
|
134,
|
||||||
|
5_120,
|
||||||
|
date,
|
||||||
|
vec![
|
||||||
|
ReplayMove::StockClick,
|
||||||
|
ReplayMove::Move {
|
||||||
|
from: PileType::Waste,
|
||||||
|
to: PileType::Tableau(3),
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
ReplayMove::StockClick,
|
||||||
|
ReplayMove::Move {
|
||||||
|
from: PileType::Tableau(3),
|
||||||
|
to: PileType::Foundation(0),
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-trivial replay with mixed move kinds must round-trip
|
||||||
|
/// byte-identically through `save_latest_replay_to` /
|
||||||
|
/// `load_latest_replay_from`. Catches any future field that forgets
|
||||||
|
/// `Serialize`/`Deserialize` or breaks the on-disk format.
|
||||||
|
#[test]
|
||||||
|
fn replay_round_trips_through_save_and_load() {
|
||||||
|
let path = tmp_path("round_trip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
let replay = sample_replay();
|
||||||
|
save_latest_replay_to(&path, &replay).expect("save");
|
||||||
|
|
||||||
|
let loaded = load_latest_replay_from(&path).expect("load must succeed");
|
||||||
|
assert_eq!(loaded, replay, "round-trip must preserve every field");
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file written by an older schema (or a pre-`schema_version`
|
||||||
|
/// build) must be rejected. We write a minimal v0 fixture and assert
|
||||||
|
/// that `load_latest_replay_from` returns `None` so the player gets
|
||||||
|
/// a clean "no replay" state instead of a broken one.
|
||||||
|
#[test]
|
||||||
|
fn replay_legacy_schema_version_falls_through_to_none() {
|
||||||
|
let path = tmp_path("legacy_schema");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
// No `schema_version` key — defaults to 0 via `schema_v0()`. Even
|
||||||
|
// if the rest of the JSON parses cleanly, the version gate must
|
||||||
|
// reject it.
|
||||||
|
let v0_json = r#"{
|
||||||
|
"seed": 1,
|
||||||
|
"draw_mode": "DrawOne",
|
||||||
|
"mode": "Classic",
|
||||||
|
"time_seconds": 60,
|
||||||
|
"final_score": 100,
|
||||||
|
"recorded_at": "2025-01-01",
|
||||||
|
"moves": []
|
||||||
|
}"#;
|
||||||
|
fs::write(&path, v0_json).expect("write v0 fixture");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
load_latest_replay_from(&path).is_none(),
|
||||||
|
"v0 replay must be rejected (schema gate)",
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic-write contract — `.tmp` must not be left behind after
|
||||||
|
/// `save_latest_replay_to` returns. Mirrors the same check that
|
||||||
|
/// guards `save_game_state_to` in `storage.rs`.
|
||||||
|
#[test]
|
||||||
|
fn replay_save_is_atomic() {
|
||||||
|
let path = tmp_path("atomic");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
save_latest_replay_to(&path, &sample_replay()).expect("save");
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loading from a path that does not exist must return `None`, not
|
||||||
|
/// panic or surface an `Err`.
|
||||||
|
#[test]
|
||||||
|
fn replay_missing_file_returns_none() {
|
||||||
|
let path = tmp_path("missing_xyz");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
assert!(load_latest_replay_from(&path).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loading from a corrupt / partially-written file must return
|
||||||
|
/// `None`, not surface a deserialiser error to the engine.
|
||||||
|
#[test]
|
||||||
|
fn replay_corrupt_file_returns_none() {
|
||||||
|
let path = tmp_path("corrupt");
|
||||||
|
fs::write(&path, b"not valid json!!!").expect("write");
|
||||||
|
assert!(load_latest_replay_from(&path).is_none());
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse}
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
|
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
|
||||||
|
replay::Replay,
|
||||||
settings::SyncBackend,
|
settings::SyncBackend,
|
||||||
SyncError, SyncProvider,
|
SyncError, SyncProvider,
|
||||||
};
|
};
|
||||||
@@ -356,6 +357,54 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
|
|
||||||
extract_leaderboard_body(resp).await
|
extract_leaderboard_body(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upload a winning replay to `POST /api/replays`. Mirrors the
|
||||||
|
/// `push` auth flow: 401 triggers a token refresh and one retry.
|
||||||
|
/// Non-success statuses are surfaced as the relevant `SyncError`
|
||||||
|
/// variant so the engine's push-on-win system can downgrade
|
||||||
|
/// network/auth failures into a quiet log without aborting the
|
||||||
|
/// game flow.
|
||||||
|
async fn push_replay(&self, replay: &Replay) -> Result<(), SyncError> {
|
||||||
|
let token = self.access_token()?;
|
||||||
|
let url = format!("{}/api/replays", self.base_url);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.json(replay)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
|
self.refresh_token().await?;
|
||||||
|
let new_token = self.access_token()?;
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(new_token)
|
||||||
|
.json(replay)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
return check_replay_status(resp.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
check_replay_status(resp.status())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_replay_status(status: reqwest::StatusCode) -> Result<(), SyncError> {
|
||||||
|
if status.is_success() {
|
||||||
|
Ok(())
|
||||||
|
} else if status == reqwest::StatusCode::UNAUTHORIZED
|
||||||
|
|| status == reqwest::StatusCode::FORBIDDEN
|
||||||
|
{
|
||||||
|
Err(SyncError::Auth(format!("server returned {status}")))
|
||||||
|
} else {
|
||||||
|
Err(SyncError::Network(format!("server returned {status}")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user