feat(shard): ingest points.board and publish the leaderboards
Protocol 3.0 §7 (docs/link/v3.md). The shard publishes ~25 points/loyalty
leaderboards — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up
Britannia — and the site renders them, plus each character's own standings on
their sheet.
Server
- shard_points_boards: one row per system, keyed by the shard's PointsType
name. The top-N list stays inside `payload` — a fixed-size list read whole,
exactly like shard_governors.candidates. Normalizing into an entries table
buys nothing until something needs a per-character reverse lookup, and a
character's own standings already ride inside char.profile.
- shardIngest routes points.board to upsertPointsBoard and deliberately does
NOT log it: this is board state like guild.update, and the shard emits a
frame every time anyone's score moves a top ten.
- uoLinkSocket backfills /points through snapshot() with ingestEach rather
than a replace*: there is no points.remove and the system set is fixed, so
upserting IS the reconciliation, and a system the operator later excludes
keeps its last-known board rather than vanishing.
- GET /public/shard/points and /points/:system behind
requireFeature('leaderboards'), both projected per §3.6.1. :system is
constrained to an identifier before any query runs; 404 for a system never
published, distinct from a published board nobody has scored in (200, empty
top).
The leaderboards field rule now keys on `name`, not `characterName`
Part A pre-wired FEATURES.leaderboards.fields = { characterName: ... }, but
projectValue matches on the LITERAL JSON key and the wire key is `name`. As
written the rule was inert: an admin tightening character names would have got
no enforcement and no error — precisely the failure §3.6.1 records for the
flattened `ownerAcct` spelling. Fixed, with a test that fails if it is renamed
back, and the admin panel's FIELD_LABEL carries the meaning instead.
Client
- routes/public/Leaderboards.jsx at /site/leaderboards. A points.board frame
describes ONE system, so live frames merge over the fetched set by system
key rather than replacing it wholesale the way the ruleset does. Filter
matches board name, system key, or any ranked player — the last is what
makes it useful ("where do I appear?").
- A "Loyalty & Points" section in CharacterSheet.jsx, one edit serving both
PlayerCharacter and AdminCharacter.
- Both treat maxPoints: 0 as UNCAPPED and both fall back to humanising the
system key when nameString is null. Neither is defensive padding: on a real
shard uncapped and cliloc-only names are the majority case.
Verified end to end against the local MariaDB, the Rust sidecar, and the real
ServUO shard: backfill from /points, live SSE delivery (a board absent from the
initial fetch appearing without a reload, and an existing one updating in
place), REST reflecting the overwrite, and the gate at every rung — 200 by
default with names, names stripped but points kept at fieldRules name=staff, 403
plus dropped from /features at audience=staff, 404 when disabled. Page rendered
clean, no console errors beyond the pre-existing React Router v7 warnings.
605 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:
@@ -295,6 +295,85 @@ test('getRuleset projects: acct/webId never survive below admin', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── points boards ──────────────────────────────────────────────────────
|
||||
const BOARD = {
|
||||
system: 'QueensLoyalty',
|
||||
nameString: "Queen's Loyalty",
|
||||
nameNumber: 1114938,
|
||||
maxPoints: 30000,
|
||||
players: 842,
|
||||
top: [
|
||||
{ rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 },
|
||||
{ rank: 2, serial: '0x1A2C', name: 'Mireille', points: 21000 },
|
||||
],
|
||||
}
|
||||
|
||||
test('getPointsBoards serves every board with its ranked list intact', async () => {
|
||||
shardState.listPointsBoards = async () => [BOARD]
|
||||
const res = mockRes()
|
||||
await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res)
|
||||
assert.equal(res.body.length, 1)
|
||||
assert.equal(res.body[0].system, 'QueensLoyalty')
|
||||
// The ranked list is an ARRAY through projection, not an object keyed 0/1 —
|
||||
// the same trap the ruleset's rankThresholds assertion guards.
|
||||
assert.ok(Array.isArray(res.body[0].top))
|
||||
assert.equal(res.body[0].top[1].name, 'Mireille')
|
||||
})
|
||||
|
||||
test('getPointsBoards serves an empty list before the shard has published any', async () => {
|
||||
shardState.listPointsBoards = async () => []
|
||||
const res = mockRes()
|
||||
await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res)
|
||||
assert.deepEqual(res.body, [])
|
||||
assert.equal(res.statusCode, 200)
|
||||
})
|
||||
|
||||
// §3.6.1's rule again: a shard read that does not project is a bug. Boards carry
|
||||
// no actor today — they write entries inline as {serial, name} precisely so they
|
||||
// never carry acct/webId — but the gate is what keeps that true if the shape grows.
|
||||
test('getPointsBoard projects: acct/webId never survive below admin', async () => {
|
||||
shardState.getPointsBoard = async () => ({
|
||||
system: 'QueensLoyalty',
|
||||
top: [{ rank: 1, name: 'Darrow', acct: 'darrow_acct', webId: 9, points: 1 }],
|
||||
})
|
||||
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
||||
const res = mockRes()
|
||||
await ctrl.getPointsBoard({ params: { system: 'QueensLoyalty' }, viewerLevel: level }, res)
|
||||
assert.equal(res.body.top[0].acct, undefined, `${level} saw acct`)
|
||||
assert.equal(res.body.top[0].webId, undefined, `${level} saw webId`)
|
||||
assert.equal(res.body.top[0].name, 'Darrow', 'the ranked name is public by default')
|
||||
}
|
||||
})
|
||||
|
||||
// "No such system" and "a board nobody has scored in" are different answers.
|
||||
test('getPointsBoard 404s for a system the shard has never published', async () => {
|
||||
shardState.getPointsBoard = async () => null
|
||||
const res = mockRes()
|
||||
await ctrl.getPointsBoard({ params: { system: 'NoSuchSystem' }, viewerLevel: 'anonymous' }, res)
|
||||
assert.equal(res.statusCode, 404)
|
||||
})
|
||||
|
||||
test('getPointsBoard rejects a malformed system name before touching the model', async () => {
|
||||
let queried = false
|
||||
shardState.getPointsBoard = async () => { queried = true; return null }
|
||||
for (const system of ['../etc', 'a'.repeat(64), '', 'has space', '1leading']) {
|
||||
const res = mockRes()
|
||||
await ctrl.getPointsBoard({ params: { system }, viewerLevel: 'anonymous' }, res)
|
||||
assert.equal(res.statusCode, 400, `${JSON.stringify(system)} should be rejected`)
|
||||
}
|
||||
assert.equal(queried, false, 'a malformed name must never reach the query')
|
||||
})
|
||||
|
||||
test('getPointsBoards degrades to a 500 when the model fails, without throwing', async () => {
|
||||
shardState.listPointsBoards = async () => {
|
||||
throw new Error('pool down')
|
||||
}
|
||||
const res = mockRes()
|
||||
await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res)
|
||||
assert.equal(res.statusCode, 500)
|
||||
assert.equal(res.body.message, 'Internal Server Error')
|
||||
})
|
||||
|
||||
test('getRuleset degrades to a 500 when the model fails, without throwing', async () => {
|
||||
shardState.getRuleset = async () => {
|
||||
throw new Error('pool down')
|
||||
|
||||
111
server/test/shardIngest.points.test.js
Normal file
111
server/test/shardIngest.points.test.js
Normal file
@@ -0,0 +1,111 @@
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const shardIngest = require('../src/utils/shardIngest')
|
||||
|
||||
// Protocol 3.0 points.board routing. Same shape as shardIngest.ruleset.test.js:
|
||||
// stubbed deps, asserting where the dispatcher sends the frame and whether it is
|
||||
// appended to the event log.
|
||||
function makeDeps() {
|
||||
const calls = { boards: [], appended: [], broadcast: [] }
|
||||
const noop = async () => {}
|
||||
return {
|
||||
calls,
|
||||
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
|
||||
shardState: {
|
||||
upsertPointsBoard: async (ev) => { calls.boards.push(ev) },
|
||||
// Present so any stray routing is a harmless no-op.
|
||||
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
|
||||
addEconomySample: noop, setRuleset: noop,
|
||||
},
|
||||
shardLinks: { removeByAccount: noop },
|
||||
uoLinkConfig: { recordStatus: noop },
|
||||
broadcast: (ev) => { calls.broadcast.push(ev) },
|
||||
pushDispatch: async () => {},
|
||||
log: { warn() {}, info() {}, error() {} },
|
||||
}
|
||||
}
|
||||
|
||||
const FRAME = {
|
||||
kind: 'points.board',
|
||||
t: 1000,
|
||||
system: 'QueensLoyalty',
|
||||
nameString: "Queen's Loyalty",
|
||||
nameNumber: 1114938,
|
||||
maxPoints: 30000,
|
||||
showOnGump: true,
|
||||
players: 842,
|
||||
top: [
|
||||
{ rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 },
|
||||
{ rank: 2, serial: '0x1A2C', name: 'Mireille', points: 21000 },
|
||||
],
|
||||
}
|
||||
|
||||
beforeEach(() => shardIngest.reset())
|
||||
|
||||
test('points.board routes to upsertPointsBoard with the whole frame', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, deps)
|
||||
assert.equal(deps.calls.boards.length, 1)
|
||||
const stored = deps.calls.boards[0]
|
||||
assert.equal(stored.system, 'QueensLoyalty')
|
||||
assert.equal(stored.nameNumber, 1114938)
|
||||
assert.equal(stored.players, 842)
|
||||
// The ranked list must survive intact — the read model serves it from the payload.
|
||||
assert.equal(stored.top.length, 2)
|
||||
assert.equal(stored.top[0].name, 'Darrow')
|
||||
})
|
||||
|
||||
// A board is state, not an event. The shard emits a frame every time anyone's
|
||||
// score moves the top ten, so logging would grow shard_events without bound for
|
||||
// something whose only interesting value is its latest version — the same call
|
||||
// guild.update already makes.
|
||||
test('points.board 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('points.board'), false)
|
||||
})
|
||||
|
||||
test('points.board is broadcast (the leaderboards 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, 'points.board')
|
||||
})
|
||||
|
||||
test('a backfilled points.board still stores but does not broadcast', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, { ...deps, fromBackfill: true })
|
||||
assert.equal(deps.calls.boards.length, 1)
|
||||
assert.equal(deps.calls.broadcast.length, 0)
|
||||
})
|
||||
|
||||
// Each system is its own row, so two systems must not collide — this is the whole
|
||||
// reason the frame is per-system rather than one board of everything.
|
||||
test('two systems are stored independently', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, deps)
|
||||
await shardIngest.ingest({ ...FRAME, system: 'CleanUpBritannia', nameString: null }, deps)
|
||||
assert.deepEqual(deps.calls.boards.map((b) => b.system), ['QueensLoyalty', 'CleanUpBritannia'])
|
||||
})
|
||||
|
||||
// A re-emitted board is an overwrite of one row, never an append.
|
||||
test('a repeated points.board overwrites rather than accumulating', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, deps)
|
||||
await shardIngest.ingest({ ...FRAME, t: 2000, players: 843 }, deps)
|
||||
assert.equal(deps.calls.appended.length, 0)
|
||||
assert.equal(deps.calls.boards.length, 2) // two writes...
|
||||
assert.equal(deps.calls.boards[1].system, 'QueensLoyalty') // ...of the same row
|
||||
})
|
||||
|
||||
// A model write that throws must not kill the feed.
|
||||
test('an upsertPointsBoard failure does not throw or stop the broadcast', async () => {
|
||||
const deps = makeDeps()
|
||||
deps.shardState.upsertPointsBoard = 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)
|
||||
})
|
||||
@@ -150,6 +150,49 @@ test('rule 1 matches FLATTENED spellings, not just the two canonical keys', () =
|
||||
assert.equal(asAdmin.ownerAcct, 'cadmus_acct')
|
||||
})
|
||||
|
||||
// ── Protocol 3.0 leaderboards ──────────────────────────────────────────────
|
||||
//
|
||||
// The leaderboards field rule is spelled `name` because that is the key
|
||||
// points.board actually puts a ranked character's name under. v3.md §7.4 calls it
|
||||
// "characterName", which describes the meaning — and projectValue matches on the
|
||||
// literal key, so a rule under that spelling would have been silently inert. This
|
||||
// is the same failure mode §3.6.1 records for the flattened `ownerAcct`, and this
|
||||
// test is the guard on it: if someone renames the rule back, an admin who tightens
|
||||
// character names would get no enforcement and no error.
|
||||
test('a tightened leaderboards name rule actually strips ranked character names', async () => {
|
||||
withRows([
|
||||
{ feature: 'leaderboards', enabled: true, audience: 'anonymous', stream: true, fieldRules: { name: 'logged_in' } },
|
||||
])
|
||||
const config = await visibility.getConfig()
|
||||
const board = {
|
||||
system: 'QueensLoyalty',
|
||||
nameString: "Queen's Loyalty",
|
||||
top: [{ rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 }],
|
||||
}
|
||||
|
||||
const anon = visibility.projectFeature('leaderboards', board, 'anonymous', config)
|
||||
assert.equal('name' in anon.top[0], false, 'anonymous must not see the ranked name')
|
||||
assert.equal(anon.top[0].points, 29500, 'the rest of the entry survives')
|
||||
// The BOARD's own display name is a different key and must not be caught by it.
|
||||
assert.equal(anon.nameString, "Queen's Loyalty")
|
||||
|
||||
const member = visibility.projectFeature('leaderboards', board, 'logged_in', config)
|
||||
assert.equal(member.top[0].name, 'Darrow')
|
||||
})
|
||||
|
||||
// Default config: boards are public, exactly as v3.md §7 specifies.
|
||||
test('leaderboards are anonymous-visible by default, names included', () => {
|
||||
const config = visibility.compileDefaults()
|
||||
const out = visibility.projectFeature(
|
||||
'leaderboards',
|
||||
{ top: [{ rank: 1, name: 'Darrow', points: 1 }] },
|
||||
'anonymous',
|
||||
config,
|
||||
)
|
||||
assert.equal(out.top[0].name, 'Darrow')
|
||||
assert.equal(visibility.kindVisibleTo('points.board', 'anonymous', config), true)
|
||||
})
|
||||
|
||||
test('isLockedField locks acct/webId and their suffixed forms, and nothing else', () => {
|
||||
for (const key of ['acct', 'webId', 'WEBID', 'ownerAcct', 'leaderWebId', 'governorAcct']) {
|
||||
assert.equal(visibility.isLockedField(key), true, `${key} must be locked`)
|
||||
|
||||
Reference in New Issue
Block a user