fix(replay): store the deal via upstream card_game serializers (schema v4)
Test / test (pull_request) Successful in 36m34s

Replays previously persisted only seed + moves and re-dealt the board
from the seed at playback time, so any change to the seed->deal mapping
(RNG bumps, upstream upgrades) silently invalidated every existing
replay. Schema v4 instead embeds a SessionRecording - the upstream
card_game Session serde ({config, initial_state, instructions}) - so
playback rebuilds the exact recorded board; seed/draw_mode/mode remain
caption metadata only.

- core: SessionRecording newtype delegating to Session<Klondike> serde;
  GameState::recording() / from_recording(); from_instructions_unchecked
  fixture helper; serde_json added to dev-deps (tests only)
- data: Replay v4 (recording replaces moves); v1-v3 files rejected by
  the existing version gate
- engine: win-recording and sync upload freeze game.recording();
  playback rebuilds from the recording; Playing carries the extracted
  move list (+ Box<Replay> for clippy large_enum_variant)
- wasm: replay_export() builds the full v4 upload payload so JS never
  hand-assembles it (the old game.js path hardcoded schema_version: 2
  and corrupted u64 seeds via Math.round); ReplayPlayer::from_json
  enforces schema_version == 4 with a descriptive error
- web: game.js/play.html use replay_export; replay.js surfaces player
  construction errors in the caption instead of dying silently
- server: mode validation accepts data-carrying GameMode variants
  (Difficulty uploads previously 400'd against the String field)

Both replays on prod are May-era v1 rows with empty move lists - every
shared replay was already unplayable; the viewer now says why.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-10 09:10:38 -07:00
parent fce0266b47
commit 4cb4212829
20 changed files with 753 additions and 616 deletions
+33 -8
View File
@@ -31,17 +31,37 @@ use crate::{AppState, error::AppError, middleware::AuthenticatedUser};
const KNOWN_MODES: &[&str] = &["Classic", "Zen", "TimeAttack", "Challenge", "Difficulty"];
const KNOWN_DRAW_MODES: &[&str] = &["DrawOne", "DrawThree"];
fn validate_header(h: &ReplayHeader) -> Result<(), AppError> {
/// Extract the mode label from the client's serde representation.
///
/// Unit `GameMode` variants serialise as plain strings (`"Classic"`),
/// but data-carrying variants serialise as single-key objects
/// (`{"Difficulty": "Easy"}`) — a plain-`String` field rejected those
/// uploads with a 400 even though `"Difficulty"` sits in `KNOWN_MODES`.
fn mode_label(mode: &serde_json::Value) -> Result<String, AppError> {
if let Some(s) = mode.as_str() {
return Ok(s.to_string());
}
if let Some(obj) = mode.as_object()
&& obj.len() == 1
&& let Some(key) = obj.keys().next()
{
return Ok(key.clone());
}
Err(AppError::BadRequest(format!(
"invalid mode '{mode}'; expected a mode string or single-variant object"
)))
}
fn validate_header(h: &ReplayHeader, mode: &str) -> Result<(), AppError> {
if !KNOWN_DRAW_MODES.contains(&h.draw_mode.as_str()) {
return Err(AppError::BadRequest(format!(
"invalid draw_mode '{}'; expected one of {:?}",
h.draw_mode, KNOWN_DRAW_MODES
)));
}
if !KNOWN_MODES.contains(&h.mode.as_str()) {
if !KNOWN_MODES.contains(&mode) {
return Err(AppError::BadRequest(format!(
"invalid mode '{}'; expected one of {:?}",
h.mode, KNOWN_MODES
"invalid mode '{mode}'; expected one of {KNOWN_MODES:?}"
)));
}
if h.time_seconds <= 0 || h.time_seconds > 86_400 {
@@ -72,7 +92,11 @@ fn validate_header(h: &ReplayHeader) -> Result<(), AppError> {
struct ReplayHeader {
seed: u64,
draw_mode: String,
mode: String,
/// Kept as raw JSON: unit variants arrive as strings, data-carrying
/// variants (`GameMode::Difficulty(level)`) as single-key objects.
/// [`mode_label`] projects both onto the label stored in the `mode`
/// column.
mode: serde_json::Value,
time_seconds: i64,
final_score: i64,
recorded_at: String,
@@ -126,7 +150,8 @@ pub async fn upload(
let header: ReplayHeader = serde_json::from_value(payload.clone())
.map_err(|e| AppError::BadRequest(format!("replay JSON missing fields: {e}")))?;
validate_header(&header)?;
let mode = mode_label(&header.mode)?;
validate_header(&header, &mode)?;
let id = Uuid::new_v4().to_string();
let received_at = Utc::now().to_rfc3339();
@@ -144,7 +169,7 @@ pub async fn upload(
user.user_id,
seed_i64,
header.draw_mode,
header.mode,
mode,
header.time_seconds,
header.final_score,
header.recorded_at,
@@ -158,7 +183,7 @@ pub async fn upload(
// beats their existing best. Only classic mode counts for the leaderboard.
// Use `received_at` (server-computed) rather than `header.recorded_at`
// (client-supplied) so clients cannot spoof the timestamp.
if header.mode == "Classic" {
if mode == "Classic" {
sqlx::query!(
r#"UPDATE leaderboard
SET best_score = ?,