Skip to main content

proof_engine/networking/
leaderboard.rs

1//! Leaderboard protocol: submit scores, fetch boards, paginate results.
2//!
3//! Speaks JSON over [`HttpClient`] (needs the `http` feature for real
4//! network access). The expected server API:
5//!
6//! - `POST {base}/scores` with `{"player_id", "name", "score", "metadata",
7//!   "checksum"?, "replay_id"?}`, answered with `{"rank", "score"}`.
8//! - `GET {base}/scores?period=&class=&page=&page_size=` answered with either
9//!   a JSON array of entries or `{"entries": [...], "total": N}`.
10//! - `GET {base}/scores/{player_id}` answered with one entry.
11//!
12//! An entry is `{"rank", "player_id" (or "id"), "name", "score",
13//! "metadata"?, "timestamp"?, "replay_url"?}`; numbers may also be strings.
14
15#![warn(missing_docs)]
16
17use std::collections::VecDeque;
18use crate::networking::http::{HttpClient, HttpRequest, HttpEvent, RequestId, Method};
19
20// ── ScoreEntry ────────────────────────────────────────────────────────────────
21
22/// One row of a leaderboard.
23#[derive(Debug, Clone)]
24pub struct ScoreEntry {
25    /// Position on the board, 1 = best.
26    pub rank:      u32,
27    /// Stable player identifier.
28    pub player_id: String,
29    /// Display name.
30    pub name:      String,
31    /// The score.
32    pub score:     i64,
33    /// Arbitrary metadata: class, build, floor, kills, etc.
34    pub metadata:  std::collections::HashMap<String, String>,
35    /// ISO-8601 timestamp.
36    pub timestamp: String,
37    /// Optional replay URL.
38    pub replay_url: Option<String>,
39}
40
41// ── LeaderboardFilter ─────────────────────────────────────────────────────────
42
43/// Which part of the board to fetch.
44#[derive(Debug, Clone, Default)]
45pub struct LeaderboardFilter {
46    /// Time window: `"daily"`, `"weekly"` or `"all_time"`; sent as `period=`.
47    pub period:      Option<String>,
48    /// Only this character class; sent as `class=`.
49    pub class:       Option<String>,
50    /// Lowest score wanted; sent as `min_score=`.
51    pub min_score:   Option<i64>,
52    /// Highest score wanted; sent as `max_score=`.
53    pub max_score:   Option<i64>,
54    /// Page number, starting at 0.
55    pub page:        u32,
56    /// Entries per page.
57    pub page_size:   u32,
58}
59
60impl LeaderboardFilter {
61    /// First page, 100 entries, no period or class.
62    pub fn new() -> Self { Self { page_size: 100, ..Default::default() } }
63    /// Today's scores.
64    pub fn daily(mut self) -> Self { self.period = Some("daily".into()); self }
65    /// This week's scores.
66    pub fn weekly(mut self) -> Self { self.period = Some("weekly".into()); self }
67    /// All scores ever.
68    pub fn all_time(mut self) -> Self { self.period = Some("all_time".into()); self }
69    /// Set the page number.
70    pub fn page(mut self, p: u32) -> Self { self.page = p; self }
71    /// Set the entries per page.
72    pub fn page_size(mut self, n: u32) -> Self { self.page_size = n; self }
73}
74
75// ── LeaderboardEvent ──────────────────────────────────────────────────────────
76
77/// Results from `LeaderboardClient::drain_events`.
78#[derive(Debug, Clone)]
79pub enum LeaderboardEvent {
80    /// The server accepted a score.
81    ScoreSubmitted {
82        /// Rank the score reached.
83        rank: u32,
84        /// The score as recorded by the server.
85        score: i64,
86    },
87    /// A submission failed (HTTP error, network error or timeout).
88    ScoreRejected {
89        /// Server response text or error description.
90        reason: String,
91    },
92    /// A page of the board arrived.
93    FetchSuccess {
94        /// The entries on this page.
95        entries: Vec<ScoreEntry>,
96        /// Total entries on the board, if the server said; otherwise this page's count.
97        total: u32,
98        /// The page that was requested.
99        page: u32,
100    },
101    /// A fetch or rank lookup failed.
102    FetchFailed {
103        /// What went wrong.
104        reason: String,
105    },
106    /// A player's rank arrived.
107    PlayerRank {
108        /// The player's rank.
109        rank: u32,
110        /// The player's entry.
111        entry: ScoreEntry,
112    },
113    /// The server had no entry for this player.
114    RankNotFound {
115        /// The player that was looked up.
116        player_id: String,
117    },
118}
119
120// ── ScoreSubmission ───────────────────────────────────────────────────────────
121
122/// A score to send to the board.
123#[derive(Debug, Clone)]
124pub struct ScoreSubmission {
125    /// Stable player identifier.
126    pub player_id:   String,
127    /// Display name.
128    pub name:        String,
129    /// The score.
130    pub score:       i64,
131    /// Extra fields sent as a JSON object (class, floor, kills and so on).
132    pub metadata:    std::collections::HashMap<String, String>,
133    /// Optional anti-cheat checksum (SHA-256 of score + secret).
134    pub checksum:    Option<String>,
135    /// Optional replay data attachment.
136    pub replay_id:   Option<String>,
137}
138
139impl ScoreSubmission {
140    /// A submission with no metadata, checksum or replay.
141    pub fn new(player_id: impl Into<String>, name: impl Into<String>, score: i64) -> Self {
142        Self {
143            player_id: player_id.into(),
144            name:      name.into(),
145            score,
146            metadata:  std::collections::HashMap::new(),
147            checksum:  None,
148            replay_id: None,
149        }
150    }
151
152    /// Add a metadata field.
153    pub fn with_meta(mut self, key: impl Into<String>, val: impl Into<String>) -> Self {
154        self.metadata.insert(key.into(), val.into());
155        self
156    }
157
158    /// Attach a replay id.
159    pub fn with_replay(mut self, id: impl Into<String>) -> Self {
160        self.replay_id = Some(id.into());
161        self
162    }
163}
164
165// ── LeaderboardClient ────────────────────────────────────────────────────────
166
167/// High-level leaderboard API built on top of HttpClient.
168pub struct LeaderboardClient {
169    http:           HttpClient,
170    base_url:       String,
171    api_key:        Option<String>,
172    events:         VecDeque<LeaderboardEvent>,
173    pending:        Vec<(RequestId, LeaderboardOp)>,
174}
175
176#[derive(Debug, Clone)]
177enum LeaderboardOp {
178    Submit,
179    Fetch { page: u32 },
180    GetRank { player_id: String },
181}
182
183impl LeaderboardClient {
184    /// A client for the board at `base_url` (no trailing slash).
185    pub fn new(base_url: impl Into<String>) -> Self {
186        Self {
187            http:     HttpClient::new(),
188            base_url: base_url.into(),
189            api_key:  None,
190            events:   VecDeque::new(),
191            pending:  Vec::new(),
192        }
193    }
194
195    /// Send `X-API-Key: <key>` with every request.
196    pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
197        self.api_key = Some(key.into());
198        self
199    }
200
201    /// Submit a score.
202    pub fn submit(&mut self, submission: ScoreSubmission) {
203        let url  = format!("{}/scores", self.base_url);
204        let body = self.serialize_submission(&submission);
205        let mut req = HttpRequest::post_json(url, body);
206        if let Some(ref key) = self.api_key {
207            req = req.with_header("X-API-Key", key.clone());
208        }
209        let id = self.http.send(req);
210        self.pending.push((id, LeaderboardOp::Submit));
211    }
212
213    /// Fetch leaderboard entries.
214    pub fn fetch(&mut self, filter: LeaderboardFilter) {
215        let url = self.build_fetch_url(&filter);
216        let mut req = HttpRequest::get(url);
217        if let Some(ref key) = self.api_key {
218            req = req.with_header("X-API-Key", key.clone());
219        }
220        let page = filter.page;
221        let id   = self.http.send(req);
222        self.pending.push((id, LeaderboardOp::Fetch { page }));
223    }
224
225    /// Get a specific player's rank.
226    pub fn get_rank(&mut self, player_id: impl Into<String>) {
227        let pid = player_id.into();
228        let url = format!("{}/scores/{}", self.base_url, pid);
229        let mut req = HttpRequest::get(url);
230        if let Some(ref key) = self.api_key {
231            req = req.with_header("X-API-Key", key.clone());
232        }
233        let id = self.http.send(req);
234        self.pending.push((id, LeaderboardOp::GetRank { player_id: pid }));
235    }
236
237    /// Drive the client. Call once per frame.
238    pub fn tick(&mut self, dt: f32) {
239        self.http.tick(dt);
240
241        let http_events: Vec<HttpEvent> = self.http.drain_events().collect();
242        for event in http_events {
243            match event {
244                HttpEvent::Success { id, response } => {
245                    if let Some(pos) = self.pending.iter().position(|(rid, _)| *rid == id) {
246                        let (_, op) = self.pending.remove(pos);
247                        self.process_response(op, &response);
248                    }
249                }
250                HttpEvent::Failure { id, error, .. } => {
251                    if let Some(pos) = self.pending.iter().position(|(rid, _)| *rid == id) {
252                        let (_, op) = self.pending.remove(pos);
253                        match op {
254                            LeaderboardOp::Submit => {
255                                self.events.push_back(LeaderboardEvent::ScoreRejected {
256                                    reason: error.to_string(),
257                                });
258                            }
259                            LeaderboardOp::Fetch { .. } | LeaderboardOp::GetRank { .. } => {
260                                self.events.push_back(LeaderboardEvent::FetchFailed {
261                                    reason: error.to_string(),
262                                });
263                            }
264                        }
265                    }
266                }
267                HttpEvent::Timeout { id, url } => {
268                    self.fail(id, format!("timed out: {url}"));
269                }
270                HttpEvent::Cancelled { id } => {
271                    self.fail(id, "cancelled".into());
272                }
273                HttpEvent::RateLimited { .. } => {}
274            }
275        }
276    }
277
278    /// Resolve a pending operation as failed. Timeouts and cancellations used
279    /// to be ignored, so the caller never heard back.
280    fn fail(&mut self, id: RequestId, reason: String) {
281        if let Some(pos) = self.pending.iter().position(|(rid, _)| *rid == id) {
282            let (_, op) = self.pending.remove(pos);
283            self.events.push_back(match op {
284                LeaderboardOp::Submit => LeaderboardEvent::ScoreRejected { reason },
285                LeaderboardOp::Fetch { .. } | LeaderboardOp::GetRank { .. } => {
286                    LeaderboardEvent::FetchFailed { reason }
287                }
288            });
289        }
290    }
291
292    /// Take all events produced since the last call.
293    pub fn drain_events(&mut self) -> impl Iterator<Item = LeaderboardEvent> + '_ {
294        self.events.drain(..)
295    }
296
297    fn process_response(
298        &mut self,
299        op: LeaderboardOp,
300        response: &crate::networking::http::HttpResponse,
301    ) {
302        match op {
303            LeaderboardOp::Submit => {
304                if response.is_success() {
305                    let rank  = response.json_field("rank").and_then(|s| s.parse().ok()).unwrap_or(0);
306                    let score = response.json_field("score").and_then(|s| s.parse().ok()).unwrap_or(0);
307                    self.events.push_back(LeaderboardEvent::ScoreSubmitted { rank, score });
308                } else {
309                    self.events.push_back(LeaderboardEvent::ScoreRejected {
310                        reason: response.text_body().chars().take(200).collect(),
311                    });
312                }
313            }
314            LeaderboardOp::Fetch { page } => {
315                if !response.is_success() {
316                    self.events.push_back(LeaderboardEvent::FetchFailed {
317                        reason: format!("HTTP {}: {}", response.status,
318                            response.text_body().chars().take(200).collect::<String>()),
319                    });
320                    return;
321                }
322                match response.json().map_err(|e| e.to_string()).and_then(|v| parse_board(&v)) {
323                    Ok((entries, total)) => self.events.push_back(LeaderboardEvent::FetchSuccess {
324                        entries,
325                        total,
326                        page,
327                    }),
328                    Err(reason) => self.events.push_back(LeaderboardEvent::FetchFailed { reason }),
329                }
330            }
331            LeaderboardOp::GetRank { player_id } => {
332                let parsed = response.json().ok().and_then(|v| parse_entry(&v));
333                if let (true, Some(mut entry)) = (response.is_success(), parsed) {
334                    if entry.player_id.is_empty() {
335                        entry.player_id = player_id.clone();
336                    }
337                    self.events.push_back(LeaderboardEvent::PlayerRank { rank: entry.rank, entry });
338                } else {
339                    self.events.push_back(LeaderboardEvent::RankNotFound { player_id });
340                }
341            }
342        }
343    }
344
345    /// JSON body for a submission. Built with serde_json, so names with
346    /// quotes or backslashes stay valid JSON (the old `format!` did not
347    /// escape them), and `checksum` / `replay_id` are actually sent.
348    fn serialize_submission(&self, s: &ScoreSubmission) -> String {
349        let mut body = serde_json::json!({
350            "player_id": s.player_id,
351            "name": s.name,
352            "score": s.score,
353            "metadata": s.metadata,
354        });
355        if let Some(c) = &s.checksum {
356            body["checksum"] = serde_json::Value::String(c.clone());
357        }
358        if let Some(r) = &s.replay_id {
359            body["replay_id"] = serde_json::Value::String(r.clone());
360        }
361        body.to_string()
362    }
363
364    fn build_fetch_url(&self, f: &LeaderboardFilter) -> String {
365        let mut params = Vec::new();
366        if let Some(ref p) = f.period   { params.push(format!("period={}", url_encode(p))); }
367        if let Some(ref c) = f.class    { params.push(format!("class={}", url_encode(c))); }
368        if let Some(v) = f.min_score    { params.push(format!("min_score={v}")); }
369        if let Some(v) = f.max_score    { params.push(format!("max_score={v}")); }
370        params.push(format!("page={}", f.page));
371        params.push(format!("page_size={}", f.page_size));
372        if params.is_empty() {
373            format!("{}/scores", self.base_url)
374        } else {
375            format!("{}/scores?{}", self.base_url, params.join("&"))
376        }
377    }
378}
379
380// ── JSON parsing ──────────────────────────────────────────────────────────────
381
382/// Percent-encode a query value (RFC 3986 unreserved characters pass through).
383fn url_encode(s: &str) -> String {
384    let mut out = String::with_capacity(s.len());
385    for b in s.bytes() {
386        match b {
387            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
388            _ => out.push_str(&format!("%{b:02X}")),
389        }
390    }
391    out
392}
393
394fn as_i64(v: &serde_json::Value) -> Option<i64> {
395    v.as_i64()
396        .or_else(|| v.as_f64().map(|f| f as i64))
397        .or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))
398}
399
400fn as_text(v: &serde_json::Value) -> String {
401    match v {
402        serde_json::Value::String(s) => s.clone(),
403        serde_json::Value::Null => String::new(),
404        other => other.to_string(),
405    }
406}
407
408/// One leaderboard entry from a JSON object. `None` if it is not an object
409/// or has no usable score.
410fn parse_entry(v: &serde_json::Value) -> Option<ScoreEntry> {
411    let o = v.as_object()?;
412    let score = o.get("score").and_then(as_i64)?;
413    let metadata = o
414        .get("metadata")
415        .and_then(|m| m.as_object())
416        .map(|m| m.iter().map(|(k, v)| (k.clone(), as_text(v))).collect())
417        .unwrap_or_default();
418    Some(ScoreEntry {
419        rank: o.get("rank").and_then(as_i64).unwrap_or(0).clamp(0, u32::MAX as i64) as u32,
420        player_id: o.get("player_id").or_else(|| o.get("id")).map(as_text).unwrap_or_default(),
421        name: o.get("name").map(as_text).unwrap_or_default(),
422        score,
423        metadata,
424        timestamp: o.get("timestamp").map(as_text).unwrap_or_default(),
425        replay_url: o.get("replay_url").map(as_text).filter(|s| !s.is_empty()),
426    })
427}
428
429/// A page of entries: a bare array, or `{"entries": [...], "total": N}`.
430/// Missing ranks are filled from the position on the page.
431fn parse_board(v: &serde_json::Value) -> Result<(Vec<ScoreEntry>, u32), String> {
432    let (list, total) = match v {
433        serde_json::Value::Array(a) => (a, None),
434        serde_json::Value::Object(o) => (
435            o.get("entries")
436                .and_then(|e| e.as_array())
437                .ok_or("expected an \"entries\" array")?,
438            o.get("total").and_then(as_i64),
439        ),
440        _ => return Err("expected a JSON array or object".into()),
441    };
442    let mut entries: Vec<ScoreEntry> = list.iter().filter_map(parse_entry).collect();
443    for (i, e) in entries.iter_mut().enumerate() {
444        if e.rank == 0 {
445            e.rank = i as u32 + 1;
446        }
447    }
448    let total = total.map(|t| t.max(0) as u32).unwrap_or(entries.len() as u32);
449    Ok((entries, total))
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn parses_bare_array_and_wrapped_pages() {
458        let v: serde_json::Value = serde_json::from_str(
459            r#"[{"name":"Ada","player_id":"p1","score":"4200","rank":1,"seed":"123","ts":1},
460                {"name":"Bo","id":"p2","score":900,"metadata":{"class":"Rogue","floor":3}}]"#,
461        ).unwrap();
462        let (entries, total) = parse_board(&v).unwrap();
463        assert_eq!(total, 2);
464        assert_eq!(entries[0].score, 4200);
465        assert_eq!(entries[1].player_id, "p2");
466        assert_eq!(entries[1].rank, 2);
467        assert_eq!(entries[1].metadata["floor"], "3");
468
469        let v: serde_json::Value =
470            serde_json::from_str(r#"{"entries":[{"name":"C","score":5}],"total":77}"#).unwrap();
471        let (entries, total) = parse_board(&v).unwrap();
472        assert_eq!((entries.len(), total), (1, 77));
473        assert!(parse_board(&serde_json::json!("nope")).is_err());
474    }
475
476    #[test]
477    fn submission_json_is_escaped_and_complete() {
478        let client = LeaderboardClient::new("http://x");
479        let mut s = ScoreSubmission::new("p\"1", "Name \"with\" quotes\\", 10).with_replay("r9");
480        s.checksum = Some("abc".into());
481        let v: serde_json::Value = serde_json::from_str(&client.serialize_submission(&s)).unwrap();
482        assert_eq!(v["name"], "Name \"with\" quotes\\");
483        assert_eq!(v["player_id"], "p\"1");
484        assert_eq!(v["replay_id"], "r9");
485        assert_eq!(v["checksum"], "abc");
486    }
487
488    #[test]
489    fn query_values_are_encoded() {
490        let client = LeaderboardClient::new("http://x");
491        let url = client.build_fetch_url(&LeaderboardFilter::new().daily());
492        assert!(url.ends_with("/scores?period=daily&page=0&page_size=100"), "{url}");
493        let mut f = LeaderboardFilter::new();
494        f.class = Some("Void Walker&x=1".into());
495        assert!(client.build_fetch_url(&f).contains("class=Void%20Walker%26x%3D1"));
496        f.min_score = Some(100);
497        f.max_score = Some(-5);
498        let url = client.build_fetch_url(&f);
499        assert!(url.contains("min_score=100") && url.contains("max_score=-5"), "{url}");
500    }
501}