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:
2026-07-28 21:04:44 -05:00
parent bfa1db58c4
commit 26094459ae
22 changed files with 1098 additions and 2 deletions

View File

@@ -278,6 +278,50 @@ async function getRuleset() {
return rows[0] || null
}
// ── Points/loyalty boards (Protocol 3.0 points.board) ──────────────────────
// One row per point system. The shard only emits a system whose top N actually
// moved, so this is a sparse stream of overwrites; there is no delete, because
// the shard's set of systems is fixed at startup.
async function upsertPointsBoard({ system, name, nameCliloc, maxPoints, players, showOnGump, payload, t }) {
await query(
`INSERT INTO shard_points_boards
(system, name, name_cliloc, max_points, players, show_on_gump, payload, t)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), name_cliloc = VALUES(name_cliloc),
max_points = VALUES(max_points), players = VALUES(players),
show_on_gump = VALUES(show_on_gump), payload = VALUES(payload), t = VALUES(t)`,
[
system,
name ?? null,
Number.isFinite(nameCliloc) ? nameCliloc : null,
Number.isFinite(maxPoints) ? maxPoints : null,
Number.isFinite(players) ? players : null,
showOnGump ? 1 : 0,
payload,
Number.isFinite(t) ? t : null,
],
)
}
// Ordered by display name, falling back to the system key for a board whose name
// arrived as a bare cliloc — otherwise every unresolved board would sort together
// under NULL.
async function listPointsBoards() {
return query(
`SELECT system, name, name_cliloc, max_points, players, show_on_gump, payload, t, updated_at
FROM shard_points_boards ORDER BY COALESCE(name, system), system`,
)
}
async function getPointsBoard(system) {
const rows = await query(
`SELECT system, name, name_cliloc, max_points, players, show_on_gump, payload, t, updated_at
FROM shard_points_boards WHERE system = ?`,
[system],
)
return rows[0] || null
}
module.exports = {
upsertOnline,
removeOnline,
@@ -311,6 +355,9 @@ module.exports = {
latestPresence,
setRuleset,
getRuleset,
upsertPointsBoard,
listPointsBoards,
getPointsBoard,
upsertChamp,
removeChamp,
clearChamps,

View File

@@ -537,6 +537,50 @@ async function getRuleset() {
return { ...payload, updatedAt: r.updated_at }
}
// ── Points/loyalty boards (Protocol 3.0 points.board) ──────────────────────
//
// The whole frame is stored in `payload`; the columns beside it are hoisted for
// listing and ordering only. The top-N list deliberately stays inside the payload
// (see schema.sql) — it is a fixed-size list read whole, like the governor board's
// candidates.
async function upsertPointsBoard(ev) {
if (!ev || !ev.system) return
await db.upsertPointsBoard({
system: String(ev.system).slice(0, 48),
name: ev.nameString ?? null,
nameCliloc: ev.nameNumber,
maxPoints: ev.maxPoints,
players: ev.players,
showOnGump: ev.showOnGump !== false,
payload: JSON.stringify(ev),
t: ev.t,
})
}
// A stored frame plus the freshness stamp. `top` is normalized to an array so a
// caller never has to guard it — a board with nobody on it is a real state (a
// system nobody has scored in yet), distinct from a system that was never
// published at all, which is absent from the table entirely.
function shapePointsBoard(r) {
const payload = (typeof r.payload === 'string' ? safeJson(r.payload) : r.payload) || {}
return {
...payload,
system: r.system,
top: Array.isArray(payload.top) ? payload.top : [],
updatedAt: r.updated_at,
}
}
async function listPointsBoards() {
const rows = await db.listPointsBoards()
return rows.map(shapePointsBoard)
}
async function getPointsBoard(system) {
const r = await db.getPointsBoard(system)
return r ? shapePointsBoard(r) : null
}
function safeJson(s) {
try {
return JSON.parse(s)
@@ -588,4 +632,7 @@ module.exports = {
latestPresence,
setRuleset,
getRuleset,
upsertPointsBoard,
listPointsBoards,
getPointsBoard,
}

View File

@@ -262,6 +262,43 @@ async function getRuleset(req, res) {
}
}
// The shard keys boards by its own PointsType enum name (QueensLoyalty,
// CleanUpBritannia, …). Constrain the path param to that shape before it reaches
// the model: the column is VARCHAR(48), and an unbounded string here is a needless
// query on a value that can only ever be an identifier.
const SYSTEM_RE = /^[A-Za-z][A-Za-z0-9_]{0,47}$/
// GET /public/shard/points — every points/loyalty leaderboard the shard publishes.
// Served from our own store, so the page renders while the shard is down — which
// matters more here than for live state: these are standings accumulated over
// months, and blanking them during a restart would look like a data loss.
async function getPointsBoards(req, res) {
try {
const boards = await shardState.listPointsBoards()
return res.json(await visibility.project('leaderboards', boards, req))
} catch (err) {
log.error('shard.getPointsBoards', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/points/:system — one system's board.
//
// 404 for a system the shard has never published, matching the sidecar: "no such
// board" and "a board nobody is on yet" are different answers.
async function getPointsBoard(req, res) {
const { system } = req.params
if (!SYSTEM_RE.test(system)) return res.status(400).json({ message: 'Invalid points system.' })
try {
const board = await shardState.getPointsBoard(system)
if (!board) return res.status(404).json({ message: 'Unknown points system.' })
return res.json(await visibility.project('leaderboards', board, req))
} catch (err) {
log.error('shard.getPointsBoard', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/features — the shard features THIS caller can actually see,
// so the SPA (and the Android client) can hide nav entries instead of rendering
// links that 403. Deliberately reports only what the viewer may reach: the list
@@ -296,6 +333,8 @@ module.exports = {
getPresence,
getHouses,
getRuleset,
getPointsBoards,
getPointsBoard,
getFeatures,
stream,
}

View File

@@ -146,6 +146,27 @@ shardRouter.get(
/* #swagger.responses[200] = { description: 'The ruleset, or null if never published', content: { "application/json": { schema: { type: "object", nullable: true, additionalProperties: true } } } } */
shard.getRuleset,
)
shardRouter.get(
'/points',
requireFeature('leaderboards'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Points / loyalty leaderboards, one board per point system'
// #swagger.description = 'Every points/loyalty leaderboard the shard publishes (Queen\'s Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, …), each with its display name, max points, participant count and top N. Served from our own store, so it renders while the shard is down; live via points.board on /shard/stream. A board\'s display name may arrive as a literal (`nameString`) or a cliloc id (`nameNumber`) — resolve clilocs client-side.'
/* #swagger.responses[200] = { description: 'Boards, ordered by display name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardPointsBoard" } } } } } */
shard.getPointsBoards,
)
shardRouter.get(
'/points/:system',
requireFeature('leaderboards'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'One points system\'s leaderboard'
// #swagger.description = 'A single board by the shard\'s own PointsType name (e.g. `QueensLoyalty`, `CleanUpBritannia`). Returns 404 when the shard has never published that system — distinct from a published board that nobody has scored in yet, which returns 200 with an empty `top`.'
/* #swagger.parameters['system'] = { in: 'path', required: true, description: 'PointsType name, e.g. QueensLoyalty', schema: { type: 'string' } } */
/* #swagger.responses[200] = { description: 'The board', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardPointsBoard" } } } } */
/* #swagger.responses[400] = { description: 'Malformed system name' } */
/* #swagger.responses[404] = { description: 'The shard has never published that system' } */
shard.getPointsBoard,
)
shardRouter.get(
'/features',
// #swagger.tags = ['Public · Shard']

View File

@@ -181,6 +181,12 @@ async function applyStateChange(event, deps) {
case 'world.ruleset':
await shardState.setRuleset(event)
return
// Board state, like guild.update — the newest frame for a system replaces the
// previous one, so it is NOT in LOGGED_KINDS. Logging would append a row every
// time anyone's score moved the top ten, which is a board, not an event.
case 'points.board':
await shardState.upsertPointsBoard(event)
return
case 'account.unlinked':
// A player ran [unlink in game (or a site-side unlink echoed back) — drop
// our local link mirror so attribution stops immediately.

View File

@@ -102,7 +102,14 @@ const FEATURES = {
// ── New in v3. ──
ruleset: { audience: 'anonymous', fields: { connect: 'anonymous' } },
atlas: { audience: 'anonymous', fields: {} },
leaderboards: { audience: 'anonymous', fields: { characterName: 'anonymous' } },
// `name` is the ranked character's name inside points.board's `top` entries, and
// it is spelled the way the WIRE spells it, not the way v3.md §7.4 describes it
// ("characterName"). projectValue matches on the literal JSON key, so a rule
// named for the field's meaning rather than its key silently does nothing — the
// same failure §3.6.1 records for the flattened `ownerAcct` spelling. Within a
// leaderboards payload `name` can only be a character name: the board's own
// display name arrives as `nameString`/`nameNumber`.
leaderboards: { audience: 'anonymous', fields: { name: 'anonymous' } },
// Shop name, owner character name and vendor location are already globally
// visible in-game via the stock Vendor Search gump, so publishing them is not
// a new disclosure — but they stay configurable so an admin can tighten them.

View File

@@ -131,6 +131,10 @@ const getPresence = () => call('/online') // aggregate population (count + byFac
// Protocol 3.0: the shard's published ruleset. Object-shaped, not a board — the
// sidecar answers `{ ruleset: null }` until the shard has published one.
const getRuleset = () => call('/ruleset')
// Protocol 3.0: points/loyalty leaderboards. `/points` is board-shaped (an array
// under `boards`); the per-system read 404s for a system the shard never published.
const getPoints = () => call('/points')
const getPointsBoard = (system) => call(`/points/${encodeURIComponent(system)}`)
// ── Commands ──────────────────────────────────────────────────────────────
const confirmLink = (code, websiteUserId) =>
@@ -195,6 +199,8 @@ module.exports = {
getHouses,
getPresence,
getRuleset,
getPoints,
getPointsBoard,
confirmLink,
linkLookup,
createAccount,

View File

@@ -99,6 +99,13 @@ async function backfill() {
log.info('snapshotted shard ruleset from /ruleset', { rev: ruleset.data.ruleset.rev })
}
// Points boards ARE array-shaped, so they go through snapshot() — but with
// ingestEach rather than a replace*: there is no points.remove and the shard's
// system set is fixed, so upserting is the whole reconciliation. A system the
// operator has since excluded keeps its last-known board rather than vanishing,
// which is the right answer for a month-scale standing.
await snapshot(() => uoLinkClient.getPoints(), 'boards', ingestEach, 'snapshotted points boards from /points')
const presence = await uoLinkClient.getPresence()
if (presence.ok && presence.data && typeof presence.data.count === 'number') {
await shardState.setPresence(presence.data)