Files
Ferrous-Solitaire/solitaire_server/src/replays.rs
T
funman300 7840ef9eb2
Build and Deploy / build-and-push (push) Successful in 3m40s
fix(multi): resolve 26 bugs found in comprehensive codebase review
Core fixes (issues #12, #13, #22):
- #12: undo now preserves score delta instead of restoring snapshot score
- #13: take_from_foundation defaults to false (non-standard house rule)
- #22: check_win validates full suit sequence, not just card count

Engine fixes:
- #8:  replay keyboard input guard against non-replay state
- #9:  help modal scrims.is_empty() guard added
- #10: settings modal scrims.is_empty() guard added
- #11: sync_plugin builds payload at poll time (not task-spawn time)
- #14: server replay mode case-sensitivity fix ("Classic")
- #15: play_by_seed_plugin confirmed flag set to true on launch
- #16: replay back-step debounce via Local<bool> + StateChangedEvent;
       register StateChangedEvent in ReplayOverlayPlugin (fixes 52 tests)
- #17: time-attack timer ignores win-summary overlay
- #18: HUD dropdown glyphs U+25BE → U+2193 (FiraMono-safe arrow)
- #19: theme plugin applies immediate visual update on A→B→A switch
- #20: SyncAuthError / SyncBusyOverlay split into separate entities so
       auth errors are visible after busy overlay is hidden
- #21: handle_forfeit ordered before update_stats_on_new_game
- #23: server merge uses correct avg_time_seconds and games_lost math
- #24: win_summary migrated to ModalScrim pattern
- #25: card_animation apply_deferred between animation systems
- #26: cursor_plugin HashMap access uses .get() with fallback
- #27: auto_complete mid-sequence deactivation guard
- #28: feedback_anim SettleAnim ordered before FoundationFlourish
- #29: achievement_plugin iterates all win events; adds scrims guard
- #30: leaderboard modal scrims.is_empty() guard added
- #31: server auth tmp file cleanup on rename failure
- #32: sync_setup modal scrims.is_empty() guard added
- #33: font_plugin uses match fallback; TokioRuntimeResource graceful
       current-thread fallback on runtime init failure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:14:47 -07:00

221 lines
7.7 KiB
Rust

//! Winning-replay storage and retrieval.
//!
//! `POST /api/replays` — upload a winning replay (auth required).
//! `GET /api/replays/recent` — list the N most-recent replays across users.
//! `GET /api/replays/:id` — fetch a single replay's full JSON.
//!
//! The replay payload itself is opaque to the server — the desktop client
//! generates a `solitaire_data::Replay` and the web playback re-executes
//! the same atomic input list against a fresh `GameState`. The server
//! just persists, indexes, and serves the JSON; it does not validate the
//! semantics of the move list.
//!
//! Three columns are projected out of the replay JSON at insert time
//! (`final_score`, `time_seconds`, `recorded_at`) so list endpoints can
//! be served without scanning every blob.
use axum::{
extract::{Path, Query, State},
Json,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
// ---------------------------------------------------------------------------
// Wire types
// ---------------------------------------------------------------------------
/// Subset of `Replay` fields the server needs to project out of the
/// uploaded JSON to populate the denormalised columns. Mirrors the
/// fields on `solitaire_data::Replay`; we don't depend on
/// `solitaire_data` here because the server crate must not pull in
/// the desktop client's transitive dependencies.
#[derive(Debug, Deserialize)]
struct ReplayHeader {
seed: u64,
draw_mode: String,
mode: String,
time_seconds: i64,
final_score: i64,
recorded_at: String,
}
/// Successful upload acknowledgement. The server-minted `id` is what
/// the client / web UI uses to link to `/replays/<id>`.
#[derive(Debug, Serialize)]
pub struct ReplayUploadResponse {
/// UUID v4 minted server-side at insert time.
pub id: String,
}
/// One row in the recent-replays list. Just the projection columns —
/// the full move list lives behind `GET /api/replays/:id`.
#[derive(Debug, Serialize)]
pub struct ReplaySummary {
pub id: String,
pub username: String,
pub seed: i64,
pub draw_mode: String,
pub mode: String,
pub time_seconds: i64,
pub final_score: i64,
pub recorded_at: String,
pub received_at: String,
}
/// `GET /api/replays/recent?limit=N` — bound the result set so a
/// long-tail history doesn't ship megabytes per request.
#[derive(Debug, Deserialize)]
pub struct RecentQuery {
pub limit: Option<u32>,
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/// `POST /api/replays` — accept a winning replay JSON, persist it,
/// return the server-minted `id`. Auth required (the upload is
/// attributed to the authenticated user).
pub async fn upload(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<ReplayUploadResponse>, AppError> {
// Project the header fields the SQL columns need. The full payload
// is stored verbatim — schema_version sits inside it and the
// playback path is what enforces compatibility.
let header: ReplayHeader = serde_json::from_value(payload.clone())
.map_err(|e| AppError::BadRequest(format!("replay JSON missing fields: {e}")))?;
let id = Uuid::new_v4().to_string();
let received_at = Utc::now().to_rfc3339();
let replay_json = serde_json::to_string(&payload)?;
// SQLite INTEGER columns bind as i64. Reinterpret the u64 bits — the
// database stores the same 8 bytes; high-bit seeds round-trip correctly.
let seed_i64 = header.seed as i64;
sqlx::query!(
r#"INSERT INTO replays (
id, user_id, seed, draw_mode, mode, time_seconds, final_score,
recorded_at, received_at, replay_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
id,
user.user_id,
seed_i64,
header.draw_mode,
header.mode,
header.time_seconds,
header.final_score,
header.recorded_at,
received_at,
replay_json,
)
.execute(&state.pool)
.await?;
// Update leaderboard best score/time for opted-in users when this replay
// beats their existing best. Only classic mode counts for the leaderboard.
if header.mode == "Classic" {
sqlx::query!(
r#"UPDATE leaderboard
SET best_score = ?,
best_time_secs = ?,
recorded_at = ?
WHERE user_id = ?
AND (
best_score IS NULL
OR ? > best_score
OR (? = best_score AND (best_time_secs IS NULL OR ? < best_time_secs))
)"#,
header.final_score,
header.time_seconds,
header.recorded_at,
user.user_id,
header.final_score,
header.final_score,
header.time_seconds,
)
.execute(&state.pool)
.await?;
}
Ok(Json(ReplayUploadResponse { id }))
}
/// `GET /api/replays/recent` — list the N most-recent replays across
/// every user, newest first. Auth not required so the web UI can show
/// a public "latest wins" feed without a logged-in client.
pub async fn recent(
State(state): State<AppState>,
Query(q): Query<RecentQuery>,
) -> Result<Json<Vec<ReplaySummary>>, AppError> {
// 50 is a sane upper bound so a `?limit=999999` request can't make
// the server allocate megabytes. 20 is the default for a quick feed.
let limit = q.limit.unwrap_or(20).min(50) as i64;
let rows = sqlx::query!(
r#"SELECT
r.id AS "id!: String",
u.username AS "username!: String",
r.seed AS "seed!: i64",
r.draw_mode AS "draw_mode!: String",
r.mode AS "mode!: String",
r.time_seconds AS "time_seconds!: i64",
r.final_score AS "final_score!: i64",
r.recorded_at AS "recorded_at!: String",
r.received_at AS "received_at!: String"
FROM replays r
JOIN users u ON u.id = r.user_id
ORDER BY r.received_at DESC
LIMIT ?"#,
limit,
)
.fetch_all(&state.pool)
.await?;
Ok(Json(
rows.into_iter()
.map(|r| ReplaySummary {
id: r.id,
username: r.username,
seed: r.seed,
draw_mode: r.draw_mode,
mode: r.mode,
time_seconds: r.time_seconds,
final_score: r.final_score,
recorded_at: r.recorded_at,
received_at: r.received_at,
})
.collect(),
))
}
/// `GET /api/replays/:id` — return the full replay JSON the desktop
/// client uploaded. Public; the web UI fetches this directly.
///
/// The server does not validate or transform the payload — what was
/// stored is what's returned. Schema-version compatibility is the
/// responsibility of the playback side (web UI), matching the
/// `schema_version` gate the desktop loader uses.
pub async fn get_by_id(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let row = sqlx::query!(
"SELECT replay_json FROM replays WHERE id = ?",
id,
)
.fetch_optional(&state.pool)
.await?;
let replay_json = row
.ok_or_else(|| AppError::NotFound("replay not found".into()))?
.replay_json;
let value: serde_json::Value = serde_json::from_str(&replay_json)?;
Ok(Json(value))
}