diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index a1893c0..3b765a1 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -2,9 +2,9 @@ use bevy::prelude::*; use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings}; use solitaire_engine::{ AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardPlugin, - ChallengePlugin, DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, OnboardingPlugin, - PausePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin, TablePlugin, - TimeAttackPlugin, WeeklyGoalsPlugin, + ChallengePlugin, DailyChallengePlugin, GamePlugin, HelpPlugin, InputPlugin, LeaderboardPlugin, + OnboardingPlugin, PausePlugin, ProgressPlugin, SettingsPlugin, StatsPlugin, SyncPlugin, + TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, }; fn main() { @@ -45,5 +45,6 @@ fn main() { .add_plugins(AudioPlugin) .add_plugins(OnboardingPlugin) .add_plugins(SyncPlugin::new(sync_provider)) + .add_plugins(LeaderboardPlugin) .run(); } diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index c61d043..8c1b6a5 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -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, SyncError> { + Ok(vec![]) + } } /// Blanket impl so `Box` (returned by @@ -54,6 +59,9 @@ impl SyncProvider for Box { async fn mirror_achievement(&self, id: &str) -> Result<(), SyncError> { (**self).mirror_achievement(id).await } + async fn fetch_leaderboard(&self) -> Result, SyncError> { + (**self).fetch_leaderboard().await + } } pub mod stats; diff --git a/solitaire_data/src/sync_client.rs b/solitaire_data/src/sync_client.rs index a592765..c764206 100644 --- a/solitaire_data/src/sync_client.rs +++ b/solitaire_data/src/sync_client.rs @@ -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, 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`. +async fn extract_leaderboard_body(resp: reqwest::Response) -> Result, 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 { diff --git a/solitaire_engine/src/leaderboard_plugin.rs b/solitaire_engine/src/leaderboard_plugin.rs new file mode 100644 index 0000000..df05304 --- /dev/null +++ b/solitaire_engine/src/leaderboard_plugin.rs @@ -0,0 +1,441 @@ +//! In-game leaderboard panel. +//! +//! Press `L` to open the panel. On first open, an async fetch is kicked off +//! against the active [`SyncProvider`]. Fetched results are cached in +//! [`LeaderboardResource`] and re-displayed without another network trip until +//! the user explicitly presses `L` again while the panel is already open +//! (which closes it) and then `L` once more (which re-fetches). +//! +//! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`) +//! the panel shows "Not available" immediately. + +use bevy::prelude::*; +use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task}; +use solitaire_sync::LeaderboardEntry; + +use crate::sync_plugin::SyncProviderResource; + +// --------------------------------------------------------------------------- +// Resources +// --------------------------------------------------------------------------- + +/// Cached leaderboard data. `None` means no fetch has completed yet. +#[derive(Resource, Default, Debug, Clone)] +pub struct LeaderboardResource(pub Option>); + +/// Set to `true` in the frame the user explicitly closes the panel so that a +/// fetch completing in the same frame doesn't immediately reopen it. +#[derive(Resource, Default)] +struct ClosedThisFrame(bool); + +/// In-flight fetch task result carrier — transfers data from the task thread. +#[derive(Resource, Default)] +struct LeaderboardFetchResult(Option, String>>); + +#[derive(Resource, Default)] +struct LeaderboardFetchTask(Option, String>>>); + +/// Marker on the leaderboard overlay root node. +#[derive(Component, Debug)] +pub struct LeaderboardScreen; + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +pub struct LeaderboardPlugin; + +impl Plugin for LeaderboardPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems( + Update, + ( + reset_closed_flag, + toggle_leaderboard_screen, + poll_leaderboard_fetch, + update_leaderboard_panel, + ) + .chain(), + ); + } +} + +// --------------------------------------------------------------------------- +// Systems +// --------------------------------------------------------------------------- + +/// Clear the "closed this frame" flag at the start of each frame. +fn reset_closed_flag(mut flag: ResMut) { + flag.0 = false; +} + +/// `L` key — open or close the leaderboard panel. +/// On open, starts a new fetch if no data is cached or a fetch is not in flight. +fn toggle_leaderboard_screen( + mut commands: Commands, + keys: Res>, + screens: Query>, + data: Res, + provider: Option>, + mut task_res: ResMut, + mut closed_flag: ResMut, +) { + if !keys.just_pressed(KeyCode::KeyL) { + return; + } + if let Ok(entity) = screens.get_single() { + commands.entity(entity).despawn_recursive(); + closed_flag.0 = true; + return; + } + + // Spawn the panel immediately with whatever data we have (may be None). + spawn_leaderboard_screen(&mut commands, data.0.as_deref()); + + // Start a background fetch if not already in flight. + if task_res.0.is_none() { + if let Some(p) = provider { + let provider = p.0.clone(); + let task = AsyncComputeTaskPool::get().spawn(async move { + provider.fetch_leaderboard().await.map_err(|e| e.to_string()) + }); + task_res.0 = Some(task); + } + } +} + +/// Poll the background fetch task; store results when complete. +fn poll_leaderboard_fetch( + mut task_res: ResMut, + mut result_res: ResMut, +) { + let Some(task) = task_res.0.as_mut() else { return }; + let Some(result) = future::block_on(future::poll_once(task)) else { return }; + task_res.0 = None; + result_res.0 = Some(result); +} + +/// When a fetch completes, cache the data and update any open panel. +/// Skips the panel rebuild if the user closed the panel in this same frame +/// (commands are deferred, so the query would still see the despawned entity). +fn update_leaderboard_panel( + mut commands: Commands, + mut result_res: ResMut, + mut data: ResMut, + screens: Query>, + closed_flag: Res, +) { + let Some(result) = result_res.0.take() else { return }; + + match result { + Ok(entries) => { + data.0 = Some(entries); + } + Err(e) => { + warn!("leaderboard fetch failed: {e}"); + if data.0.is_none() { + data.0 = Some(vec![]); // show empty rather than spinner forever + } + } + } + + // Rebuild the panel if it's open — but not if the user just closed it in + // this frame (their despawn command is still deferred). + if closed_flag.0 { + return; + } + for entity in &screens { + commands.entity(entity).despawn_recursive(); + spawn_leaderboard_screen(&mut commands, data.0.as_deref()); + } +} + +// --------------------------------------------------------------------------- +// UI construction +// --------------------------------------------------------------------------- + +fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[LeaderboardEntry]>) { + commands + .spawn(( + LeaderboardScreen, + Node { + position_type: PositionType::Absolute, + left: Val::Percent(0.0), + top: Val::Percent(0.0), + width: Val::Percent(100.0), + height: Val::Percent(100.0), + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + ..default() + }, + BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.82)), + ZIndex(210), + )) + .with_children(|root| { + root.spawn(( + Node { + flex_direction: FlexDirection::Column, + padding: UiRect::all(Val::Px(28.0)), + row_gap: Val::Px(8.0), + min_width: Val::Px(420.0), + max_height: Val::Percent(80.0), + overflow: Overflow::clip_y(), + ..default() + }, + BackgroundColor(Color::srgb(0.09, 0.09, 0.12)), + BorderRadius::all(Val::Px(8.0)), + )) + .with_children(|card| { + // Header + card.spawn(( + Text::new("Leaderboard"), + TextFont { font_size: 26.0, ..default() }, + TextColor(Color::WHITE), + )); + card.spawn(( + Text::new("Press L to close"), + TextFont { font_size: 14.0, ..default() }, + TextColor(Color::srgb(0.55, 0.55, 0.60)), + )); + + // Separator + card.spawn(( + Node { + height: Val::Px(1.0), + margin: UiRect::vertical(Val::Px(6.0)), + ..default() + }, + BackgroundColor(Color::srgb(0.25, 0.25, 0.30)), + )); + + match entries { + None => { + // Fetch in progress + card.spawn(( + Text::new("Fetching…"), + TextFont { font_size: 18.0, ..default() }, + TextColor(Color::srgb(0.65, 0.65, 0.70)), + )); + } + Some([]) => { + card.spawn(( + Text::new("No entries yet — sync and opt in to appear here."), + TextFont { font_size: 16.0, ..default() }, + TextColor(Color::srgb(0.55, 0.55, 0.60)), + )); + } + Some(rows) => { + // Column headers + card.spawn(Node { + flex_direction: FlexDirection::Row, + column_gap: Val::Px(16.0), + margin: UiRect::bottom(Val::Px(4.0)), + ..default() + }) + .with_children(|row| { + header_cell(row, "#", 30.0); + header_cell(row, "Player", 160.0); + header_cell(row, "Best Score", 100.0); + header_cell(row, "Fastest Win", 110.0); + }); + + // Data rows (top 10) + let mut sorted = rows.to_vec(); + sorted.sort_by(|a, b| { + b.best_score + .unwrap_or(0) + .cmp(&a.best_score.unwrap_or(0)) + }); + + for (i, entry) in sorted.iter().take(10).enumerate() { + let rank_color = match i { + 0 => Color::srgb(1.0, 0.84, 0.0), + 1 => Color::srgb(0.75, 0.75, 0.75), + 2 => Color::srgb(0.80, 0.50, 0.20), + _ => Color::srgb(0.80, 0.80, 0.80), + }; + + let time_str = entry + .best_time_secs + .map(format_secs) + .unwrap_or_else(|| "-".to_string()); + let score_str = entry + .best_score + .map(|s| s.to_string()) + .unwrap_or_else(|| "-".to_string()); + + card.spawn(Node { + flex_direction: FlexDirection::Row, + column_gap: Val::Px(16.0), + ..default() + }) + .with_children(|row| { + data_cell(row, &format!("{}", i + 1), 30.0, rank_color); + data_cell(row, &entry.display_name, 160.0, Color::WHITE); + data_cell(row, &score_str, 100.0, Color::WHITE); + data_cell(row, &time_str, 110.0, Color::WHITE); + }); + } + } + } + }); + }); +} + +fn header_cell(parent: &mut ChildBuilder, text: &str, width: f32) { + parent.spawn(( + Text::new(text.to_string()), + TextFont { font_size: 13.0, ..default() }, + TextColor(Color::srgb(0.55, 0.75, 0.55)), + Node { width: Val::Px(width), ..default() }, + )); +} + +fn data_cell(parent: &mut ChildBuilder, text: &str, width: f32, color: Color) { + parent.spawn(( + Text::new(text.to_string()), + TextFont { font_size: 15.0, ..default() }, + TextColor(color), + Node { width: Val::Px(width), ..default() }, + )); +} + +fn format_secs(secs: u64) -> String { + let m = secs / 60; + let s = secs % 60; + if m > 0 { + format!("{m}:{s:02}") + } else { + format!("{s}s") + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::game_plugin::GamePlugin; + use crate::table_plugin::TablePlugin; + use crate::sync_plugin::SyncPlugin; + use solitaire_data::SyncError; + use solitaire_sync::{SyncPayload, SyncResponse}; + use chrono::Utc; + use uuid::Uuid; + use solitaire_sync::PlayerProgress; + use solitaire_data::StatsSnapshot; + + struct NoOpProvider; + + #[async_trait::async_trait] + impl solitaire_data::SyncProvider for NoOpProvider { + async fn pull(&self) -> Result { + Ok(SyncPayload { + user_id: Uuid::nil(), + stats: StatsSnapshot::default(), + achievements: vec![], + progress: PlayerProgress::default(), + last_modified: Utc::now(), + }) + } + async fn push(&self, _p: &SyncPayload) -> Result { + Ok(SyncResponse { + merged: SyncPayload { + user_id: Uuid::nil(), + stats: StatsSnapshot::default(), + achievements: vec![], + progress: PlayerProgress::default(), + last_modified: Utc::now(), + }, + server_time: Utc::now(), + conflicts: vec![], + }) + } + fn backend_name(&self) -> &'static str { "no-op" } + fn is_authenticated(&self) -> bool { false } + + async fn fetch_leaderboard(&self) -> Result, SyncError> { + Ok(vec![ + LeaderboardEntry { + display_name: "Alice".to_string(), + best_score: Some(5000), + best_time_secs: Some(180), + recorded_at: Utc::now(), + }, + ]) + } + } + + fn headless_app() -> App { + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(GamePlugin) + .add_plugins(TablePlugin) + .add_plugins(crate::stats_plugin::StatsPlugin::headless()) + .add_plugins(crate::progress_plugin::ProgressPlugin::headless()) + .add_plugins(crate::achievement_plugin::AchievementPlugin::headless()) + .add_plugins(SyncPlugin::new(NoOpProvider)) + .add_plugins(LeaderboardPlugin); + app.init_resource::>(); + app.update(); + app + } + + fn press(app: &mut App, key: KeyCode) { + let mut input = app.world_mut().resource_mut::>(); + input.release(key); + input.clear(); + input.press(key); + } + + #[test] + fn resource_starts_empty() { + let app = headless_app(); + assert!(app.world().resource::().0.is_none()); + } + + #[test] + fn pressing_l_spawns_screen() { + let mut app = headless_app(); + press(&mut app, KeyCode::KeyL); + app.update(); + let count = app + .world_mut() + .query::<&LeaderboardScreen>() + .iter(app.world()) + .count(); + assert_eq!(count, 1); + } + + #[test] + fn pressing_l_twice_dismisses_screen() { + let mut app = headless_app(); + press(&mut app, KeyCode::KeyL); + app.update(); + press(&mut app, KeyCode::KeyL); + app.update(); + let count = app + .world_mut() + .query::<&LeaderboardScreen>() + .iter(app.world()) + .count(); + assert_eq!(count, 0); + } + + #[test] + fn format_secs_below_minute() { + assert_eq!(format_secs(45), "45s"); + } + + #[test] + fn format_secs_above_minute() { + assert_eq!(format_secs(183), "3:03"); + } +} diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 743779b..edfae28 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -10,6 +10,7 @@ pub mod daily_challenge_plugin; pub mod events; pub mod game_plugin; pub mod help_plugin; +pub mod leaderboard_plugin; pub mod input_plugin; pub mod layout; pub mod onboarding_plugin; @@ -42,6 +43,7 @@ pub use events::{ }; pub use game_plugin::{GameMutation, GamePlugin, GameStatePath}; pub use help_plugin::{HelpPlugin, HelpScreen}; +pub use leaderboard_plugin::{LeaderboardPlugin, LeaderboardResource, LeaderboardScreen}; pub use input_plugin::InputPlugin; pub use onboarding_plugin::{OnboardingPlugin, OnboardingScreen}; pub use pause_plugin::{PausePlugin, PauseScreen, PausedResource};