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

@@ -17,6 +17,7 @@ const shardStateModel = require('../model/shardState/shardState.model')
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
const shardMarketModel = require('../model/shardMarket/shardMarket.model')
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
const settingsModel = require('../model/settings/settings.model')
const broadcaster = require('./shardBroadcast')
const pushDispatch = require('./pushDispatch')
const defaultLog = require('./logger')('shard-ingest')
@@ -63,6 +64,31 @@ function shouldLog(event) {
return LOGGED_KINDS.has(event.kind)
}
// ServUO's stock Server.cfg name. An operator who never set one publishes this
// verbatim, so it carries no more information than a blank — matched
// case-insensitively and trim-tolerantly, but ONLY as an exact whole value: a
// shard genuinely called "My Shard Reborn" keeps its name.
const STOCK_SHARD_NAME = 'my shard'
/**
* The name to publish for the shard: its own, or this instance's when it has
* effectively not given one.
*
* Deliberately not a general "blank means brand" rule applied across the wire —
* it is scoped to this one field, where the two names denote the same thing.
*/
async function resolveShardName(shard, deps) {
const given = String(shard ?? '').trim()
if (given !== '' && given.toLowerCase() !== STOCK_SHARD_NAME) return given
try {
return (await deps.settings.getInstanceName()) || given
} catch {
// A ruleset that publishes the stock name is still better than one that
// fails to store because the settings read hiccuped.
return given
}
}
// Apply the state-change side effect for a kind (if any). Returns a promise.
async function applyStateChange(event, deps) {
const { shardState, uoLinkConfig, log } = deps
@@ -180,6 +206,15 @@ async function applyStateChange(event, deps) {
// would put a duplicate row in the event log on every reconnect, and
// server.hello already marks each of those.
case 'world.ruleset':
// A shard whose operator never edited Server.cfg publishes ServUO's stock
// "My Shard". That is the shard saying *unnamed*, not a name, so the site
// answers with its own — the rules page reading "My Shard" under a header
// reading UOMysticmoon is the shard failing to introduce itself.
//
// Normalized HERE rather than on read because the ruleset is also live: the
// same `event` object is handed to the SSE broadcast a few lines below, and
// a read-time fix would be undone by the next reconnect's frame.
event.shard = await resolveShardName(event.shard, deps)
await shardState.setRuleset(event)
return
// Board state, like guild.update — the newest frame for a system replaces the
@@ -225,6 +260,7 @@ function resolveDeps(deps) {
shardLinks: deps.shardLinks || shardLinksModel,
shardMarket: deps.shardMarket || shardMarketModel,
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
settings: deps.settings || settingsModel,
broadcast: deps.broadcast || broadcaster.broadcast,
pushDispatch: deps.pushDispatch || pushDispatch.fromShardEvent,
log: deps.log || defaultLog,