PURPOSE

React component that renders the global leaderboard table for The Void planet in the hub. Shows the top 50 players sorted by highest tier reached, with rank, display name, kills, equipped weapon icons, and tier. Highlights the local player’s row. KPI blocks (highest tier, next milestone) live elsewhere in TierKpiOverlay and are rendered across all planets via HubScreen; this component is only the rankings table.

OWNS

  • Local component state: entries: LeaderboardEntry[] and loading: boolean.
  • DOM structure under .lb-wrapper.lb-rankings-column.lb-rankings-list with per-row .lb-row (and .lb-row-self for the local player), broken into .lb-rank, .lb-name, .lb-kills, .lb-weapons, .lb-tier spans.
  • The LeaderboardEntry and WeaponEntry interfaces used to type the RPC payload.
  • A cancelled guard in the fetch effect so a late RPC response after unmount or planetId change cannot call setEntries / setLoading on a dead instance.

READS FROM

  • usePlayerStore — selects profile?.id as playerId to mark the local player’s row with lb-row-self.
  • invokeRpc<LeaderboardEntry[] | null>('get_tier_leaderboard', { p_planet_id: planetId }) from @metagame/services/supabase — single source of leaderboard rows.
  • getWeaponIconPath(w.id) and WEAPON_EMOJIS[w.id] from @starship-survivors/data/weapon-icons to resolve each equipped weapon’s image path (with emoji and fallbacks).
  • Props: planetId: number (controls which leaderboard scope is fetched and is the effect dependency).

PUSHES TO

  • React DOM only. Writes the rankings table into the hub-building-overlap container on The Void planet.
  • Does not write to any Zustand store, Supabase table, or telemetry sink.

DOES NOT

  • Does not compute, rank, or sort entries — relies entirely on the rank field returned by get_tier_leaderboard.
  • Does not render KPI blocks for the player’s own highest tier or next milestone — that responsibility lives in TierKpiOverlay.
  • Does not poll or subscribe; data is fetched once per mount or when planetId changes.
  • Does not handle pagination, search, or filtering — top 50 is the contract on the RPC side.
  • Does not mutate leaderboard data, submit runs, or write player progress.
  • Does not surface fetch errors to the user beyond a console.error log; the table simply shows whatever state was last set (loading, empty, or last good entries).
  • Does not gate rendering by planet — the caller (HubScreen / planet content) decides to mount it only on The Void.

Signals

  • Inputs: planetId prop change re-runs the fetch effect.
  • Effect lifecycle: on mount or planetId change → set loading = true, call RPC, on resolve set entries (defaulting null to []) and loading = false. On unmount or planet change, the prior in-flight call is ignored via the cancelled flag.
  • Player identity signal: usePlayerStore subscription on profile?.id re-renders to update which row carries the lb-row-self class when the profile loads or changes.
  • Error signal: failed RPC logs 'Leaderboard fetch failed:' to console.error and still flips loading off.

Entry points

  • Exported as a named function component: export function LeaderboardWrapper({ planetId }: LeaderboardWrapperProps).
  • Mounted by the hub layer inside the hub-building-overlap container on The Void planet.
  • Depends on playerStore.profile being populated for self-row highlighting; otherwise renders the table without a highlighted row.

Pattern notes

  • Cancellation pattern: let cancelled = false captured in the effect closure, with the cleanup function setting it to true and every .then / .finally branch guarding on !cancelled before calling state setters. Standard React-effect-with-async-fetch idiom.
  • Null-safety: RPC return is typed LeaderboardEntry[] | null and coalesced with ?? []; entry.weapons_json is defensively normalized via Array.isArray(...) ? ... : [] before mapping.
  • Weapon icon fallback chain: image path from getWeaponIconPath(w.id) → emoji from WEAPON_EMOJIS[w.id] literal. Image is rendered with fixed inline width: 24px, height: 24px, maxWidth: 'none' to override any inherited icon constraints.
  • Self-row highlight uses string concatenation on className rather than a classnames helper.
  • Loading and empty states are mutually exclusive ternary branches inside the rankings list; no skeleton rows.
  • Component is stateless beyond the fetch — no memoization, no useCallback / useMemo, no refs. Re-render cost is bounded by 50 rows.