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:
98
server/test/shardIngest.ruleset.test.js
Normal file
98
server/test/shardIngest.ruleset.test.js
Normal file
@@ -0,0 +1,98 @@
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const shardIngest = require('../src/utils/shardIngest')
|
||||
|
||||
// Protocol 3.0 world.ruleset routing. Same shape as shardIngest.protocol2.test.js:
|
||||
// stubbed deps, asserting where the dispatcher sends the frame and whether it is
|
||||
// appended to the event log.
|
||||
function makeDeps() {
|
||||
const calls = { rulesetSet: [], appended: [], broadcast: [] }
|
||||
const noop = async () => {}
|
||||
return {
|
||||
calls,
|
||||
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
|
||||
shardState: {
|
||||
setRuleset: async (ev) => { calls.rulesetSet.push(ev) },
|
||||
// Present so any stray routing is a harmless no-op.
|
||||
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
|
||||
addEconomySample: noop,
|
||||
},
|
||||
shardLinks: { removeByAccount: noop },
|
||||
uoLinkConfig: { recordStatus: noop },
|
||||
broadcast: (ev) => { calls.broadcast.push(ev) },
|
||||
pushDispatch: async () => {},
|
||||
log: { warn() {}, info() {}, error() {} },
|
||||
}
|
||||
}
|
||||
|
||||
const FRAME = {
|
||||
kind: 'world.ruleset',
|
||||
t: 1000,
|
||||
rev: '1a2b3c4d',
|
||||
shard: 'UOMysticmoon',
|
||||
expansion: 'EJ',
|
||||
systems: { cityLoyalty: true, vvv: true, factions: false },
|
||||
caps: { skill: 1000, totalSkill: 7000 },
|
||||
}
|
||||
|
||||
beforeEach(() => shardIngest.reset())
|
||||
|
||||
test('world.ruleset routes to setRuleset with the whole frame', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, deps)
|
||||
assert.equal(deps.calls.rulesetSet.length, 1)
|
||||
const stored = deps.calls.rulesetSet[0]
|
||||
assert.equal(stored.rev, '1a2b3c4d')
|
||||
assert.equal(stored.expansion, 'EJ')
|
||||
// The nested blocks must survive intact — the read model serves the frame whole.
|
||||
assert.equal(stored.systems.vvv, true)
|
||||
assert.equal(stored.caps.totalSkill, 7000)
|
||||
})
|
||||
|
||||
// The shard re-emits world.ruleset on EVERY sidecar connect. Logging it would put
|
||||
// a duplicate row in shard_events per reconnect, and server.hello already marks
|
||||
// each of those — so this assertion is the guard on that decision.
|
||||
test('world.ruleset is NOT appended to the event log', async () => {
|
||||
const deps = makeDeps()
|
||||
const r = await shardIngest.ingest(FRAME, deps)
|
||||
assert.equal(r.logged, false)
|
||||
assert.equal(deps.calls.appended.length, 0)
|
||||
assert.equal(shardIngest.LOGGED_KINDS.has('world.ruleset'), false)
|
||||
})
|
||||
|
||||
test('world.ruleset is broadcast (the rules page updates live)', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, deps)
|
||||
assert.equal(deps.calls.broadcast.length, 1)
|
||||
assert.equal(deps.calls.broadcast[0].kind, 'world.ruleset')
|
||||
})
|
||||
|
||||
// A backfill replay must reach the store but must NOT re-animate the live ticker.
|
||||
test('a backfilled world.ruleset still stores but does not broadcast', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, { ...deps, fromBackfill: true })
|
||||
assert.equal(deps.calls.rulesetSet.length, 1)
|
||||
assert.equal(deps.calls.broadcast.length, 0)
|
||||
})
|
||||
|
||||
// A re-emitted identical ruleset is an overwrite, not an append: two ingests of
|
||||
// the same rev leave one stored frame's worth of state, never a growing log.
|
||||
test('a repeated world.ruleset overwrites rather than accumulating', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, deps)
|
||||
await shardIngest.ingest({ ...FRAME, t: 2000 }, deps)
|
||||
assert.equal(deps.calls.appended.length, 0)
|
||||
assert.equal(deps.calls.rulesetSet.length, 2) // two writes...
|
||||
assert.equal(deps.calls.rulesetSet[1].rev, '1a2b3c4d') // ...of the same singleton
|
||||
})
|
||||
|
||||
// A model write that throws must not kill the feed — ingest swallows it and the
|
||||
// frame is still broadcast.
|
||||
test('a setRuleset failure does not throw or stop the broadcast', async () => {
|
||||
const deps = makeDeps()
|
||||
deps.shardState.setRuleset = async () => { throw new Error('db down') }
|
||||
const r = await shardIngest.ingest(FRAME, deps)
|
||||
assert.equal(r.logged, false)
|
||||
assert.equal(deps.calls.broadcast.length, 1)
|
||||
})
|
||||
Reference in New Issue
Block a user