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}