feat(engine): leaderboard error and idle states plus local-only guard

LeaderboardResource was a tuple struct of Option<Vec<Entry>>: None for
pre-fetch and empty Vec for both "actually empty" and "fetch failed"
— the user couldn't tell a network error from a legitimately quiet
leaderboard. The resource is now a four-state enum (Idle / Error /
Loaded), with Loaded covering both populated and empty rows. A
transient error no longer wipes a previously populated list, and the
panel renders "Couldn't reach the leaderboard. Try again later."
when the most recent fetch failed.

The Opt In / Opt Out buttons used to render unconditionally and
silently no-op under LocalOnlyProvider. The panel now reads the
SyncProviderResource backend name and, when no remote is configured,
replaces the buttons with a single line directing the player to
configure cloud sync in Settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-30 20:18:34 +00:00
parent 65d595ad12
commit 138436558f
+90 -39
View File
@@ -30,9 +30,25 @@ use crate::ui_theme::{
// Resources // Resources
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Cached leaderboard data. `None` means no fetch has completed yet. /// State of the cached leaderboard fetch.
///
/// Distinguishes "fetch hasn't completed yet" from "fetch failed" from
/// "fetch succeeded but the leaderboard is empty" so the UI can show
/// targeted copy for each case rather than a single ambiguous "no
/// entries" line that hid network errors from the player.
#[derive(Resource, Default, Debug, Clone)] #[derive(Resource, Default, Debug, Clone)]
pub struct LeaderboardResource(pub Option<Vec<LeaderboardEntry>>); pub enum LeaderboardResource {
/// No fetch has completed yet — show "Fetching..." in the panel.
#[default]
Idle,
/// Last fetch failed (network, auth, etc.) — show error copy.
/// The wrapped string is the underlying error for logging only;
/// the UI shows a fixed user-friendly message.
Error(String),
/// Fetch succeeded — wrapped Vec may be empty (legitimately empty
/// leaderboard) or populated.
Loaded(Vec<LeaderboardEntry>),
}
/// Set to `true` in the frame the user explicitly closes the panel so that a /// 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. /// fetch completing in the same frame doesn't immediately reopen it.
@@ -134,8 +150,12 @@ fn toggle_leaderboard_screen(
return; return;
} }
// Spawn the panel immediately with whatever data we have (may be None). // Spawn the panel immediately with whatever data we have so far.
spawn_leaderboard_screen(&mut commands, data.0.as_deref(), font_res.as_deref()); let remote_available = provider
.as_ref()
.map(|p| p.0.backend_name() != "local")
.unwrap_or(false);
spawn_leaderboard_screen(&mut commands, &data, remote_available, font_res.as_deref());
// Start a background fetch if not already in flight. // Start a background fetch if not already in flight.
if task_res.0.is_none() if task_res.0.is_none()
@@ -167,6 +187,7 @@ fn update_leaderboard_panel(
mut result_res: ResMut<LeaderboardFetchResult>, mut result_res: ResMut<LeaderboardFetchResult>,
mut data: ResMut<LeaderboardResource>, mut data: ResMut<LeaderboardResource>,
screens: Query<Entity, With<LeaderboardScreen>>, screens: Query<Entity, With<LeaderboardScreen>>,
provider: Option<Res<SyncProviderResource>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
closed_flag: Res<ClosedThisFrame>, closed_flag: Res<ClosedThisFrame>,
) { ) {
@@ -174,12 +195,15 @@ fn update_leaderboard_panel(
match result { match result {
Ok(entries) => { Ok(entries) => {
data.0 = Some(entries); *data = LeaderboardResource::Loaded(entries);
} }
Err(e) => { Err(e) => {
warn!("leaderboard fetch failed: {e}"); warn!("leaderboard fetch failed: {e}");
if data.0.is_none() { // Preserve previously-loaded data on a transient failure so a
data.0 = Some(vec![]); // show empty rather than spinner forever // momentary network blip doesn't wipe a populated list. Only
// surface an Error state when we have nothing better to show.
if !matches!(*data, LeaderboardResource::Loaded(_)) {
*data = LeaderboardResource::Error(e);
} }
} }
} }
@@ -189,9 +213,13 @@ fn update_leaderboard_panel(
if closed_flag.0 { if closed_flag.0 {
return; return;
} }
let remote_available = provider
.as_ref()
.map(|p| p.0.backend_name() != "local")
.unwrap_or(false);
for entity in &screens { for entity in &screens {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
spawn_leaderboard_screen(&mut commands, data.0.as_deref(), font_res.as_deref()); spawn_leaderboard_screen(&mut commands, &data, remote_available, font_res.as_deref());
} }
} }
@@ -316,7 +344,8 @@ pub struct LeaderboardCloseButton;
fn spawn_leaderboard_screen( fn spawn_leaderboard_screen(
commands: &mut Commands, commands: &mut Commands,
entries: Option<&[LeaderboardEntry]>, data: &LeaderboardResource,
remote_available: bool,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
) { ) {
spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| { spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
@@ -345,32 +374,44 @@ fn spawn_leaderboard_screen(
..default() ..default()
}; };
card.spawn(( if remote_available {
Text::new("Use Opt In / Opt Out to control your visibility on the server."), card.spawn((
font_caption.clone(), Text::new("Use Opt In / Opt Out to control your visibility on the server."),
TextColor(TEXT_SECONDARY), font_caption.clone(),
)); TextColor(TEXT_SECONDARY),
));
// Opt In / Opt Out row uses the same modal-button helpers as // Opt In / Opt Out row uses the same modal-button helpers as
// the rest of the UI for consistent hover / press feedback. // the rest of the UI for consistent hover / press feedback.
spawn_modal_actions(card, |row| { spawn_modal_actions(card, |row| {
spawn_modal_button( spawn_modal_button(
row, row,
LeaderboardOptInButton, LeaderboardOptInButton,
"Opt In", "Opt In",
None, None,
ButtonVariant::Secondary, ButtonVariant::Secondary,
font_res, font_res,
); );
spawn_modal_button( spawn_modal_button(
row, row,
LeaderboardOptOutButton, LeaderboardOptOutButton,
"Opt Out", "Opt Out",
None, None,
ButtonVariant::Tertiary, ButtonVariant::Tertiary,
font_res, font_res,
); );
}); });
} else {
// No remote sync provider configured — opt-in/out would be a
// silent no-op, so show a single explanatory line instead.
card.spawn((
Text::new(
"Leaderboards require cloud sync. Configure a server in Settings to participate.",
),
font_caption.clone(),
TextColor(TEXT_SECONDARY),
));
}
// Subtle separator between the controls and the data area. // Subtle separator between the controls and the data area.
card.spawn(( card.spawn((
@@ -381,22 +422,29 @@ fn spawn_leaderboard_screen(
BackgroundColor(BORDER_SUBTLE), BackgroundColor(BORDER_SUBTLE),
)); ));
match entries { match data {
None => { LeaderboardResource::Idle => {
card.spawn(( card.spawn((
Text::new("Fetching\u{2026}"), Text::new("Fetching\u{2026}"),
font_status.clone(), font_status.clone(),
TextColor(STATE_INFO), TextColor(STATE_INFO),
)); ));
} }
Some([]) => { LeaderboardResource::Error(_) => {
card.spawn((
Text::new("Couldn't reach the leaderboard. Try again later."),
font_status.clone(),
TextColor(TEXT_SECONDARY),
));
}
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
card.spawn(( card.spawn((
Text::new("No entries yet \u{2014} sync and opt in to appear here."), Text::new("No entries yet \u{2014} sync and opt in to appear here."),
font_row.clone(), font_row.clone(),
TextColor(TEXT_SECONDARY), TextColor(TEXT_SECONDARY),
)); ));
} }
Some(rows) => { LeaderboardResource::Loaded(rows) => {
// Column headers // Column headers
card.spawn(Node { card.spawn(Node {
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
@@ -583,7 +631,10 @@ mod tests {
#[test] #[test]
fn resource_starts_empty() { fn resource_starts_empty() {
let app = headless_app(); let app = headless_app();
assert!(app.world().resource::<LeaderboardResource>().0.is_none()); assert!(matches!(
app.world().resource::<LeaderboardResource>(),
LeaderboardResource::Idle
));
} }
#[test] #[test]