import { useMemo, useState } from 'react' import PublicLayout from '../../components/PublicLayout.jsx' import PageHeader from '../../components/PageHeader.jsx' import { Loading, ErrorState } from '../../components/PageState.jsx' import { useAsync } from '../../lib/useAsync.js' import { useShardFeed } from '../../lib/useShardFeed.js' import { api } from '../../api/client.js' import { useSite } from '../../contexts/SiteContext.jsx' // Points / loyalty leaderboards (Protocol 3.0 §7). The shard carries ~25 separate // point currencies — Queen's Loyalty, Void Pool, Clean Up Britannia, the nine city // loyalties, the Doom/Khaldun/Kotl treasure systems — every one of them a standing // players build over months, and none of them visible anywhere but an in-game gump // until now. // // Loaded from /public/shard/points, then kept current from the live feed. Unlike // the ruleset (one frame = the whole thing), a points.board frame describes ONE // system, so live frames are merged over the fetched set by system key rather than // replacing it. const POINTS_KINDS = new Set(['points.board']) // A board's display name may arrive as a literal (`nameString`), a cliloc id // (`nameNumber`), or both — Name is a ServUO TextDefinition. We have no cliloc // table on the site, so a cliloc-only board falls back to humanising its own // PointsType key, which is already close to a display name ("CleanUpBritannia" → // "Clean Up Britannia"). Better than showing a bare number. const humanise = (key) => String(key || '') .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/^./, (c) => c.toUpperCase()) const boardTitle = (b) => b.nameString || humanise(b.system) const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—') // Merge live frames over the fetched boards. Newest frame per system wins; a // system that has never appeared in either is simply absent. function mergeBoards(fetched, events) { const bySystem = new Map() for (const b of Array.isArray(fetched) ? fetched : []) { if (b && b.system) bySystem.set(b.system, b) } // Events arrive newest-first, so walk backwards and let the newest land last. for (let i = events.length - 1; i >= 0; i--) { const ev = events[i] if (ev && ev.system) bySystem.set(ev.system, ev) } return [...bySystem.values()].sort((a, b) => boardTitle(a).localeCompare(boardTitle(b))) } function Medal({ rank }) { // Gold / silver / bronze for the podium, plain for the rest. const tone = rank === 1 ? '#c9a24b' : rank === 2 ? '#b6bcc6' : rank === 3 ? '#b3805a' : 'var(--muted)' return ( {rank} ) } // One ranked player. `name` is absent rather than empty when an admin has gated // the leaderboards `name` field above this viewer's rung — the row still renders, // because the standing itself is the point. function Entry({ entry, best }) { const pct = best > 0 ? Math.max(2, Math.round((entry.points / best) * 100)) : 0 return (
Nobody has earned points here yet.
The shard has not published any leaderboards yet.
No board or ranked player matches “{query}”.
) : (