1use std::collections::VecDeque;
4use crate::networking::http::{HttpClient, HttpRequest, HttpEvent, RequestId, Method};
5
6#[derive(Debug, Clone)]
9pub struct ScoreEntry {
10 pub rank: u32,
11 pub player_id: String,
12 pub name: String,
13 pub score: i64,
14 pub metadata: std::collections::HashMap<String, String>,
16 pub timestamp: String,
18 pub replay_url: Option<String>,
20}
21
22#[derive(Debug, Clone, Default)]
25pub struct LeaderboardFilter {
26 pub period: Option<String>, pub class: Option<String>,
28 pub min_score: Option<i64>,
29 pub max_score: Option<i64>,
30 pub page: u32,
31 pub page_size: u32,
32}
33
34impl LeaderboardFilter {
35 pub fn new() -> Self { Self { page_size: 100, ..Default::default() } }
36 pub fn daily(mut self) -> Self { self.period = Some("daily".into()); self }
37 pub fn weekly(mut self) -> Self { self.period = Some("weekly".into()); self }
38 pub fn all_time(mut self) -> Self { self.period = Some("all_time".into()); self }
39 pub fn page(mut self, p: u32) -> Self { self.page = p; self }
40 pub fn page_size(mut self, n: u32) -> Self { self.page_size = n; self }
41}
42
43#[derive(Debug, Clone)]
46pub enum LeaderboardEvent {
47 ScoreSubmitted { rank: u32, score: i64 },
48 ScoreRejected { reason: String },
49 FetchSuccess { entries: Vec<ScoreEntry>, total: u32, page: u32 },
50 FetchFailed { reason: String },
51 PlayerRank { rank: u32, entry: ScoreEntry },
52 RankNotFound { player_id: String },
53}
54
55#[derive(Debug, Clone)]
58pub struct ScoreSubmission {
59 pub player_id: String,
60 pub name: String,
61 pub score: i64,
62 pub metadata: std::collections::HashMap<String, String>,
63 pub checksum: Option<String>,
65 pub replay_id: Option<String>,
67}
68
69impl ScoreSubmission {
70 pub fn new(player_id: impl Into<String>, name: impl Into<String>, score: i64) -> Self {
71 Self {
72 player_id: player_id.into(),
73 name: name.into(),
74 score,
75 metadata: std::collections::HashMap::new(),
76 checksum: None,
77 replay_id: None,
78 }
79 }
80
81 pub fn with_meta(mut self, key: impl Into<String>, val: impl Into<String>) -> Self {
82 self.metadata.insert(key.into(), val.into());
83 self
84 }
85
86 pub fn with_replay(mut self, id: impl Into<String>) -> Self {
87 self.replay_id = Some(id.into());
88 self
89 }
90}
91
92pub struct LeaderboardClient {
96 http: HttpClient,
97 base_url: String,
98 api_key: Option<String>,
99 events: VecDeque<LeaderboardEvent>,
100 pending: Vec<(RequestId, LeaderboardOp)>,
101}
102
103#[derive(Debug, Clone)]
104enum LeaderboardOp {
105 Submit,
106 Fetch { page: u32 },
107 GetRank { player_id: String },
108}
109
110impl LeaderboardClient {
111 pub fn new(base_url: impl Into<String>) -> Self {
112 Self {
113 http: HttpClient::new(),
114 base_url: base_url.into(),
115 api_key: None,
116 events: VecDeque::new(),
117 pending: Vec::new(),
118 }
119 }
120
121 pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
122 self.api_key = Some(key.into());
123 self
124 }
125
126 pub fn submit(&mut self, submission: ScoreSubmission) {
128 let url = format!("{}/scores", self.base_url);
129 let body = self.serialize_submission(&submission);
130 let mut req = HttpRequest::post_json(url, body);
131 if let Some(ref key) = self.api_key {
132 req = req.with_header("X-API-Key", key.clone());
133 }
134 let id = self.http.send(req);
135 self.pending.push((id, LeaderboardOp::Submit));
136 }
137
138 pub fn fetch(&mut self, filter: LeaderboardFilter) {
140 let url = self.build_fetch_url(&filter);
141 let mut req = HttpRequest::get(url);
142 if let Some(ref key) = self.api_key {
143 req = req.with_header("X-API-Key", key.clone());
144 }
145 let page = filter.page;
146 let id = self.http.send(req);
147 self.pending.push((id, LeaderboardOp::Fetch { page }));
148 }
149
150 pub fn get_rank(&mut self, player_id: impl Into<String>) {
152 let pid = player_id.into();
153 let url = format!("{}/scores/{}", self.base_url, pid);
154 let mut req = HttpRequest::get(url);
155 if let Some(ref key) = self.api_key {
156 req = req.with_header("X-API-Key", key.clone());
157 }
158 let id = self.http.send(req);
159 self.pending.push((id, LeaderboardOp::GetRank { player_id: pid }));
160 }
161
162 pub fn tick(&mut self, dt: f32) {
164 self.http.tick(dt);
165
166 let http_events: Vec<HttpEvent> = self.http.drain_events().collect();
167 for event in http_events {
168 match event {
169 HttpEvent::Success { id, response } => {
170 if let Some(pos) = self.pending.iter().position(|(rid, _)| *rid == id) {
171 let (_, op) = self.pending.remove(pos);
172 self.process_response(op, &response);
173 }
174 }
175 HttpEvent::Failure { id, error, .. } => {
176 if let Some(pos) = self.pending.iter().position(|(rid, _)| *rid == id) {
177 let (_, op) = self.pending.remove(pos);
178 match op {
179 LeaderboardOp::Submit => {
180 self.events.push_back(LeaderboardEvent::ScoreRejected {
181 reason: error.to_string(),
182 });
183 }
184 LeaderboardOp::Fetch { .. } | LeaderboardOp::GetRank { .. } => {
185 self.events.push_back(LeaderboardEvent::FetchFailed {
186 reason: error.to_string(),
187 });
188 }
189 }
190 }
191 }
192 _ => {}
193 }
194 }
195 }
196
197 pub fn drain_events(&mut self) -> impl Iterator<Item = LeaderboardEvent> + '_ {
198 self.events.drain(..)
199 }
200
201 fn process_response(
202 &mut self,
203 op: LeaderboardOp,
204 response: &crate::networking::http::HttpResponse,
205 ) {
206 match op {
207 LeaderboardOp::Submit => {
208 if response.is_success() {
209 let rank = response.json_field("rank").and_then(|s| s.parse().ok()).unwrap_or(0);
210 let score = response.json_field("score").and_then(|s| s.parse().ok()).unwrap_or(0);
211 self.events.push_back(LeaderboardEvent::ScoreSubmitted { rank, score });
212 } else {
213 self.events.push_back(LeaderboardEvent::ScoreRejected {
214 reason: response.text_body().chars().take(200).collect(),
215 });
216 }
217 }
218 LeaderboardOp::Fetch { page } => {
219 self.events.push_back(LeaderboardEvent::FetchSuccess {
221 entries: Vec::new(),
222 total: 0,
223 page,
224 });
225 }
226 LeaderboardOp::GetRank { player_id } => {
227 if response.is_success() {
228 let rank = response.json_field("rank").and_then(|s| s.parse().ok()).unwrap_or(0);
229 let score_val = response.json_field("score").and_then(|s| s.parse().ok()).unwrap_or(0);
230 self.events.push_back(LeaderboardEvent::PlayerRank {
231 rank,
232 entry: ScoreEntry {
233 rank,
234 player_id: player_id.clone(),
235 name: response.json_field("name").unwrap_or_default(),
236 score: score_val,
237 metadata: std::collections::HashMap::new(),
238 timestamp: response.json_field("timestamp").unwrap_or_default(),
239 replay_url: response.json_field("replay_url"),
240 },
241 });
242 } else {
243 self.events.push_back(LeaderboardEvent::RankNotFound { player_id });
244 }
245 }
246 }
247 }
248
249 fn serialize_submission(&self, s: &ScoreSubmission) -> String {
250 let meta_pairs: String = s.metadata.iter()
251 .map(|(k, v)| format!("\"{}\":\"{}\"", k, v))
252 .collect::<Vec<_>>()
253 .join(",");
254 format!(
255 r#"{{"player_id":"{}","name":"{}","score":{},"metadata":{{{}}}}}"#,
256 s.player_id, s.name, s.score, meta_pairs
257 )
258 }
259
260 fn build_fetch_url(&self, f: &LeaderboardFilter) -> String {
261 let mut params = Vec::new();
262 if let Some(ref p) = f.period { params.push(format!("period={}", p)); }
263 if let Some(ref c) = f.class { params.push(format!("class={}", c)); }
264 params.push(format!("page={}", f.page));
265 params.push(format!("page_size={}", f.page_size));
266 if params.is_empty() {
267 format!("{}/scores", self.base_url)
268 } else {
269 format!("{}/scores?{}", self.base_url, params.join("&"))
270 }
271 }
272}