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

@@ -70,6 +70,24 @@ async function isMobileAppLinksEnabled() {
}
}
/**
* This instance's name, resolved exactly as `getPublic().brand.name` resolves it —
* the admin-editable site title wins over BRAND_NAME. Anything that has to *speak*
* the instance's name outside the settings payload must use this rather than
* `brand.name`, or an install that set only the site title gets two different names
* on two different pages.
*
* Never throws: a name is always better than an error, so a DB fault falls back to
* the env value.
*/
async function getInstanceName() {
try {
return (await settingsDb.get('site_title')) || brand.name
} catch {
return brand.name
}
}
async function get(key) {
return settingsDb.get(key)
}
@@ -154,6 +172,7 @@ module.exports = {
setMany,
getAll,
getPublic,
getInstanceName,
PUBLIC_KEYS,
REGISTRATION_KEY,
REGISTRATION_MODES,

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,

View File

@@ -142,9 +142,15 @@ async function backfill() {
// snapshot() (which asserts an array under `key`). The shard also re-emits
// world.ruleset on its own connect — this covers the other order, where the
// sidecar was already up and holding the ruleset when WE reconnected.
//
// Routed through the dispatcher rather than straight to shardState, exactly as
// ingestEach does for the array-shaped boards: the two orders must produce the
// same stored frame, and calling setRuleset directly here made this a second
// write path that silently skipped the shard-name normalization the live frame
// gets. One writer, one set of rules.
const ruleset = await uoLinkClient.getRuleset()
if (ruleset.ok && ruleset.data && ruleset.data.ruleset) {
await shardState.setRuleset(ruleset.data.ruleset)
await shardIngest.ingest(ruleset.data.ruleset, { fromBackfill: true })
log.info('snapshotted shard ruleset from /ruleset', { rev: ruleset.data.ruleset.rev })
}

View File

@@ -96,3 +96,66 @@ test('a setRuleset failure does not throw or stop the broadcast', async () => {
assert.equal(r.logged, false)
assert.equal(deps.calls.broadcast.length, 1)
})
// ── Shard name fallback ────────────────────────────────────────────────────
//
// ServUO ships Server.cfg with `Name=My Shard`. An operator who never edited it
// publishes that verbatim, which says "unnamed" rather than naming anything — so
// the site answers with its own instance name instead of printing the stock
// default under a header carrying the real one.
//
// Applied at INGEST, not on read, because world.ruleset is also broadcast live:
// the same object goes to the SSE fan-out, so a read-time fix would be undone by
// the next reconnect's frame. These tests assert both halves.
function withSettings(deps, name) {
return { ...deps, settings: { getInstanceName: async () => name } }
}
test('the stock ServUO shard name is replaced with the instance name', async () => {
const deps = makeDeps()
await shardIngest.ingest({ ...FRAME, shard: 'My Shard' }, withSettings(deps, 'UOMysticmoon'))
assert.equal(deps.calls.rulesetSet[0].shard, 'UOMysticmoon')
})
test('the substituted name reaches the live broadcast, not just the store', async () => {
const deps = makeDeps()
await shardIngest.ingest({ ...FRAME, shard: 'My Shard' }, withSettings(deps, 'UOMysticmoon'))
assert.equal(deps.calls.broadcast.length, 1)
assert.equal(deps.calls.broadcast[0].shard, 'UOMysticmoon')
})
test('a missing or blank shard name gets the same treatment', async () => {
for (const shard of [undefined, null, '', ' ']) {
const deps = makeDeps()
await shardIngest.ingest({ ...FRAME, shard }, withSettings(deps, 'UOMysticmoon'))
assert.equal(deps.calls.rulesetSet[0].shard, 'UOMysticmoon')
}
})
// The match is on the whole value, case- and padding-insensitive. A shard that
// deliberately calls itself "My Shard Reborn" has named itself and keeps it.
test('a real name that merely contains the stock one is left alone', async () => {
const deps = makeDeps()
await shardIngest.ingest({ ...FRAME, shard: 'My Shard Reborn' }, withSettings(deps, 'UOMysticmoon'))
assert.equal(deps.calls.rulesetSet[0].shard, 'My Shard Reborn')
const padded = makeDeps()
await shardIngest.ingest({ ...FRAME, shard: ' MY SHARD ' }, withSettings(padded, 'UOMysticmoon'))
assert.equal(padded.calls.rulesetSet[0].shard, 'UOMysticmoon')
})
test('a shard that named itself is never overridden by the brand', async () => {
const deps = makeDeps()
await shardIngest.ingest(FRAME, withSettings(deps, 'Some Other Brand'))
assert.equal(deps.calls.rulesetSet[0].shard, 'UOMysticmoon')
})
// The settings read is a DB call on a path that must never fail ingest.
test('a settings read failure leaves the frame storable', async () => {
const deps = makeDeps()
const boom = { ...deps, settings: { getInstanceName: async () => { throw new Error('db down') } } }
await shardIngest.ingest({ ...FRAME, shard: 'My Shard' }, boom)
assert.equal(deps.calls.rulesetSet.length, 1)
assert.equal(deps.calls.rulesetSet[0].shard, 'My Shard')
})