From 648c5c18d947515f603f00f5256730a743ed7ac9 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 27 Apr 2026 02:01:20 +0000 Subject: [PATCH] =?UTF-8?q?feat(leaderboard):=20opt-out=20support=20?= =?UTF-8?q?=E2=80=94=20server=20endpoint,=20client=20method,=20UI=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Server: DELETE /api/leaderboard/opt-in sets leaderboard_opt_in=0, hiding the player without deleting their row (scores preserved for re-opt-in) - SyncProvider trait: opt_out_leaderboard() default no-op method + blanket impl - SolitaireServerClient: implements opt_out_leaderboard via DELETE request with JWT refresh - Leaderboard UI: "Opt Out" button (dark red) alongside existing "Opt In" button - Server integration test: opt-out hides, opt-in restores (round-trip verified) Co-Authored-By: Claude Sonnet 4.6 --- solitaire_data/src/lib.rs | 8 ++ solitaire_data/src/sync_client.rs | 34 +++++++ solitaire_engine/src/leaderboard_plugin.rs | 109 +++++++++++++++++---- solitaire_server/src/leaderboard.rs | 19 ++++ solitaire_server/src/lib.rs | 1 + solitaire_server/tests/server_tests.rs | 55 +++++++++++ 6 files changed, 205 insertions(+), 21 deletions(-) diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index 755ee92..822c2df 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -46,6 +46,11 @@ pub trait SyncProvider: Send + Sync { async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError> { Ok(()) } + /// Remove the authenticated player from the leaderboard. + /// No-op for backends that don't support leaderboards. + async fn opt_out_leaderboard(&self) -> Result<(), SyncError> { + Ok(()) + } } /// Blanket impl so `Box` (returned by @@ -76,6 +81,9 @@ impl SyncProvider for Box { async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> { (**self).opt_in_leaderboard(display_name).await } + async fn opt_out_leaderboard(&self) -> Result<(), SyncError> { + (**self).opt_out_leaderboard().await + } } pub mod stats; diff --git a/solitaire_data/src/sync_client.rs b/solitaire_data/src/sync_client.rs index 9e85ae0..97b4200 100644 --- a/solitaire_data/src/sync_client.rs +++ b/solitaire_data/src/sync_client.rs @@ -261,6 +261,40 @@ impl SyncProvider for SolitaireServerClient { Ok(()) } + async fn opt_out_leaderboard(&self) -> Result<(), SyncError> { + let token = self.access_token()?; + let url = format!("{}/api/leaderboard/opt-in", self.base_url); + + let resp = self + .client + .delete(&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 + .delete(&url) + .bearer_auth(new_token) + .send() + .await + .map_err(|e| SyncError::Network(e.to_string()))?; + if !resp.status().is_success() { + return Err(SyncError::Auth(format!("opt-out failed: {}", resp.status()))); + } + return Ok(()); + } + + if !resp.status().is_success() { + return Err(SyncError::Auth(format!("opt-out failed: {}", resp.status()))); + } + Ok(()) + } + async fn fetch_leaderboard(&self) -> Result, SyncError> { let token = self.access_token()?; let url = format!("{}/api/leaderboard", self.base_url); diff --git a/solitaire_engine/src/leaderboard_plugin.rs b/solitaire_engine/src/leaderboard_plugin.rs index 92a21bf..aa2649e 100644 --- a/solitaire_engine/src/leaderboard_plugin.rs +++ b/solitaire_engine/src/leaderboard_plugin.rs @@ -45,10 +45,18 @@ pub struct LeaderboardScreen; #[derive(Component, Debug)] struct LeaderboardOptInButton; +/// Marker on the "Opt Out" button inside the leaderboard panel. +#[derive(Component, Debug)] +struct LeaderboardOptOutButton; + /// In-flight opt-in task. #[derive(Resource, Default)] struct OptInTask(Option>>); +/// In-flight opt-out task. +#[derive(Resource, Default)] +struct OptOutTask(Option>>); + // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- @@ -62,6 +70,7 @@ impl Plugin for LeaderboardPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_systems( Update, ( @@ -71,6 +80,8 @@ impl Plugin for LeaderboardPlugin { update_leaderboard_panel, handle_opt_in_button, poll_opt_in_task, + handle_opt_out_button, + poll_opt_out_task, ) .chain(), ); @@ -213,6 +224,37 @@ fn poll_opt_in_task(mut task_res: ResMut) { } } +/// Fires an async opt-out request when the player presses the "Opt Out" button. +fn handle_opt_out_button( + interaction_query: Query<&Interaction, (Changed, With)>, + provider: Option>, + mut task_res: ResMut, +) { + if task_res.0.is_some() { + return; + } + let Some(provider) = provider else { return }; + for interaction in &interaction_query { + if *interaction != Interaction::Pressed { + continue; + } + let provider = provider.0.clone(); + let task = AsyncComputeTaskPool::get() + .spawn(async move { provider.opt_out_leaderboard().await.map_err(|e| e.to_string()) }); + task_res.0 = Some(task); + } +} + +/// Polls the opt-out task; logs on error, clears on completion. +fn poll_opt_out_task(mut task_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; + if let Err(e) = result { + warn!("leaderboard opt-out failed: {e}"); + } +} + // --------------------------------------------------------------------------- // UI construction // --------------------------------------------------------------------------- @@ -257,7 +299,7 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa TextColor(Color::WHITE), )); card.spawn(( - Text::new("Press L to close • Opt in to appear on the board"), + Text::new("Press L to close • Opt In / Opt Out to control your visibility"), TextFont { font_size: 14.0, ..default() }, TextColor(Color::srgb(0.55, 0.55, 0.60)), )); @@ -272,26 +314,51 @@ fn spawn_leaderboard_screen(commands: &mut Commands, entries: Option<&[Leaderboa BackgroundColor(Color::srgb(0.25, 0.25, 0.30)), )); - // Opt-in button - card.spawn(( - LeaderboardOptInButton, - Button, - Node { - padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)), - justify_content: JustifyContent::Center, - margin: UiRect::bottom(Val::Px(8.0)), - align_self: AlignSelf::FlexStart, - ..default() - }, - BackgroundColor(Color::srgb(0.18, 0.35, 0.50)), - BorderRadius::all(Val::Px(4.0)), - )) - .with_children(|b| { - b.spawn(( - Text::new("Opt In to Leaderboard"), - TextFont { font_size: 15.0, ..default() }, - TextColor(Color::WHITE), - )); + // Opt-in / Opt-out buttons row + card.spawn(Node { + flex_direction: FlexDirection::Row, + column_gap: Val::Px(10.0), + margin: UiRect::bottom(Val::Px(8.0)), + ..default() + }) + .with_children(|row| { + row.spawn(( + LeaderboardOptInButton, + Button, + Node { + padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)), + justify_content: JustifyContent::Center, + ..default() + }, + BackgroundColor(Color::srgb(0.18, 0.35, 0.50)), + BorderRadius::all(Val::Px(4.0)), + )) + .with_children(|b| { + b.spawn(( + Text::new("Opt In"), + TextFont { font_size: 15.0, ..default() }, + TextColor(Color::WHITE), + )); + }); + + row.spawn(( + LeaderboardOptOutButton, + Button, + Node { + padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)), + justify_content: JustifyContent::Center, + ..default() + }, + BackgroundColor(Color::srgb(0.42, 0.15, 0.15)), + BorderRadius::all(Val::Px(4.0)), + )) + .with_children(|b| { + b.spawn(( + Text::new("Opt Out"), + TextFont { font_size: 15.0, ..default() }, + TextColor(Color::WHITE), + )); + }); }); match entries { diff --git a/solitaire_server/src/leaderboard.rs b/solitaire_server/src/leaderboard.rs index 5606ed6..7e1fbb5 100644 --- a/solitaire_server/src/leaderboard.rs +++ b/solitaire_server/src/leaderboard.rs @@ -84,6 +84,25 @@ pub async fn get_leaderboard( Ok(Json(entries?)) } +/// `DELETE /api/leaderboard/opt-in` — opt out, hiding the player's entry. +/// +/// Sets `leaderboard_opt_in = 0` on the user row so the entry no longer +/// appears in `GET /api/leaderboard`. The leaderboard row itself is kept +/// so scores are preserved if the player opts back in later. +pub async fn opt_out( + State(pool): State, + user: AuthenticatedUser, +) -> Result, AppError> { + sqlx::query!( + "UPDATE users SET leaderboard_opt_in = 0 WHERE id = ?", + user.user_id + ) + .execute(&pool) + .await?; + + Ok(Json(serde_json::json!({ "ok": true }))) +} + /// `POST /api/leaderboard/opt-in` — opt in and upsert the player's entry. /// /// Sets `leaderboard_opt_in = 1` on the user row and creates/updates the diff --git a/solitaire_server/src/lib.rs b/solitaire_server/src/lib.rs index 81e33af..0084016 100644 --- a/solitaire_server/src/lib.rs +++ b/solitaire_server/src/lib.rs @@ -44,6 +44,7 @@ fn build_router_inner(pool: SqlitePool, rate_limit: bool) -> Router { .route("/api/sync/push", post(sync::push)) .route("/api/leaderboard", get(leaderboard::get_leaderboard)) .route("/api/leaderboard/opt-in", post(leaderboard::opt_in)) + .route("/api/leaderboard/opt-in", delete(leaderboard::opt_out)) .route("/api/account", delete(auth::delete_account)) .layer(axum_middleware::from_fn(middleware::require_auth)); diff --git a/solitaire_server/tests/server_tests.rs b/solitaire_server/tests/server_tests.rs index 1d86038..cbd6ed1 100644 --- a/solitaire_server/tests/server_tests.rs +++ b/solitaire_server/tests/server_tests.rs @@ -776,3 +776,58 @@ async fn push_lower_score_does_not_overwrite_leaderboard_best() { assert_eq!(entry["best_score"], 5_000, "best_score must not regress"); assert_eq!(entry["best_time_secs"], 120, "best_time_secs must stay at fastest"); } + +/// Opting out hides the player from the leaderboard; opting back in restores them. +#[tokio::test] +async fn opt_out_hides_then_opt_in_restores() { + set_jwt_secret(); + let pool = test_pool().await; + let app = build_test_router(pool); + + let (access, _) = register_user(app.clone(), "visible", "pass1234").await; + + // Opt in. + let resp = post_authed( + app.clone(), + "/api/leaderboard/opt-in", + &access, + serde_json::json!({ "display_name": "Visible" }), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + // Verify they appear. + let lb = get_authed(app.clone(), "/api/leaderboard", &access).await; + let entries = body_json(lb).await; + assert!( + entries.as_array().unwrap().iter().any(|e| e["display_name"] == "Visible"), + "opted-in user must appear" + ); + + // Opt out. + let resp = delete_authed(app.clone(), "/api/leaderboard/opt-in", &access).await; + assert_eq!(resp.status(), StatusCode::OK); + + // Verify they are hidden. + let lb = get_authed(app.clone(), "/api/leaderboard", &access).await; + let entries = body_json(lb).await; + assert!( + !entries.as_array().unwrap().iter().any(|e| e["display_name"] == "Visible"), + "opted-out user must be hidden" + ); + + // Opt back in — should restore without losing display name. + post_authed( + app.clone(), + "/api/leaderboard/opt-in", + &access, + serde_json::json!({ "display_name": "Visible" }), + ) + .await; + let lb = get_authed(app.clone(), "/api/leaderboard", &access).await; + let entries = body_json(lb).await; + assert!( + entries.as_array().unwrap().iter().any(|e| e["display_name"] == "Visible"), + "re-opted-in user must appear again" + ); +}