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 (
{entry.name || 'Name hidden'} {num(entry.points)}
) } function Board({ board }) { const { siteTitle } = useSite() const top = Array.isArray(board.top) ? board.top : [] // Bars are relative to the board leader, not to maxPoints: most systems have no // cap (maxPoints 0), and where there is one the leader is often nowhere near it, // which would render every bar as a stub. const best = top.reduce((m, e) => Math.max(m, e.points || 0), 0) return (

{boardTitle(board)}

{Number.isFinite(board.players) && ( {num(board.players)} ranked )}
{top.length === 0 ? ( // A board nobody has scored on still gets a row, so the page reads as a set // of standings waiting to be filled rather than a stack of blanks. It is // deliberately NOT shaped like an Entry — no medal, no bar, an em dash where // a score goes — because a placeholder that looked like a real standing would // be a fabricated one. The first real entry replaces it.
{siteTitle} —

Nobody has earned points here yet.

) : (
{top.map((entry) => ( ))}
)} {Number.isFinite(board.maxPoints) && board.maxPoints > 0 && ( Maximum {num(board.maxPoints)} points )}
) } export default function Leaderboards() { const { loading, error, data } = useAsync(() => api.shard.points()) // Buffer generously: a single sweep can emit a frame for every system at once, // and a board dropped from the buffer would silently revert to its fetched copy. const { events, connected } = useShardFeed({ filter: POINTS_KINDS, max: 60 }) const [query, setQuery] = useState('') const boards = useMemo(() => mergeBoards(data, events), [data, events]) const shown = useMemo(() => { const q = query.trim().toLowerCase() if (!q) return boards // Match the board name, the raw system key, or any ranked player on it — the // last is what makes the filter useful ("where do I appear?"). return boards.filter( (b) => boardTitle(b).toLowerCase().includes(q) || String(b.system).toLowerCase().includes(q) || (b.top || []).some((e) => e.name && e.name.toLowerCase().includes(q)), ) }, [boards, query]) return (
{connected ? 'Live' : 'Offline'}
{loading && } {error && } {!loading && !error && boards.length === 0 && (

The shard has not published any leaderboards yet.

)} {!loading && !error && boards.length > 0 && ( <> setQuery(e.target.value)} placeholder="Filter by board or player name…" aria-label="Filter leaderboards" style={{ maxWidth: 340, marginBottom: 14 }} /> {shown.length === 0 ? (

No board or ranked player matches “{query}”.

) : (
{shown.map((board) => ( ))}
)} )}
) }