fix(replay): store the deal via upstream card_game serializers (schema v4)
Test / test (pull_request) Successful in 36m34s
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:
@@ -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 = ?,
|
||||
|
||||
Reference in New Issue
Block a user