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:
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user