fix(shard): answer with the instance name when the shard is unnamed

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
This commit is contained in:
2026-08-01 00:58:21 -05:00
parent e50fab241f
commit 01a559792c
5 changed files with 149 additions and 4 deletions

View File

@@ -5,6 +5,7 @@ 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
@@ -96,6 +97,7 @@ function Entry({ entry, best }) {
}
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,
@@ -116,9 +118,28 @@ function Board({ board }) {
</div>
{top.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>
Nobody has earned points here yet.
</p>
// 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' }}>&mdash;</span>
</div>
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
Nobody has earned points here yet.
</p>
</div>
) : (
<div>
{top.map((entry) => (