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

@@ -615,6 +615,31 @@ CREATE TABLE IF NOT EXISTS shard_ruleset (
CONSTRAINT chk_shard_ruleset_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Points/loyalty leaderboards (Protocol 3.0 points.board). One row per point
-- system, keyed by the shard's own PointsType name. The shard publishes ~25 of
-- these (Queen's Loyalty, Void Pool, the nine city loyalties, …), each a standing
-- players accumulate over months.
--
-- The top-N list stays inside `payload` rather than being normalized into a
-- shard_points_entries table. It is a fixed-size list (10 by default) that is only
-- ever read whole, exactly like shard_governors.candidates — normalizing it would
-- buy nothing until something needs a per-character reverse lookup, and a
-- character's own standings already ride inside char.profile instead.
--
-- No delete path: the shard's set of systems is fixed at startup, so there is no
-- points.remove to mirror.
CREATE TABLE IF NOT EXISTS shard_points_boards (
system VARCHAR(48) PRIMARY KEY, -- PointsType name, e.g. QueensLoyalty
name VARCHAR(128) NULL, -- resolved display name, if the shard sent a literal
name_cliloc INT NULL, -- cliloc id when the name is a TextDefinition number
max_points BIGINT NULL,
players INT NULL, -- players actually holding points in this system
show_on_gump TINYINT(1) NOT NULL DEFAULT 1, -- the shard's own "is this player-facing?" flag
payload JSON NOT NULL, -- the whole points.board frame, incl. `top`
t BIGINT NULL, -- frame time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Per-feature visibility for every shard-derived surface (Protocol 3.0). One row
-- per feature; an absent row means "use the compiled default", and the compiled
-- defaults reproduce the behavior that shipped before v3 — so an empty table is