feat(engine): leaderboard screen (press L to toggle)

Press L to open a leaderboard overlay. On open, an async fetch is
dispatched against the active SyncProvider. Results cache in
LeaderboardResource; the panel rebuilds live when data arrives.
Closing during a fetch is safe (ClosedThisFrame flag prevents
re-spawning the panel in the same frame as the user's despawn command).
Format: ranked table with player name, best score, and fastest win time.
Non-authenticated / LocalOnly providers return an empty list gracefully.

- solitaire_data: add fetch_leaderboard() to SyncProvider trait
  (default → Ok([])) and implement in SolitaireServerClient
- solitaire_engine: new LeaderboardPlugin with 5 unit tests
- solitaire_app: register LeaderboardPlugin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-27 00:38:55 +00:00
parent a7b781cd36
commit d56abcd7a9
5 changed files with 497 additions and 5 deletions
+9 -1
View File
@@ -1,5 +1,5 @@
use async_trait::async_trait;
use solitaire_sync::{SyncPayload, SyncResponse};
use solitaire_sync::{LeaderboardEntry, SyncPayload, SyncResponse};
use thiserror::Error;
/// All errors that can arise during sync operations.
@@ -33,6 +33,11 @@ pub trait SyncProvider: Send + Sync {
async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError> {
Ok(())
}
/// Fetch the global leaderboard from this backend. Returns an empty list
/// for backends that do not support leaderboards (e.g. `LocalOnlyProvider`).
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
Ok(vec![])
}
}
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by
@@ -54,6 +59,9 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
async fn mirror_achievement(&self, id: &str) -> Result<(), SyncError> {
(**self).mirror_achievement(id).await
}
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
(**self).fetch_leaderboard().await
}
}
pub mod stats;
+41 -1
View File
@@ -12,7 +12,7 @@
//! without matching on [`SyncBackend`] anywhere else in the codebase.
use async_trait::async_trait;
use solitaire_sync::{SyncPayload, SyncResponse};
use solitaire_sync::{LeaderboardEntry, SyncPayload, SyncResponse};
use crate::{
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
@@ -199,6 +199,34 @@ impl SyncProvider for SolitaireServerClient {
fn is_authenticated(&self) -> bool {
load_access_token(&self.username).is_ok()
}
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
let token = self.access_token()?;
let url = format!("{}/api/leaderboard", self.base_url);
let resp = self
.client
.get(&url)
.bearer_auth(&token)
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
.get(&url)
.bearer_auth(new_token)
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
return extract_leaderboard_body(resp).await;
}
extract_leaderboard_body(resp).await
}
}
// ---------------------------------------------------------------------------
@@ -220,6 +248,18 @@ async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncE
}
}
/// Deserialize a leaderboard response body as `Vec<LeaderboardEntry>`.
async fn extract_leaderboard_body(resp: reqwest::Response) -> Result<Vec<LeaderboardEntry>, SyncError> {
let status = resp.status();
if status.is_success() {
resp.json()
.await
.map_err(|e| SyncError::Serialization(e.to_string()))
} else {
Err(SyncError::Network(format!("server returned {status}")))
}
}
/// Deserialize a push response body as [`SyncResponse`], or map non-200
/// statuses to the appropriate [`SyncError`].
async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, SyncError> {