ServUO ships Server.cfg with `Name=My Shard`. An operator who never edited it publishes that verbatim, so the rules page read "My Shard" under a header carrying the real name. That value is the shard saying *unnamed* rather than naming anything, so the site now answers with its own. `settings.getInstanceName()` resolves `site_title || BRAND_NAME` — the same resolution `getPublic().brand.name` already uses, so an install that set only the site title can never show two different names on two pages. Bare `brand.name` would have been wrong for exactly that case. Substituted at INGEST rather than on read: world.ruleset is also broadcast live, and the same object is handed to the SSE fan-out, so a read-time fix would be undone by the next reconnect's frame. Matched case- and padding-insensitively but only as a whole value, so a shard genuinely called "My Shard Reborn" keeps its name. Fixes a second ruleset writer found on the way: uoLinkSocket.backfill() called shardState.setRuleset directly instead of going through the dispatcher as ingestEach does, so the boot/reconnect snapshot silently skipped this normalization. The two arrival orders have to produce the same stored frame. Also renders a placeholder row on an unscored leaderboard — the instance name with an em dash where a score goes, deliberately not shaped like an entry (no medal, no bar) because a placeholder that looked like a real standing would be a fabricated one. Presentation only; the API still sends an empty `top`. Verified live against the shard + sidecar: rules page and leaderboards on web and Android both correct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
241 lines
9.7 KiB
JavaScript
241 lines
9.7 KiB
JavaScript
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 (
|
|
<span
|
|
className="display"
|
|
style={{
|
|
flex: 'none', width: 26, textAlign: 'right', color: tone,
|
|
fontSize: rank <= 3 ? '1rem' : '0.86rem',
|
|
}}
|
|
>
|
|
{rank}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// 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 (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
|
<Medal rank={entry.rank} />
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
|
|
<span
|
|
className="sans"
|
|
style={{
|
|
color: entry.name ? 'var(--ink)' : 'var(--muted)',
|
|
fontSize: '0.86rem', fontStyle: entry.name ? 'normal' : 'italic',
|
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{entry.name || 'Name hidden'}
|
|
</span>
|
|
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
|
|
{num(entry.points)}
|
|
</span>
|
|
</div>
|
|
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden', marginTop: 3 }}>
|
|
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<section className="panel" style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10 }}>
|
|
<h2 className="display" style={{ margin: 0, fontSize: '1.02rem', color: 'var(--head)' }}>
|
|
{boardTitle(board)}
|
|
</h2>
|
|
{Number.isFinite(board.players) && (
|
|
<span className="sans dim" style={{ fontSize: '0.72rem', flex: 'none' }}>
|
|
{num(board.players)} ranked
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{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.
|
|
<div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10, padding: '6px 0' }}>
|
|
<span
|
|
className="sans"
|
|
style={{
|
|
color: 'var(--muted)', fontSize: '0.86rem',
|
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{siteTitle}
|
|
</span>
|
|
<span className="sans dim" style={{ fontSize: '0.82rem', flex: 'none' }}>—</span>
|
|
</div>
|
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
|
|
Nobody has earned points here yet.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{top.map((entry) => (
|
|
<Entry key={`${board.system}-${entry.rank}-${entry.serial}`} entry={entry} best={best} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{Number.isFinite(board.maxPoints) && board.maxPoints > 0 && (
|
|
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
|
Maximum {num(board.maxPoints)} points
|
|
</span>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<PublicLayout section="website">
|
|
<div className="shell page-body">
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
|
<PageHeader
|
|
eyebrow="Live"
|
|
title="Leaderboards"
|
|
lead="Loyalty and points standings, straight from the shard — every currency the server tracks, updated as players climb."
|
|
/>
|
|
<span
|
|
className="sans"
|
|
style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem',
|
|
color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6,
|
|
}}
|
|
>
|
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
|
{connected ? 'Live' : 'Offline'}
|
|
</span>
|
|
</div>
|
|
|
|
{loading && <Loading />}
|
|
{error && <ErrorState message="Could not load the leaderboards right now." />}
|
|
|
|
{!loading && !error && boards.length === 0 && (
|
|
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
|
<p className="sans dim" style={{ margin: 0 }}>
|
|
The shard has not published any leaderboards yet.
|
|
</p>
|
|
</section>
|
|
)}
|
|
|
|
{!loading && !error && boards.length > 0 && (
|
|
<>
|
|
<input
|
|
className="input"
|
|
type="search"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Filter by board or player name…"
|
|
aria-label="Filter leaderboards"
|
|
style={{ maxWidth: 340, marginBottom: 14 }}
|
|
/>
|
|
|
|
{shown.length === 0 ? (
|
|
<p className="sans dim">No board or ranked player matches “{query}”.</p>
|
|
) : (
|
|
<div className="grid-2" style={{ gap: 12, alignItems: 'start' }}>
|
|
{shown.map((board) => (
|
|
<Board key={board.system} board={board} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</PublicLayout>
|
|
)
|
|
}
|