Skip to main content

proof_engine/networking/
leaderboard.rs

1//! Leaderboard protocol: submit scores, fetch boards, paginate results.
2
3use std::collections::VecDeque;
4use crate::networking::http::{HttpClient, HttpRequest, HttpEvent, RequestId, Method};
5
6// ── ScoreEntry ────────────────────────────────────────────────────────────────
7
8#[derive(Debug, Clone)]
9pub struct ScoreEntry {
10    pub rank:      u32,
11    pub player_id: String,
12    pub name:      String,
13    pub score:     i64,
14    /// Arbitrary metadata: class, build, floor, kills, etc.
15    pub metadata:  std::collections::HashMap<String, String>,
16    /// ISO-8601 timestamp.
17    pub timestamp: String,
18    /// Optional replay URL.
19    pub replay_url: Option<String>,
20}
21
22// ── LeaderboardFilter ─────────────────────────────────────────────────────────
23
24#[derive(Debug, Clone, Default)]
25pub struct LeaderboardFilter {
26    pub period:      Option<String>,   // "daily", "weekly", "all_time"
27    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// ── LeaderboardEvent ──────────────────────────────────────────────────────────
44
45#[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// ── ScoreSubmission ───────────────────────────────────────────────────────────
56
57#[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    /// Optional anti-cheat checksum (SHA-256 of score + secret).
64    pub checksum:    Option<String>,
65    /// Optional replay data attachment.
66    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
92// ── LeaderboardClient ────────────────────────────────────────────────────────
93
94/// High-level leaderboard API built on top of HttpClient.
95pub 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    /// Submit a score.
127    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    /// Fetch leaderboard entries.
139    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    /// Get a specific player's rank.
151    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    /// Drive the client. Call once per frame.
163    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                // Stub: parse entries from JSON (in real impl: full serde)
220                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}