7840ef9eb2
Build and Deploy / build-and-push (push) Successful in 3m40s
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>
221 lines
7.7 KiB
Rust
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))
|
|
}
|