feat(shard): ingest world.ruleset and publish it at /site/rules

Protocol 3.0 §5 (docs/link/v3.md). The shard publishes its own ruleset —
expansion, which optional systems are on, skill/stat caps, account and house
limits, champion scroll rules, the save/restart schedule — and the site renders
it, so the rules page cannot drift from how the shard actually plays.

Server
  - shard_ruleset: a singleton table (id = 1) holding the whole frame in
    `payload`, with `rev` and `expansion` hoisted. Nothing is normalized out:
    the frame is a flat description of config read as one page, and splitting it
    into columns would mean a schema change every time the shard grows a block.
  - shardIngest routes world.ruleset to setRuleset and deliberately does NOT
    log it — the shard re-emits the whole ruleset on every sidecar connect, so
    logging would append a duplicate row per reconnect, and server.hello already
    marks each of those.
  - uoLinkSocket backfills GET /ruleset explicitly rather than via snapshot(),
    which asserts an array; this covers the order where the sidecar was already
    up and holding the ruleset when we reconnected.
  - GET /public/shard/ruleset behind requireFeature('ruleset') and projected,
    per §3.6.1's rule that a shard read which doesn't project is a bug. `null`
    means the shard has never published one — a real answer, distinct from a
    published ruleset, and the page says so.

Client
  - routes/public/Rules.jsx at /site/rules, live via world.ruleset (a frame is a
    complete ruleset, not a delta, so the newest one wins outright). Caps are
    rendered from tenths — 7000 is 700.0, and showing the raw number would
    mislead. A systems key this build doesn't know still renders, humanised, so
    a newer plugin can't go invisible against an older client.
  - Nav entry gated on the `ruleset` feature, so it hides rather than 403s.

Verified end to end against the local MariaDB and a sidecar fed by a fake shard:
backfill snapshot, live SSE delivery of a changed ruleset, REST reflecting the
overwrite, an empty /feed (not logged), and the gate — 200 by default, 403 at
audience=staff (and dropped from /features so nav hides it), 404 when disabled.
Page rendered clean at all breakpoints checked, no console errors.

497 server tests pass; routes.manifest.json, routes.guards.json and the OpenAPI
spec regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-28 14:35:24 -05:00
parent 6b1396dd2f
commit 61d6bfaca2
17 changed files with 677 additions and 0 deletions

View File

@@ -56,6 +56,7 @@ const originals = {
listIdoc: shardState.listIdoc,
onlineCount: shardState.onlineCount,
latestEconomy: shardState.latestEconomy,
getRuleset: shardState.getRuleset,
getSafe: uoLinkConfig.getSafe,
}
afterEach(() => {
@@ -63,6 +64,7 @@ afterEach(() => {
shardState.listIdoc = originals.listIdoc
shardState.onlineCount = originals.onlineCount
shardState.latestEconomy = originals.latestEconomy
shardState.getRuleset = originals.getRuleset
uoLinkConfig.getSafe = originals.getSafe
})
@@ -243,6 +245,66 @@ test('getIdoc preserves Date columns rather than flattening them to {}', async (
assert.equal(res.body[0].updatedAt.toISOString(), when.toISOString())
})
// ── getRuleset: "never published" is a real answer ──────────────────────
test('getRuleset serves null when the shard has never published a ruleset', async () => {
shardState.getRuleset = async () => null
const res = mockRes()
await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res)
// Deliberately null, not {} — the page says "not published yet" rather than
// rendering an empty ruleset as though the shard had no rules.
assert.equal(res.body, null)
assert.equal(res.statusCode, 200)
})
test('getRuleset serves the published ruleset whole, nested blocks intact', async () => {
shardState.getRuleset = async () => ({
kind: 'world.ruleset',
rev: '1a2b3c4d',
shard: 'UOMysticmoon',
expansion: 'EJ',
systems: { cityLoyalty: true, vvv: true, factions: false },
caps: { skill: 1000, totalSkill: 7000, stat: 225 },
champions: { powerScrolls: 6, rankThresholds: [5, 10, 13] },
})
const res = mockRes()
await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res)
assert.equal(res.body.expansion, 'EJ')
assert.equal(res.body.systems.vvv, true)
assert.equal(res.body.caps.totalSkill, 7000)
// Arrays must survive projection as arrays, not become objects.
assert.deepEqual(res.body.champions.rankThresholds, [5, 10, 13])
})
// §3.6.1's rule: a read path that returns shard data and does not project is a
// bug. The ruleset frame carries no actor today, but it goes through the same
// gate — so a future block that does cannot leak.
test('getRuleset projects: acct/webId never survive below admin', async () => {
shardState.getRuleset = async () => ({
expansion: 'EJ',
connect: 'play.example.com,2593',
owner: { name: 'Lord British', acct: 'lb_acct', webId: 7 },
})
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
const res = mockRes()
await ctrl.getRuleset({ viewerLevel: level }, res)
assert.equal(res.body.owner.acct, undefined, `${level} saw acct`)
assert.equal(res.body.owner.webId, undefined, `${level} saw webId`)
// `connect` defaults to the anonymous rung: an operator who published it
// meant it to be readable.
assert.equal(res.body.connect, 'play.example.com,2593')
}
})
test('getRuleset degrades to a 500 when the model fails, without throwing', async () => {
shardState.getRuleset = async () => {
throw new Error('pool down')
}
const res = mockRes()
await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res)
assert.equal(res.statusCode, 500)
assert.equal(res.body.message, 'Internal Server Error')
})
// ── getStatus assembles the summary ─────────────────────────────────────
test('getStatus merges the sidecar config with the online count and latest economy', async () => {
uoLinkConfig.getSafe = async () => ({