Skip to main content

supreme_fs/game_storage/online/
rooms.rs

1//! Rooms API client for the online tournament.
2//!
3//! Everything here talks to the rooms actions the backend exposes at
4//! `<api_url>?action=room_*` with a JSON body:
5//!
6//! | Method (this module)      | Action               | Response payload          |
7//! |---------------------------|----------------------|---------------------------|
8//! | [`GameStorage::create_room`]       | `room_create`        | `{room: RoomDto}`         |
9//! | [`GameStorage::list_rooms`]        | `room_list`          | `{rooms: RoomSummaryDto[]}` |
10//! | [`GameStorage::get_room`]          | `room_get`           | `{room: RoomDto}`         |
11//! | [`GameStorage::join_room`]         | `room_join`          | `{room: RoomDto}`         |
12//! | [`GameStorage::remove_room`]       | `room_remove`        | `{status: ...}`           |
13//! | [`GameStorage::upload_room_record`]| `room_upload_record` | `{accepted, personal_best, rank, room}` |
14//! | [`GameStorage::fetch_room_records`]| `room_records`       | `{records: RoomRecordDto[]}` |
15//! | [`GameStorage::fetch_room_high_times`]| `room_high_times`  | `{high_times, entries}`    |
16//! | [`GameStorage::fetch_room_high_times_version`]| `room_high_times_version` | `{version: sha256}` |
17//!
18//! # JSON casing (matched to the real server envelope)
19//!
20//! The backend's existing response builders are snake_case, `php/index.php`
21//! emits `status`, `message`, `missing_files`, `max_upload_size`,
22//! `chunk_index`, `upload_id`, `path_info`, `filename`, and the payload
23//! examples are snake_case too (`ends_at`, `time_ticks`, `player_count`,
24//! `record_count`, `uploaded_at`, `personal_best`). The DTO field names
25//! below therefore match the wire format verbatim: **no** `rename_all`
26//! attribute, plain serde field names.
27//!
28//! # Response handling
29//!
30//! Follows the fixed online-layer pattern (the client is built with
31//! `http_status_as_error(false)`): an explicit `status().is_success()` check
32//! turns non-2xx responses into errors carrying the server's message, and a
33//! 2xx response whose body is the rooms error envelope
34//! `{"status":"error","message":"..."}` is also an error.
35//! `accepted: false` from `room_upload_record` is a *successful* response
36//! (the server kept the player's existing personal best), never an error.
37//!
38//! # Record blob upload
39//!
40//! [`GameStorage::upload_room_record`] first pushes the raw `.dat` into the
41//! server's CAS through the existing unified chunked upload
42//! ([`GameStorage::upload_file_unified`], the same path
43//! `supreme_snapshot` uses for replay content), and only then claims it
44//! with `room_upload_record {id, player, time_ticks, hash}`, the server
45//! rejects records whose hash is not already in the CAS.
46//!
47//! Because the backend only accepts content uploads whose hash is
48//! referenced by a pending staging manifest (anti-abuse rule carried over
49//! from the snapshot flow), a minimal throw-away manifest referencing the
50//! blob is staged first. The manifest is deliberately **not** committed:
51//! unlike a shared replay snapshot it must not appear in the public
52//! replays listing, and the room JSON the server keeps is the only
53//! manifest a tournament record needs.
54
55use super::util::api_http_error;
56use crate::HybridStorageError;
57use crate::game_storage::GameStorage;
58use serde::de::DeserializeOwned;
59use sha2::{Digest, Sha256};
60use std::io;
61use std::path::Path;
62
63/// One room record: the best replay `.dat` of a single player in a
64/// room. `hash` is the CAS sha256 of the uploaded `.dat`.
65///
66/// `splits_ms` is `[checkpoint1_ms, checkpoint2_ms]` as captured at upload
67/// time (whole milliseconds); `None` for records uploaded without usable
68/// split data (including everything stored by older clients/servers, the
69/// server renders finish-only splits for those).
70#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
71pub struct RoomRecordDto {
72    pub player: String,
73    pub time_ticks: i64,
74    pub hash: String,
75    pub size: i64,
76    pub uploaded_at: i64,
77    #[serde(default)]
78    pub splits_ms: Option<[i64; 2]>,
79}
80
81/// Lightweight room entry as returned by `room_list` (every room whose
82/// `ends_at` is in the future, including not-yet-started rooms, which the
83/// launcher shows as unselectable until they go live).
84#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
85pub struct RoomSummaryDto {
86    pub id: String,
87    /// Room display name. Empty for servers/rooms predating it.
88    #[serde(default)]
89    pub name: String,
90    pub env: String,
91    pub difficulty: String,
92    pub host: String,
93    /// When the room opens for play, epoch seconds UTC. `0` on
94    /// servers predating it, treated as "already started".
95    #[serde(default)]
96    pub starts_at: i64,
97    pub ends_at: i64,
98    pub player_count: i64,
99    pub record_count: i64,
100    /// The room's human-facing results page, built BY THE SERVER from its
101    /// own public base URL (env `SUPREME_PUBLIC_BASE_URL`, falling back to
102    /// the request origin). `None` when the server does not publish one:
103    /// the launcher only renders its results icon when this is `Some`,
104    /// it never assembles the URL client-side (the launcher cannot know
105    /// how the backend is exposed to browsers).
106    #[serde(default)]
107    pub results_url: Option<String>,
108    /// The creating account's user id when the server tracks room
109    /// ownership (an account-created room). `None` on servers/rooms
110    /// predating it: ownership then rests on the `host` name alone (the
111    /// guest trust level of the rest of the API). The launcher uses it
112    /// to offer removal of rooms "your account owns".
113    #[serde(default)]
114    pub owner_user_id: Option<i64>,
115}
116
117/// Full room state as returned by `room_create`, `room_get` and `room_join`.
118#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
119pub struct RoomDto {
120    pub id: String,
121    /// Room display name (; server-generated snowboarding name when
122    /// the creator left it empty). Empty for servers predating it.
123    #[serde(default)]
124    pub name: String,
125    pub env: String,
126    pub difficulty: String,
127    pub host: String,
128    /// When the room opens for play, epoch seconds UTC. `0` on
129    /// servers predating it, treated as "already started".
130    #[serde(default)]
131    pub starts_at: i64,
132    pub ends_at: i64,
133    pub created_at: i64,
134    pub players: Vec<String>,
135    pub records: Vec<RoomRecordDto>,
136}
137
138impl RoomDto {
139    /// Is the room open for business right now (started AND not ended)?
140    /// `starts_at == 0` (servers predating ) counts as started.
141    #[must_use]
142    pub fn is_live(&self, now: i64) -> bool {
143        (self.starts_at == 0 || self.starts_at <= now) && self.ends_at > now
144    }
145}
146
147impl RoomSummaryDto {
148    /// [`RoomDto::is_live`] for the list entry.
149    #[must_use]
150    pub fn is_live(&self, now: i64) -> bool {
151        (self.starts_at == 0 || self.starts_at <= now) && self.ends_at > now
152    }
153
154    /// May this room still be removed by its creator: only BEFORE the
155    /// room's start time and while no other player has joined (the
156    /// server enforces both guards under its room lock, this is the
157    /// launcher's client-side gate for showing the affordance at all).
158    /// A `starts_at` of `0` (servers predating the field) counts as
159    /// already started: an old-style room can never be removed.
160    #[must_use]
161    pub fn is_removable(&self, now: i64) -> bool {
162        self.starts_at > now && self.player_count <= 1
163    }
164}
165
166/// Result of `room_upload_record`. `accepted: false` means the server kept
167/// the player's existing (better or equal) personal best, a successful
168/// response, not an error.
169#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
170pub struct RoomUploadResult {
171    pub accepted: bool,
172    pub personal_best: Option<i64>,
173    pub rank: i64,
174}
175
176/// A logged-in account's identity, attached additively to room actions
177/// (`user_id` + `auth_key` body fields; `None` = guest). The server
178/// verifies the pair and stamps the account on the room/record; guests
179/// keep working unchanged and may not use a registered nickname.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub struct RoomIdentity<'a> {
182    pub user_id: i64,
183    pub auth_key: &'a str,
184}
185
186/// The `room_remove` JSON body (pure, so the wire shape is unit-testable):
187/// `{id, player}` plus the account identity fields when the caller is
188/// logged in.
189#[must_use]
190pub fn room_remove_payload(
191    id: &str,
192    player: &str,
193    identity: Option<RoomIdentity<'_>>,
194) -> serde_json::Value {
195    let mut payload = serde_json::json!({ "id": id, "player": player });
196    apply_room_identity(&mut payload, identity);
197    payload
198}
199
200/// Add the account identity fields to a JSON action body when the caller
201/// is logged in (pure, so the wire shape is unit-testable). Absent fields
202/// mean guest, exactly like an old client's body.
203pub(crate) fn apply_room_identity(
204    body: &mut serde_json::Value,
205    identity: Option<RoomIdentity<'_>>,
206) {
207    if let (Some(identity), Some(obj)) = (identity, body.as_object_mut()) {
208        obj.insert(
209            "user_id".to_owned(),
210            serde_json::Value::Number(identity.user_id.into()),
211        );
212        obj.insert(
213            "auth_key".to_owned(),
214            serde_json::Value::String(identity.auth_key.to_owned()),
215        );
216    }
217}
218
219/// Envelope for `room_create` / `room_get` / `room_join`: `{room: RoomDto}`.
220#[derive(serde::Deserialize)]
221struct RoomEnvelope {
222    room: RoomDto,
223}
224
225/// Envelope for `room_list`: `{rooms: RoomSummaryDto[]}`.
226#[derive(serde::Deserialize)]
227struct RoomListEnvelope {
228    rooms: Vec<RoomSummaryDto>,
229}
230
231/// Envelope for `room_remove`: the standard `{status: "success"}`
232/// envelope (the payload is empty, the `status` check lives in
233/// [`post_room_action`](GameStorage::post_room_action)).
234#[derive(serde::Deserialize)]
235struct RoomRemovedEnvelope {}
236
237/// Envelope for `room_records`: `{records: RoomRecordDto[]}`.
238#[derive(serde::Deserialize)]
239struct RoomRecordsEnvelope {
240    records: Vec<RoomRecordDto>,
241}
242
243/// Envelope for `room_high_times`: `{high_times, entries}`, the exact
244/// high-times file text the server rendered for the requesting player, plus
245/// the number of entries it contains (diagnostics; the full leaderboard can
246/// exceed the game's five replay slots).
247#[derive(serde::Deserialize)]
248struct RoomHighTimesEnvelope {
249    high_times: String,
250    #[serde(default)]
251    entries: i64,
252}
253
254/// Envelope for `room_high_times_version` : `{version}`, the sha-256
255/// hex digest of the exact text `room_high_times` would currently return for
256/// the same request. This is the CAS version test: the caller hashes its
257/// local file and only downloads the body when the digests differ, then
258/// verifies the downloaded body against the same digest before storing it
259/// (content-addressed copy semantics).
260#[derive(serde::Deserialize)]
261struct RoomHighTimesVersionEnvelope {
262    version: String,
263}
264
265/// Map a [`fetch_room_high_times`](GameStorage::fetch_room_high_times)
266/// result to the optional `(file text, entry count)`: a server that predates
267/// the action answers the request with a version-mismatch signature, which
268/// means "generate locally" (Ok(None)), every other error stays an error so
269/// the caller's own error path reports it.
270fn room_high_times_from_result(
271    result: Result<RoomHighTimesEnvelope, HybridStorageError>,
272) -> Result<Option<(String, i64)>, HybridStorageError> {
273    match result {
274        Ok(envelope) => Ok(Some((envelope.high_times, envelope.entries))),
275        Err(HybridStorageError::ServerVersionMismatch(_)) => Ok(None),
276        Err(e) => Err(e),
277    }
278}
279
280/// Map a [`fetch_room_high_times_version`](GameStorage::fetch_room_high_times_version)
281/// result to the optional content hash, with the same "server predates the
282/// action" (Ok(None)) convention as [`room_high_times_from_result`].
283fn room_high_times_version_from_result(
284    result: Result<RoomHighTimesVersionEnvelope, HybridStorageError>,
285) -> Result<Option<String>, HybridStorageError> {
286    match result {
287        Ok(envelope) => Ok(Some(envelope.version)),
288        Err(HybridStorageError::ServerVersionMismatch(_)) => Ok(None),
289        Err(e) => Err(e),
290    }
291}
292
293/// Render the high-times entries for a room's records LOCALLY (local
294/// fallback for servers predating `room_high_times`): every valid record
295/// (positive `time_ticks`, non-empty `hash`), per-player best only,
296/// ascending by `(time_ticks, player, hash)`, the same ordering as
297/// [`supreme_tournament::rank_records`] and the server-side renderer, so
298/// a fallback file can never disagree with a server-rendered one about
299/// rank order.
300///
301/// Split times come straight from the stored `splits_ms`; records without
302/// usable splits render finish-only (both checkpoints collapse to the
303/// finish, exactly like the server does). The per-entry date column is the
304/// record's upload date (UTC, game format), no padding is applied here;
305/// callers that need the hot-tail dummies call
306/// [`supreme_tournament::pad_with_dummies`] themselves.
307pub fn high_times_entries_from_records(
308    dtos: &[RoomRecordDto],
309) -> Vec<supreme_tournament::HighTimesEntry> {
310    use supreme_tournament::{HighTimesEntry, entry_times_ms, format_game_date};
311
312    let mut ranked: Vec<&RoomRecordDto> = dtos
313        .iter()
314        .filter(|d| d.time_ticks > 0 && !d.hash.is_empty())
315        .collect();
316    ranked.sort_by(|a, b| {
317        (a.time_ticks, &a.player, &a.hash).cmp(&(b.time_ticks, &b.player, &b.hash))
318    });
319    ranked.dedup_by_key(|d| d.player.as_str());
320
321    ranked
322        .into_iter()
323        .map(|d| {
324            let finish_ms = d.time_ticks * 10;
325            let (cp1, cp2, finish) = match d.splits_ms {
326                Some([cp1_ms, cp2_ms]) => entry_times_ms(finish_ms, cp1_ms, Some(cp2_ms)),
327                None => entry_times_ms(finish_ms, 0, None),
328            };
329            HighTimesEntry {
330                player: d.player.clone(),
331                checkpoint1_ms: cp1,
332                checkpoint2_ms: cp2,
333                finish_ms: finish,
334                conditions: "Day".to_owned(),
335                date: format_game_date(d.uploaded_at),
336            }
337        })
338        .collect()
339}
340
341/// Envelope for `room_upload_record`: `{accepted, personal_best, rank, room}`.
342/// `rank` / `personal_best` are `#[serde(default)]` so a server that omits
343/// them on a rejected upload still parses; an absent `rank` maps to `0`
344/// ("unknown") in [`RoomUploadResult`].
345#[derive(serde::Deserialize)]
346struct RoomUploadEnvelope {
347    accepted: bool,
348    #[serde(default)]
349    personal_best: Option<i64>,
350    #[serde(default)]
351    rank: Option<i64>,
352}
353
354/// Return the error message when `value` is the standard error envelope
355/// `{"status":"error","message":"..."}`, `None` for success payloads.
356/// Shared by the rooms actions and the auth module's actions (the same
357/// envelope governs every `?action=` endpoint).
358pub(super) fn room_error_message(value: &serde_json::Value) -> Option<String> {
359    if value.get("status").and_then(|s| s.as_str()) == Some("error") {
360        return Some(
361            value
362                .get("message")
363                .and_then(|m| m.as_str())
364                .unwrap_or("unknown room API error")
365                .to_string(),
366        );
367    }
368    None
369}
370
371fn json_parse_error(e: serde_json::Error) -> HybridStorageError {
372    HybridStorageError::IoError(io::Error::new(io::ErrorKind::InvalidData, e))
373}
374
375impl GameStorage {
376    /// POST one JSON action (`?action=<name>` with a JSON body, the
377    /// request shape the rooms and auth endpoints share) and decode its
378    /// payload.
379    ///
380    /// Auth mirrors the existing multipart actions: the `X-SSBZ-Token`
381    /// header when a token is configured, plus the `api_token` field inside
382    /// the JSON body (the JSON equivalent of the `api_token` form field).
383    ///
384    /// # Errors
385    ///
386    /// Transport errors propagate. A response carrying the error
387    /// envelope `{"status":"error","message":"..."}` is an
388    /// [`HybridStorageError::UploadFailed`] with the server's message
389    /// VERBATIM (the envelope is the server's per-request refusal copy:
390    /// `403` not the creator, `409` players joined, a reserved nickname,
391    /// ...), regardless of the HTTP status. Non-2xx responses that are
392    /// NOT an error envelope are classified by the online layer's shared
393    /// [`api_http_error`]: signatures of a backend that predates the
394    /// requested API (legacy 413/400 catch-alls, `Unknown action`,
395    /// endpoint-level 404s) become
396    /// [`HybridStorageError::ServerVersionMismatch`], the end-user-facing
397    /// "server version mismatch" message; genuine per-request failures
398    /// stay [`HybridStorageError::UploadFailed`] carrying the server's
399    /// message.
400    pub(crate) fn post_room_action<T: DeserializeOwned>(
401        &self,
402        action: &str,
403        payload: serde_json::Value,
404    ) -> Result<T, HybridStorageError> {
405        let client = self
406            .client
407            .as_ref()
408            .ok_or_else(|| io::Error::other("Online storage not configured"))?;
409        let api_url = self
410            .api_url
411            .as_ref()
412            .ok_or_else(|| io::Error::other("Online storage not configured"))?;
413
414        let mut body = payload;
415        if let (Some(token), Some(obj)) = (&self.token, body.as_object_mut()) {
416            obj.insert(
417                "api_token".to_string(),
418                serde_json::Value::String(token.clone()),
419            );
420        }
421        let body_bytes = serde_json::to_vec(&body).map_err(json_parse_error)?;
422
423        let url = format!("{api_url}?action={action}");
424        let mut request = client.post(&url).header("Content-Type", "application/json");
425        if let Some(token) = &self.token {
426            request = request.header("X-SSBZ-Token", token);
427        }
428        let response = request.send(body_bytes)?;
429
430        let value: serde_json::Value = if response.status().is_success() {
431            response
432                .into_body()
433                .read_json()
434                .map_err(|e| {
435                    HybridStorageError::IoError(io::Error::new(
436                        io::ErrorKind::InvalidData,
437                        format!("rooms API returned invalid JSON: {e}"),
438                    ))
439                })?
440        } else {
441            let status = response.status().as_u16();
442            let body = response.into_body().read_to_string().unwrap_or_default();
443            // The rooms error envelope IS the server's refusal copy, on
444            // ANY status: surface its message verbatim. Only the
445            // version-mismatch signatures keep their classified error (an
446            // old backend's "Unknown action" answers an update-the-server
447            // message, not a per-request refusal).
448            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&body)
449                && let Some(message) = room_error_message(&parsed)
450                && !super::util::server_version_mismatch_signature(
451                    status,
452                    Some(message.as_str()),
453                    true,
454                )
455            {
456                return Err(HybridStorageError::UploadFailed(message));
457            }
458            return Err(api_http_error(status, &body));
459        };
460
461        if let Some(message) = room_error_message(&value) {
462            return Err(HybridStorageError::UploadFailed(message));
463        }
464
465        serde_json::from_value(value).map_err(json_parse_error)
466    }
467
468    /// Create a tournament room (`room_create`). The server generates
469    /// the room id (`r_` + 12 hex chars) and `created_at`.
470    ///
471    /// the room has a planning window, `starts_at` (epoch seconds;
472    /// may reach 13 h into the past, 7 days into the future) and `ends_at`
473    /// (after `starts_at`, at most 7 days out; a host may not have two
474    /// rooms sharing a UTC calendar day), and an optional display `name`
475    /// (empty = the server picks a snowboarding word pair; a supplied name
476    /// must pass the server's blocklist).
477    ///
478    /// `identity` attaches the logged-in account when present (the room
479    /// becomes account-owned and verified); `None` creates as a guest.
480    ///
481    /// # Errors
482    ///
483    /// See [`GameStorage::post_room_action`].
484    pub fn create_room(
485        &self,
486        env: &str,
487        difficulty: &str,
488        host: &str,
489        name: &str,
490        starts_at: i64,
491        ends_at: i64,
492        identity: Option<RoomIdentity<'_>>,
493    ) -> Result<RoomDto, HybridStorageError> {
494        let mut payload = serde_json::json!({
495            "env": env,
496            "difficulty": difficulty,
497            "host": host,
498            "name": name,
499            "starts_at": starts_at,
500            "ends_at": ends_at,
501        });
502        apply_room_identity(&mut payload, identity);
503        Ok(self
504            .post_room_action::<RoomEnvelope>("room_create", payload)?
505            .room)
506    }
507
508    /// List open rooms (`room_list`; the server only returns rooms whose
509    /// `ends_at` is in the future).
510    ///
511    /// # Errors
512    ///
513    /// See [`GameStorage::post_room_action`].
514    pub fn list_rooms(&self) -> Result<Vec<RoomSummaryDto>, HybridStorageError> {
515        Ok(self
516            .post_room_action::<RoomListEnvelope>("room_list", serde_json::json!({}))?
517            .rooms)
518    }
519
520    /// Fetch full room state (`room_get`).
521    ///
522    /// # Errors
523    ///
524    /// See [`GameStorage::post_room_action`].
525    pub fn get_room(&self, id: &str) -> Result<RoomDto, HybridStorageError> {
526        Ok(self
527            .post_room_action::<RoomEnvelope>("room_get", serde_json::json!({ "id": id }))?
528            .room)
529    }
530
531    /// Join a room as `player` (`room_join`), with the logged-in
532    /// account's `identity` attached when present (a guest join stays
533    /// `None`; the server refuses a guest whose name matches a
534    /// registered nickname, that refusal arrives verbatim).
535    ///
536    /// # Errors
537    ///
538    /// See [`GameStorage::post_room_action`].
539    pub fn join_room(
540        &self,
541        id: &str,
542        player: &str,
543        identity: Option<RoomIdentity<'_>>,
544    ) -> Result<RoomDto, HybridStorageError> {
545        let mut payload = serde_json::json!({ "id": id, "player": player });
546        apply_room_identity(&mut payload, identity);
547        Ok(self
548            .post_room_action::<RoomEnvelope>("room_join", payload)?
549            .room)
550    }
551
552    /// Remove a room you created (`room_remove`). The server only
553    /// accepts the request from the room's creator, only while no other
554    /// player has joined, and only BEFORE the room's start time; the
555    /// player identifies as the creator by `player` name (guest rooms)
556    /// and/or the account `identity` when logged in. The room's files are
557    /// deleted server-side; the content-addressed blobs are shared and
558    /// stay.
559    ///
560    /// # Errors
561    ///
562    /// See [`GameStorage::post_room_action`]; the server's refusal
563    /// messages (`not the creator`, `players have joined`, `room already
564    /// started`, `unknown room`) arrive verbatim. A backend predating
565    /// the action answers a version-mismatch signature.
566    pub fn remove_room(
567        &self,
568        id: &str,
569        player: &str,
570        identity: Option<RoomIdentity<'_>>,
571    ) -> Result<(), HybridStorageError> {
572        let payload = room_remove_payload(id, player, identity);
573        self.post_room_action::<RoomRemovedEnvelope>("room_remove", payload)
574            .map(|_| ())
575    }
576
577    /// Fetch the best record per player (`room_records`, the DLL's
578    /// polling endpoint).
579    ///
580    /// # Errors
581    ///
582    /// See [`GameStorage::post_room_action`].
583    pub fn fetch_room_records(
584        &self,
585        room_id: &str,
586    ) -> Result<Vec<RoomRecordDto>, HybridStorageError> {
587        Ok(self
588            .post_room_action::<RoomRecordsEnvelope>(
589                "room_records",
590                serde_json::json!({ "id": room_id }),
591            )?
592            .records)
593    }
594
595    /// Fetch the server-rendered high-times file for the selected track's
596    /// room and `player` (`room_high_times`): the exact text the game's
597    /// `Saved_Data/<env>_<diff>_high_times.txt` should contain, the
598    /// FULL room leaderboard (every record the server has, ascending, plus
599    /// dummy padding only when fewer than five), so the game's high-score
600    /// screen shows everyone. Entry rank N pairs with `Replay_N.dat` for the
601    /// game's five replay slots.
602    ///
603    /// Returns `Ok(Some((text, entries)))` on success (`entries` = the
604    /// rendered entry count, diagnostics), and `Ok(None)` when the server
605    /// predates the action (version mismatch signature), the caller falls
606    /// back to local generation ([`high_times_entries_from_records`]).
607    ///
608    /// # Errors
609    ///
610    /// See [`GameStorage::post_room_action`]; all non-version-mismatch
611    /// errors propagate.
612    pub fn fetch_room_high_times(
613        &self,
614        room_id: &str,
615        player: &str,
616    ) -> Result<Option<(String, i64)>, HybridStorageError> {
617        let result = self.post_room_action::<RoomHighTimesEnvelope>(
618            "room_high_times",
619            serde_json::json!({ "id": room_id, "player": player }),
620        );
621        room_high_times_from_result(result)
622    }
623
624    /// Ask the server for the CONTENT HASH of the current high-times file
625    /// for the selected track's room and `player`
626    /// (`room_high_times_version`): the sha-256 hex digest of the exact
627    /// text [`GameStorage::fetch_room_high_times`] would return right now.
628    /// The cheap half of the CAS copy rule, hash the local file, and only
629    /// download the body when the digests differ.
630    ///
631    /// Returns `Ok(Some(version))` on success and `Ok(None)` when the server
632    /// predates the action (version-mismatch signature), the caller then
633    /// degrades to fetching the body and byte-comparing it against the local
634    /// file, which avoids spurious writes on old servers too.
635    ///
636    /// # Errors
637    ///
638    /// See [`GameStorage::post_room_action`]; all non-version-mismatch
639    /// errors propagate.
640    pub fn fetch_room_high_times_version(
641        &self,
642        room_id: &str,
643        player: &str,
644    ) -> Result<Option<String>, HybridStorageError> {
645        let result = self.post_room_action::<RoomHighTimesVersionEnvelope>(
646            "room_high_times_version",
647            serde_json::json!({ "id": room_id, "player": player }),
648        );
649        room_high_times_version_from_result(result)
650    }
651
652    /// Upload a tournament record for `player` (`room_upload_record`).
653    ///
654    /// `time_ticks` is the race time in the game's 10 ms replay ticks. The
655    /// `.dat` at `dat_path` is first pushed into the server CAS through the
656    /// existing unified chunked upload, then claimed with
657    /// `room_upload_record {id, player, time_ticks, hash, splits_ms?}`.
658    /// `splits_ms` (whole-millisecond `[checkpoint1_ms, checkpoint2_ms]`)
659    /// lets the server render the room's high-times entries with real
660    /// split times; `None` uploads the record without them (the server
661    /// then falls back to finish-only splits).
662    ///
663    /// `accepted: false` (worse or equal than the player's existing
664    /// personal best) is a successful [`RoomUploadResult`], not an error.
665    ///
666    /// # Errors
667    ///
668    /// See [`GameStorage::post_room_action`]; also fails when `time_ticks`
669    /// is 0 (the server would reject it and the blob upload would be
670    /// wasted) or when reading `dat_path` fails.
671    pub fn upload_room_record(
672        &self,
673        room_id: &str,
674        player: &str,
675        time_ticks: u32,
676        dat_path: &Path,
677        splits_ms: Option<[i64; 2]>,
678    ) -> Result<RoomUploadResult, HybridStorageError> {
679        if time_ticks == 0 {
680            return Err(HybridStorageError::UploadFailed(
681                "time_ticks must be > 0".to_string(),
682            ));
683        }
684
685        let data = std::fs::read(dat_path)?;
686        let hash = {
687            let mut hasher = Sha256::new();
688            hasher.update(&data);
689            hex::encode(hasher.finalize())
690        };
691
692        self.upload_record_blob(&hash, &data)?;
693
694        let mut payload = serde_json::json!({
695            "id": room_id,
696            "player": player,
697            "time_ticks": time_ticks,
698            "hash": hash,
699        });
700        if let Some(splits) = splits_ms {
701            payload["splits_ms"] = serde_json::json!(splits);
702        }
703        let envelope: RoomUploadEnvelope = self.post_room_action("room_upload_record", payload)?;
704
705        Ok(RoomUploadResult {
706            accepted: envelope.accepted,
707            personal_best: envelope.personal_best,
708            rank: envelope.rank.unwrap_or(0),
709        })
710    }
711
712    /// Push the raw record `.dat` into the server CAS via the existing
713    /// upload path.
714    ///
715    /// Stages a minimal manifest referencing the blob (the backend only
716    /// accepts content uploads referenced by a pending staging manifest),
717    /// then reuses [`GameStorage::upload_file_unified`] with
718    /// `file_type = "content"`, the exact call
719    /// [`crate::game_storage::GameStorage::upload_content_file`] makes, so
720    /// chunking, hashing and error handling are identical to every other
721    /// CAS upload. The staged manifest is never committed (see module docs).
722    ///
723    /// # Errors
724    ///
725    /// Staging or content upload failures surface as
726    /// [`HybridStorageError::UploadFailed`].
727    fn upload_record_blob(&self, hash: &str, data: &[u8]) -> Result<(), HybridStorageError> {
728        use super::multipart::{MultipartForm, SendMultipart};
729
730        let client = self
731            .client
732            .as_ref()
733            .ok_or_else(|| io::Error::other("Online storage not configured"))?;
734        let api_url = self
735            .api_url
736            .as_ref()
737            .ok_or_else(|| io::Error::other("Online storage not configured"))?;
738
739        let manifest = serde_json::json!({
740            "name": format!("room_record_{hash}"),
741            "files": { "record.dat": hash },
742        });
743        let manifest_bytes = serde_json::to_vec(&manifest).map_err(json_parse_error)?;
744        let manifest_hash = {
745            let mut hasher = Sha256::new();
746            hasher.update(&manifest_bytes);
747            hex::encode(hasher.finalize())
748        };
749        let manifest_filename = format!("{manifest_hash}.json");
750
751        // Single-shot staging upload (no commit): identical to the
752        // non-chunked snapshot_file path the test harness exercises.
753        let mut form = MultipartForm::new().bytes(
754            "snapshot_file",
755            &manifest_filename,
756            "application/json",
757            &manifest_bytes,
758        );
759        if let Some(token) = &self.token {
760            form = form.text("api_token", token.clone());
761        }
762
763        let mut request = client.post(api_url);
764        if let Some(ref token) = self.token {
765            request = request.header("X-SSBZ-Token", token);
766        }
767        let response = request.send_multipart(form)?;
768        if !response.status().is_success() {
769            return Err(HybridStorageError::UploadFailed(
770                self.extract_error_message(response),
771            ));
772        }
773
774        // Existing unified chunked CAS upload (re-uploads are idempotent).
775        self.upload_file_unified("contents", data, "content", Some(hash), None)
776    }
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782
783    /// Rooms-API example JSON (snake_case wire format).
784    const ROOM_JSON: &str = r#"{
785        "id": "r_0123456789ab",
786        "env": "alpine",
787        "difficulty": "easy",
788        "host": "psycho sam",
789        "ends_at": 1760000000,
790        "created_at": 1759000000,
791        "players": ["psycho sam", "racer_x"],
792        "records": [
793            {"player": "racer_x", "time_ticks": 61230, "hash": "deadbeef01",
794             "size": 4096, "uploaded_at": 1759000100}
795        ]
796    }"#;
797
798    #[test]
799    fn room_dto_parses_snake_case_wire_format() {
800        let room: RoomDto = serde_json::from_str(ROOM_JSON).expect("parse RoomDto");
801        assert_eq!(room.id, "r_0123456789ab");
802        assert_eq!(room.env, "alpine");
803        assert_eq!(room.difficulty, "easy");
804        assert_eq!(room.host, "psycho sam");
805        assert_eq!(room.ends_at, 1_760_000_000);
806        assert_eq!(room.created_at, 1_759_000_000);
807        assert_eq!(room.players, vec!["psycho sam", "racer_x"]);
808        assert_eq!(room.records.len(), 1);
809        assert_eq!(room.records[0].player, "racer_x");
810        assert_eq!(room.records[0].time_ticks, 61_230);
811        assert_eq!(room.records[0].hash, "deadbeef01");
812        assert_eq!(room.records[0].size, 4_096);
813        assert_eq!(room.records[0].uploaded_at, 1_759_000_100);
814    }
815
816    #[test]
817    fn room_dto_serializes_snake_case_field_names() {
818        let room: RoomDto = serde_json::from_str(ROOM_JSON).expect("parse RoomDto");
819        let value = serde_json::to_value(&room).expect("serialize RoomDto");
820        let obj = value.as_object().expect("object");
821        for key in ["id", "env", "difficulty", "host", "ends_at", "created_at"] {
822            assert!(obj.contains_key(key), "missing wire field {key}");
823        }
824        assert!(!obj.contains_key("endsAt"), "no camelCase on the wire");
825        let record = &obj["records"][0];
826        for key in ["player", "time_ticks", "hash", "size", "uploaded_at"] {
827            assert!(record.get(key).is_some(), "record missing wire field {key}");
828        }
829        assert!(record.get("timeTicks").is_none());
830    }
831
832    #[test]
833    fn room_summary_parses_list_entry() {
834        let summary: RoomSummaryDto = serde_json::from_str(
835            r#"{"id":"r_aa11bb22cc33","env":"forest","difficulty":"hard",
836                "host":"host_dude","ends_at":1760000000,
837                "player_count":3,"record_count":5}"#,
838        )
839        .expect("parse RoomSummaryDto");
840        assert_eq!(summary.id, "r_aa11bb22cc33");
841        assert_eq!(summary.player_count, 3);
842        assert_eq!(summary.record_count, 5);
843        // Servers predating the field (no results_url on the wire) still
844        // parse; the launcher hides its results icon for them.
845        assert_eq!(summary.results_url, None);
846    }
847
848    #[test]
849    fn room_summary_parses_results_url_when_the_server_publishes_one() {
850        let summary: RoomSummaryDto = serde_json::from_str(
851            r#"{"id":"r_aa11bb22cc33","env":"forest","difficulty":"hard",
852                "host":"host_dude","ends_at":1760000000,
853                "player_count":3,"record_count":5,
854                "results_url":"https://ssbz.example/leaderboard.html?id=r_aa11bb22cc33"}"#,
855        )
856        .expect("parse RoomSummaryDto");
857        assert_eq!(
858            summary.results_url.as_deref(),
859            Some("https://ssbz.example/leaderboard.html?id=r_aa11bb22cc33")
860        );
861        // owner_user_id rides along when the server tracks ownership;
862        // servers predating it keep parsing as guest-hosted rooms.
863        let summary: RoomSummaryDto = serde_json::from_str(
864            r#"{"id":"r_aa11bb22cc33","env":"forest","difficulty":"hard",
865                "host":"host_dude","ends_at":1760000000,
866                "player_count":1,"record_count":0,"owner_user_id":7}"#,
867        )
868        .expect("parse RoomSummaryDto with owner");
869        assert_eq!(summary.owner_user_id, Some(7));
870    }
871
872    #[test]
873    fn room_remove_is_gated_on_start_time_and_other_players() {
874        let mut room = RoomSummaryDto {
875            id: "r_x".to_owned(),
876            name: "Fresh Powder".to_owned(),
877            env: "alpine".to_owned(),
878            difficulty: "easy".to_owned(),
879            host: "Xinfe".to_owned(),
880            starts_at: 1_000,
881            ends_at: 2_000,
882            player_count: 1,
883            record_count: 0,
884            results_url: None,
885            owner_user_id: None,
886        };
887        // Un-started, only the creator inside: removable.
888        assert!(room.is_removable(999));
889        // The boundary mirrors `is_live`: the second the room starts it
890        // is live, so that is exactly when it stops being removable.
891        assert!(!room.is_removable(1_000), "the start second is live");
892        assert!(!room.is_removable(1_001), "a started room is not removable");
893        // Someone joined: not removable even before the start.
894        room.player_count = 2;
895        assert!(!room.is_removable(999));
896        // starts_at == 0 (servers predating the field) counts as started.
897        room.starts_at = 0;
898        room.player_count = 1;
899        assert!(!room.is_removable(0), "an old-style room is never removable");
900    }
901
902    #[test]
903    fn room_remove_payload_carries_id_player_and_identity_fields() {
904        // Guest: exactly {id, player}, no identity keys (an old client's
905        // body shape).
906        let guest = room_remove_payload("r_ab12", "Xinfe", None);
907        assert_eq!(
908            guest,
909            serde_json::json!({"id": "r_ab12", "player": "Xinfe"})
910        );
911
912        // Logged in: the account identity rides along additively.
913        let identity = RoomIdentity {
914            user_id: 7,
915            auth_key: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
916        };
917        let owned = room_remove_payload("r_ab12", "Xinfe", Some(identity));
918        assert_eq!(
919            owned,
920            serde_json::json!({
921                "id": "r_ab12",
922                "player": "Xinfe",
923                "user_id": 7,
924                "auth_key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
925            })
926        );
927    }
928
929    #[test]
930    fn room_upload_envelope_parses_full_and_minimal_payloads() {
931        let full: RoomUploadEnvelope = serde_json::from_value(serde_json::json!({
932            "accepted": true, "personal_best": 61230, "rank": 1,
933            "room": serde_json::from_str::<serde_json::Value>(ROOM_JSON).unwrap()
934        }))
935        .expect("parse full upload envelope");
936        assert!(full.accepted);
937        assert_eq!(full.personal_best, Some(61_230));
938        assert_eq!(full.rank, Some(1));
939
940        // Server may omit rank/personal_best on a rejected upload; the
941        // trailing `room` payload is not part of RoomUploadResult.
942        let minimal: RoomUploadEnvelope = serde_json::from_value(serde_json::json!({
943            "accepted": false, "personal_best": 61000, "rank": 1, "room": null
944        }))
945        .expect("parse minimal upload envelope");
946        assert!(!minimal.accepted);
947        assert_eq!(minimal.personal_best, Some(61_000));
948
949        let bare: RoomUploadEnvelope =
950            serde_json::from_value(serde_json::json!({ "accepted": true }))
951                .expect("parse bare upload envelope");
952        assert_eq!(bare.rank, None);
953    }
954
955    #[test]
956    fn room_error_message_detects_d4_error_envelope() {
957        let value: serde_json::Value =
958            serde_json::from_str(r#"{"status":"error","message":"unknown action"}"#)
959                .expect("parse error envelope");
960        assert_eq!(
961            room_error_message(&value).as_deref(),
962            Some("unknown action")
963        );
964    }
965
966    #[test]
967    fn room_error_message_ignores_success_payloads() {
968        let success: serde_json::Value =
969            serde_json::from_str(r#"{"status":"success","rooms":[]}"#).expect("parse");
970        assert_eq!(room_error_message(&success), None);
971
972        let legacy: serde_json::Value =
973            serde_json::from_str(r#"{"error":"legacy error shape"}"#).expect("parse");
974        // Legacy envelope has no "status", non-2xx handling owns it.
975        assert_eq!(room_error_message(&legacy), None);
976
977        assert_eq!(room_error_message(&serde_json::json!({})), None);
978    }
979
980    #[test]
981    fn room_upload_result_maps_absent_rank_to_zero() {
982        let envelope: RoomUploadEnvelope =
983            serde_json::from_value(serde_json::json!({ "accepted": true }))
984                .expect("parse bare envelope");
985        let result = RoomUploadResult {
986            accepted: envelope.accepted,
987            personal_best: envelope.personal_best,
988            rank: envelope.rank.unwrap_or(0),
989        };
990        assert!(result.accepted);
991        assert_eq!(result.personal_best, None);
992        assert_eq!(result.rank, 0);
993    }
994
995    #[test]
996    fn room_record_dto_parses_with_and_without_splits_ms() {
997        let with: RoomRecordDto = serde_json::from_str(
998            r#"{"player":"racer_x","time_ticks":61230,"hash":"deadbeef01",
999                "size":4096,"uploaded_at":1759000100,
1000                "splits_ms":[20140,41500]}"#,
1001        )
1002        .expect("parse record with splits_ms");
1003        assert_eq!(with.splits_ms, Some([20_140, 41_500]));
1004
1005        // Older servers (and uploads without usable splits) omit the field.
1006        let without: RoomRecordDto = serde_json::from_str(
1007            r#"{"player":"racer_x","time_ticks":61230,"hash":"deadbeef01",
1008                "size":4096,"uploaded_at":1759000100}"#,
1009        )
1010        .expect("parse record without splits_ms");
1011        assert_eq!(without.splits_ms, None);
1012
1013        // The server may also store an explicit null.
1014        let null: RoomRecordDto = serde_json::from_str(
1015            r#"{"player":"r","time_ticks":1,"hash":"h","size":1,"uploaded_at":1,
1016                "splits_ms":null}"#,
1017        )
1018        .expect("parse record with null splits_ms");
1019        assert_eq!(null.splits_ms, None);
1020    }
1021
1022    #[test]
1023    fn room_high_times_version_mismatch_maps_to_none() {
1024        // A backend predating room_high_times answers "Unknown action" (400)
1025        // → ServerVersionMismatch → Ok(None) = "generate locally".
1026        let mismatch = HybridStorageError::ServerVersionMismatch(
1027            "Unknown action: room_high_times. (HTTP 400)".to_string(),
1028        );
1029        assert_eq!(
1030            room_high_times_from_result(Err(mismatch)).unwrap(),
1031            None,
1032            "version mismatch must degrade to local generation, not an error"
1033        );
1034
1035        // Real failures keep propagating.
1036        let failed = HybridStorageError::UploadFailed("Room not found: r_x (HTTP 404)".to_string());
1037        assert!(room_high_times_from_result(Err(failed)).is_err());
1038
1039        let envelope = RoomHighTimesEnvelope {
1040            high_times: "{\n}\n".to_string(),
1041            entries: 0,
1042        };
1043        assert_eq!(
1044            room_high_times_from_result(Ok(envelope)).unwrap(),
1045            Some(("{\n}\n".to_string(), 0))
1046        );
1047    }
1048
1049    #[test]
1050    fn room_high_times_version_probe_maps_all_three_outcomes() {
1051        // Success: the wire's sha-256 hex digest passes through untouched.
1052        let envelope = RoomHighTimesVersionEnvelope {
1053            version: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(),
1054        };
1055        assert_eq!(
1056            room_high_times_version_from_result(Ok(envelope))
1057                .unwrap()
1058                .as_deref(),
1059            Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
1060        );
1061
1062        // A backend predating room_high_times_version answers "Unknown
1063        // action" (400) → ServerVersionMismatch → Ok(None) = "fetch the
1064        // body and byte-compare instead".
1065        let mismatch = HybridStorageError::ServerVersionMismatch(
1066            "Unknown action: room_high_times_version. (HTTP 400)".to_string(),
1067        );
1068        assert_eq!(
1069            room_high_times_version_from_result(Err(mismatch)).unwrap(),
1070            None,
1071            "version mismatch must degrade to the body-compare path, not an error"
1072        );
1073
1074        // Real failures keep propagating (room gone, server broken, …).
1075        let failed = HybridStorageError::UploadFailed("Room not found: r_x (HTTP 404)".to_string());
1076        assert!(room_high_times_version_from_result(Err(failed)).is_err());
1077    }
1078
1079    // --- local fallback: DTOs → high-times entries -------------------
1080
1081    fn dto(
1082        player: &str,
1083        ticks: i64,
1084        hash: &str,
1085        uploaded_at: i64,
1086        splits: Option<[i64; 2]>,
1087    ) -> RoomRecordDto {
1088        RoomRecordDto {
1089            player: player.to_owned(),
1090            time_ticks: ticks,
1091            hash: hash.to_owned(),
1092            size: 4096,
1093            uploaded_at,
1094            splits_ms: splits,
1095        }
1096    }
1097
1098    #[test]
1099    fn high_times_entries_render_every_record_ascending_with_splits() {
1100        let dtos = vec![
1101            dto("slow", 90_000, "h3", 1_792_454_400, Some([30_000, 60_000])),
1102            dto("fast", 61_230, "h1", 1_792_454_400, Some([20_140, 41_500])),
1103            dto("mid", 75_000, "h2", 1_792_454_400, None),
1104        ];
1105        let entries = high_times_entries_from_records(&dtos);
1106        // ALL records, fastest first; nothing truncated.
1107        assert_eq!(entries.len(), 3);
1108        assert_eq!(entries[0].player, "fast");
1109        assert_eq!(entries[1].player, "mid");
1110        assert_eq!(entries[2].player, "slow");
1111        // Splits flow straight into the entry (whole milliseconds).
1112        // ticks are 10 ms units: 61230 ticks = 612300 ms finish.
1113        assert_eq!(entries[0].checkpoint1_ms, 20_140);
1114        assert_eq!(entries[0].checkpoint2_ms, 41_500);
1115        assert_eq!(entries[0].finish_ms, 612_300);
1116        // The per-entry date column is the record's upload date (UTC).
1117        assert_eq!(entries[0].date, "2026/10/20");
1118        // finish-only record, the fallbacks space 10 ms below the
1119        // finish (75000 ticks = 750000 ms → cp2 = 749990, cp1 = 749980);
1120        // the old collapse emitted cp1 == cp2, which the game refuses.
1121        assert_eq!(entries[1].checkpoint1_ms, 749_980);
1122        assert_eq!(entries[1].checkpoint2_ms, 749_990);
1123        assert_eq!(entries[1].finish_ms, 750_000);
1124    }
1125
1126    #[test]
1127    fn high_times_entries_dedup_per_player_best_and_skip_invalid() {
1128        let dtos = vec![
1129            dto("racer", 90_000, "h-old", 1, None),
1130            dto("racer", 60_000, "h-new", 2, Some([20_000, 40_000])),
1131            dto("broken", 0, "h-bad", 3, None), // time_ticks <= 0: skipped
1132            dto("noname", 50_000, "", 4, None), // empty hash: skipped
1133        ];
1134        let entries = high_times_entries_from_records(&dtos);
1135        assert_eq!(entries.len(), 1);
1136        assert_eq!(entries[0].player, "racer");
1137        // Best (lowest ticks) wins: 60000 ticks = 600000 ms, real splits kept.
1138        assert_eq!(entries[0].finish_ms, 600_000);
1139        assert_eq!(entries[0].checkpoint1_ms, 20_000);
1140        assert_eq!(entries[0].checkpoint2_ms, 40_000);
1141    }
1142
1143    #[test]
1144    fn high_times_entries_empty_room_is_empty_vec() {
1145        let entries = high_times_entries_from_records(&[]);
1146        assert!(entries.is_empty());
1147    }
1148
1149    // --- non-2xx classification lives in super::util::api_http_error
1150    // (shared across API action clients; pure tests there) -------------------
1151}