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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user