1#![warn(missing_docs)]
16
17use std::collections::VecDeque;
18use crate::networking::http::{HttpClient, HttpRequest, HttpEvent, RequestId, Method};
19
20#[derive(Debug, Clone)]
24pub struct ScoreEntry {
25 pub rank: u32,
27 pub player_id: String,
29 pub name: String,
31 pub score: i64,
33 pub metadata: std::collections::HashMap<String, String>,
35 pub timestamp: String,
37 pub replay_url: Option<String>,
39}
40
41#[derive(Debug, Clone, Default)]
45pub struct LeaderboardFilter {
46 pub period: Option<String>,
48 pub class: Option<String>,
50 pub min_score: Option<i64>,
52 pub max_score: Option<i64>,
54 pub page: u32,
56 pub page_size: u32,
58}
59
60impl LeaderboardFilter {
61 pub fn new() -> Self { Self { page_size: 100, ..Default::default() } }
63 pub fn daily(mut self) -> Self { self.period = Some("daily".into()); self }
65 pub fn weekly(mut self) -> Self { self.period = Some("weekly".into()); self }
67 pub fn all_time(mut self) -> Self { self.period = Some("all_time".into()); self }
69 pub fn page(mut self, p: u32) -> Self { self.page = p; self }
71 pub fn page_size(mut self, n: u32) -> Self { self.page_size = n; self }
73}
74
75#[derive(Debug, Clone)]
79pub enum LeaderboardEvent {
80 ScoreSubmitted {
82 rank: u32,
84 score: i64,
86 },
87 ScoreRejected {
89 reason: String,
91 },
92 FetchSuccess {
94 entries: Vec<ScoreEntry>,
96 total: u32,
98 page: u32,
100 },
101 FetchFailed {
103 reason: String,
105 },
106 PlayerRank {
108 rank: u32,
110 entry: ScoreEntry,
112 },
113 RankNotFound {
115 player_id: String,
117 },
118}
119
120#[derive(Debug, Clone)]
124pub struct ScoreSubmission {
125 pub player_id: String,
127 pub name: String,
129 pub score: i64,
131 pub metadata: std::collections::HashMap<String, String>,
133 pub checksum: Option<String>,
135 pub replay_id: Option<String>,
137}
138
139impl ScoreSubmission {
140 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 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 pub fn with_replay(mut self, id: impl Into<String>) -> Self {
160 self.replay_id = Some(id.into());
161 self
162 }
163}
164
165pub 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 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 pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
197 self.api_key = Some(key.into());
198 self
199 }
200
201 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 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 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 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 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 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 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
380fn 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
408fn 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
429fn 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}