#![warn(missing_docs)]
use std::collections::VecDeque;
use crate::networking::http::{HttpClient, HttpRequest, HttpEvent, RequestId, Method};
#[derive(Debug, Clone)]
pub struct ScoreEntry {
pub rank: u32,
pub player_id: String,
pub name: String,
pub score: i64,
pub metadata: std::collections::HashMap<String, String>,
pub timestamp: String,
pub replay_url: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct LeaderboardFilter {
pub period: Option<String>,
pub class: Option<String>,
pub min_score: Option<i64>,
pub max_score: Option<i64>,
pub page: u32,
pub page_size: u32,
}
impl LeaderboardFilter {
pub fn new() -> Self { Self { page_size: 100, ..Default::default() } }
pub fn daily(mut self) -> Self { self.period = Some("daily".into()); self }
pub fn weekly(mut self) -> Self { self.period = Some("weekly".into()); self }
pub fn all_time(mut self) -> Self { self.period = Some("all_time".into()); self }
pub fn page(mut self, p: u32) -> Self { self.page = p; self }
pub fn page_size(mut self, n: u32) -> Self { self.page_size = n; self }
}
#[derive(Debug, Clone)]
pub enum LeaderboardEvent {
ScoreSubmitted {
rank: u32,
score: i64,
},
ScoreRejected {
reason: String,
},
FetchSuccess {
entries: Vec<ScoreEntry>,
total: u32,
page: u32,
},
FetchFailed {
reason: String,
},
PlayerRank {
rank: u32,
entry: ScoreEntry,
},
RankNotFound {
player_id: String,
},
}
#[derive(Debug, Clone)]
pub struct ScoreSubmission {
pub player_id: String,
pub name: String,
pub score: i64,
pub metadata: std::collections::HashMap<String, String>,
pub checksum: Option<String>,
pub replay_id: Option<String>,
}
impl ScoreSubmission {
pub fn new(player_id: impl Into<String>, name: impl Into<String>, score: i64) -> Self {
Self {
player_id: player_id.into(),
name: name.into(),
score,
metadata: std::collections::HashMap::new(),
checksum: None,
replay_id: None,
}
}
pub fn with_meta(mut self, key: impl Into<String>, val: impl Into<String>) -> Self {
self.metadata.insert(key.into(), val.into());
self
}
pub fn with_replay(mut self, id: impl Into<String>) -> Self {
self.replay_id = Some(id.into());
self
}
}
pub struct LeaderboardClient {
http: HttpClient,
base_url: String,
api_key: Option<String>,
events: VecDeque<LeaderboardEvent>,
pending: Vec<(RequestId, LeaderboardOp)>,
}
#[derive(Debug, Clone)]
enum LeaderboardOp {
Submit,
Fetch { page: u32 },
GetRank { player_id: String },
}
impl LeaderboardClient {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
http: HttpClient::new(),
base_url: base_url.into(),
api_key: None,
events: VecDeque::new(),
pending: Vec::new(),
}
}
pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
self.api_key = Some(key.into());
self
}
pub fn submit(&mut self, submission: ScoreSubmission) {
let url = format!("{}/scores", self.base_url);
let body = self.serialize_submission(&submission);
let mut req = HttpRequest::post_json(url, body);
if let Some(ref key) = self.api_key {
req = req.with_header("X-API-Key", key.clone());
}
let id = self.http.send(req);
self.pending.push((id, LeaderboardOp::Submit));
}
pub fn fetch(&mut self, filter: LeaderboardFilter) {
let url = self.build_fetch_url(&filter);
let mut req = HttpRequest::get(url);
if let Some(ref key) = self.api_key {
req = req.with_header("X-API-Key", key.clone());
}
let page = filter.page;
let id = self.http.send(req);
self.pending.push((id, LeaderboardOp::Fetch { page }));
}
pub fn get_rank(&mut self, player_id: impl Into<String>) {
let pid = player_id.into();
let url = format!("{}/scores/{}", self.base_url, pid);
let mut req = HttpRequest::get(url);
if let Some(ref key) = self.api_key {
req = req.with_header("X-API-Key", key.clone());
}
let id = self.http.send(req);
self.pending.push((id, LeaderboardOp::GetRank { player_id: pid }));
}
pub fn tick(&mut self, dt: f32) {
self.http.tick(dt);
let http_events: Vec<HttpEvent> = self.http.drain_events().collect();
for event in http_events {
match event {
HttpEvent::Success { id, response } => {
if let Some(pos) = self.pending.iter().position(|(rid, _)| *rid == id) {
let (_, op) = self.pending.remove(pos);
self.process_response(op, &response);
}
}
HttpEvent::Failure { id, error, .. } => {
if let Some(pos) = self.pending.iter().position(|(rid, _)| *rid == id) {
let (_, op) = self.pending.remove(pos);
match op {
LeaderboardOp::Submit => {
self.events.push_back(LeaderboardEvent::ScoreRejected {
reason: error.to_string(),
});
}
LeaderboardOp::Fetch { .. } | LeaderboardOp::GetRank { .. } => {
self.events.push_back(LeaderboardEvent::FetchFailed {
reason: error.to_string(),
});
}
}
}
}
HttpEvent::Timeout { id, url } => {
self.fail(id, format!("timed out: {url}"));
}
HttpEvent::Cancelled { id } => {
self.fail(id, "cancelled".into());
}
HttpEvent::RateLimited { .. } => {}
}
}
}
fn fail(&mut self, id: RequestId, reason: String) {
if let Some(pos) = self.pending.iter().position(|(rid, _)| *rid == id) {
let (_, op) = self.pending.remove(pos);
self.events.push_back(match op {
LeaderboardOp::Submit => LeaderboardEvent::ScoreRejected { reason },
LeaderboardOp::Fetch { .. } | LeaderboardOp::GetRank { .. } => {
LeaderboardEvent::FetchFailed { reason }
}
});
}
}
pub fn drain_events(&mut self) -> impl Iterator<Item = LeaderboardEvent> + '_ {
self.events.drain(..)
}
fn process_response(
&mut self,
op: LeaderboardOp,
response: &crate::networking::http::HttpResponse,
) {
match op {
LeaderboardOp::Submit => {
if response.is_success() {
let rank = response.json_field("rank").and_then(|s| s.parse().ok()).unwrap_or(0);
let score = response.json_field("score").and_then(|s| s.parse().ok()).unwrap_or(0);
self.events.push_back(LeaderboardEvent::ScoreSubmitted { rank, score });
} else {
self.events.push_back(LeaderboardEvent::ScoreRejected {
reason: response.text_body().chars().take(200).collect(),
});
}
}
LeaderboardOp::Fetch { page } => {
if !response.is_success() {
self.events.push_back(LeaderboardEvent::FetchFailed {
reason: format!("HTTP {}: {}", response.status,
response.text_body().chars().take(200).collect::<String>()),
});
return;
}
match response.json().map_err(|e| e.to_string()).and_then(|v| parse_board(&v)) {
Ok((entries, total)) => self.events.push_back(LeaderboardEvent::FetchSuccess {
entries,
total,
page,
}),
Err(reason) => self.events.push_back(LeaderboardEvent::FetchFailed { reason }),
}
}
LeaderboardOp::GetRank { player_id } => {
let parsed = response.json().ok().and_then(|v| parse_entry(&v));
if let (true, Some(mut entry)) = (response.is_success(), parsed) {
if entry.player_id.is_empty() {
entry.player_id = player_id.clone();
}
self.events.push_back(LeaderboardEvent::PlayerRank { rank: entry.rank, entry });
} else {
self.events.push_back(LeaderboardEvent::RankNotFound { player_id });
}
}
}
}
fn serialize_submission(&self, s: &ScoreSubmission) -> String {
let mut body = serde_json::json!({
"player_id": s.player_id,
"name": s.name,
"score": s.score,
"metadata": s.metadata,
});
if let Some(c) = &s.checksum {
body["checksum"] = serde_json::Value::String(c.clone());
}
if let Some(r) = &s.replay_id {
body["replay_id"] = serde_json::Value::String(r.clone());
}
body.to_string()
}
fn build_fetch_url(&self, f: &LeaderboardFilter) -> String {
let mut params = Vec::new();
if let Some(ref p) = f.period { params.push(format!("period={}", url_encode(p))); }
if let Some(ref c) = f.class { params.push(format!("class={}", url_encode(c))); }
if let Some(v) = f.min_score { params.push(format!("min_score={v}")); }
if let Some(v) = f.max_score { params.push(format!("max_score={v}")); }
params.push(format!("page={}", f.page));
params.push(format!("page_size={}", f.page_size));
if params.is_empty() {
format!("{}/scores", self.base_url)
} else {
format!("{}/scores?{}", self.base_url, params.join("&"))
}
}
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn as_i64(v: &serde_json::Value) -> Option<i64> {
v.as_i64()
.or_else(|| v.as_f64().map(|f| f as i64))
.or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))
}
fn as_text(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null => String::new(),
other => other.to_string(),
}
}
fn parse_entry(v: &serde_json::Value) -> Option<ScoreEntry> {
let o = v.as_object()?;
let score = o.get("score").and_then(as_i64)?;
let metadata = o
.get("metadata")
.and_then(|m| m.as_object())
.map(|m| m.iter().map(|(k, v)| (k.clone(), as_text(v))).collect())
.unwrap_or_default();
Some(ScoreEntry {
rank: o.get("rank").and_then(as_i64).unwrap_or(0).clamp(0, u32::MAX as i64) as u32,
player_id: o.get("player_id").or_else(|| o.get("id")).map(as_text).unwrap_or_default(),
name: o.get("name").map(as_text).unwrap_or_default(),
score,
metadata,
timestamp: o.get("timestamp").map(as_text).unwrap_or_default(),
replay_url: o.get("replay_url").map(as_text).filter(|s| !s.is_empty()),
})
}
fn parse_board(v: &serde_json::Value) -> Result<(Vec<ScoreEntry>, u32), String> {
let (list, total) = match v {
serde_json::Value::Array(a) => (a, None),
serde_json::Value::Object(o) => (
o.get("entries")
.and_then(|e| e.as_array())
.ok_or("expected an \"entries\" array")?,
o.get("total").and_then(as_i64),
),
_ => return Err("expected a JSON array or object".into()),
};
let mut entries: Vec<ScoreEntry> = list.iter().filter_map(parse_entry).collect();
for (i, e) in entries.iter_mut().enumerate() {
if e.rank == 0 {
e.rank = i as u32 + 1;
}
}
let total = total.map(|t| t.max(0) as u32).unwrap_or(entries.len() as u32);
Ok((entries, total))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_bare_array_and_wrapped_pages() {
let v: serde_json::Value = serde_json::from_str(
r#"[{"name":"Ada","player_id":"p1","score":"4200","rank":1,"seed":"123","ts":1},
{"name":"Bo","id":"p2","score":900,"metadata":{"class":"Rogue","floor":3}}]"#,
).unwrap();
let (entries, total) = parse_board(&v).unwrap();
assert_eq!(total, 2);
assert_eq!(entries[0].score, 4200);
assert_eq!(entries[1].player_id, "p2");
assert_eq!(entries[1].rank, 2);
assert_eq!(entries[1].metadata["floor"], "3");
let v: serde_json::Value =
serde_json::from_str(r#"{"entries":[{"name":"C","score":5}],"total":77}"#).unwrap();
let (entries, total) = parse_board(&v).unwrap();
assert_eq!((entries.len(), total), (1, 77));
assert!(parse_board(&serde_json::json!("nope")).is_err());
}
#[test]
fn submission_json_is_escaped_and_complete() {
let client = LeaderboardClient::new("http://x");
let mut s = ScoreSubmission::new("p\"1", "Name \"with\" quotes\\", 10).with_replay("r9");
s.checksum = Some("abc".into());
let v: serde_json::Value = serde_json::from_str(&client.serialize_submission(&s)).unwrap();
assert_eq!(v["name"], "Name \"with\" quotes\\");
assert_eq!(v["player_id"], "p\"1");
assert_eq!(v["replay_id"], "r9");
assert_eq!(v["checksum"], "abc");
}
#[test]
fn query_values_are_encoded() {
let client = LeaderboardClient::new("http://x");
let url = client.build_fetch_url(&LeaderboardFilter::new().daily());
assert!(url.ends_with("/scores?period=daily&page=0&page_size=100"), "{url}");
let mut f = LeaderboardFilter::new();
f.class = Some("Void Walker&x=1".into());
assert!(client.build_fetch_url(&f).contains("class=Void%20Walker%26x%3D1"));
f.min_score = Some(100);
f.max_score = Some(-5);
let url = client.build_fetch_url(&f);
assert!(url.contains("min_score=100") && url.contains("max_score=-5"), "{url}");
}
}