feat(shard): ingest points.board and publish the leaderboards #114

Merged
whitlocktech merged 1 commits from feat/points-board into edge 2026-07-29 07:52:54 +00:00
Member

What & why

Protocol 3.0 §7 (docs/link/v3.md), order 4 of 6. 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.

Part of a four-repo change: servuo-plugins #4 → link #18 → website (this) → docs #69.

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 instead.
  • 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. Logging would grow shard_events without bound for something whose only interesting value is its latest version.
  • 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, which is the right answer for a month-scale standing.
  • 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 — please look at this one

Part A pre-wired FEATURES.leaderboards.fields = { characterName: 'anonymous' }, and v3.md §7.4 specifies that name. But projectValue matches on the literal JSON key, and the wire key for a ranked player is name. As written the rule was inert: an admin tightening character names would have got no enforcement and no error.

That is precisely the failure §3.6.1 records for the flattened ownerAcct spelling — a rule named for the field's meaning rather than its key. Fixed to key on name, with a test that fails if it is renamed back, and the admin panel's FIELD_LABEL carries the meaning to the operator instead ("Character names on leaderboards"). Within a leaderboards payload name can only be a character name — the board's own display name arrives as nameString/nameNumber.

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. The filter matches board name, system key, or any ranked player — the last is what makes it useful ("where do I appear, and who's around me?").
  • 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 systems and cliloc-only names are the majority case (see servuo-plugins #4).

How it was tested

Verified end to end against the local MariaDB, the Rust sidecar from #18, and the real ServUO shard — not just unit tests.

Full chain — fake shard → sidecar (TCP) → sidecar store + WS → uoLinkSocketshardIngest → MariaDB → public REST → browser:

  • backfill landed (snapshotted points boards from /points {"count":2});
  • live SSE: a board absent from the initial fetch appeared without a reload, and an existing board updated in place (participants 77 → 78, its top list replaced) — confirming latest-wins merge by system;
  • REST reflected the overwrite rather than accumulating;
  • the real shard fed five genuine boards through the same path.

The visibility gate, exercised live at every rung (config toggled in the DB, endpoint re-read anonymously each time):

Config Result
default 200, ranked names present
fieldRules: { name: 'staff' } names stripped, points retained
audience: 'staff' 403, and dropped from /features so nav hides
enabled: false 404 (does not leak that it exists)
reset back to 200 with names

Pages — rendered at /site/leaderboards: podium colours, ranks 1–10, per-board participant counts, uncapped vs capped boards, the empty-board state ("Nobody has earned points here yet"), the cliloc fallback rendering CleanUpBritannia → "Clean Up Britannia", and player-name filtering. No console errors beyond the pre-existing React Router v7 future-flag warnings.

Suites605 server tests pass (7 new in shardIngest.points.test.js, 6 new controller tests, 2 new visibility tests including the guard on the name rule above). Client builds clean. routes.manifest.json, routes.guards.json and the OpenAPI spec regenerated and committed.

Not covered by an automated test: the CharacterSheet points section is presentational React with no DOM test harness in this repo (the client suite covers pure-logic modules only). It builds clean and follows the skills section directly above it, but it was not rendered against a live linked-player profile — that needs a logged-in player with a linked game account and a shard answering a profile RPC.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • No AI tools were used to produce this contribution.
  • AI tools were used. Tool(s): Claude Code (Opus 5). I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a Co-Authored-By trailer.

License

  • I agree that my contribution is licensed under this project's license (GNU GPL v3.0 or later), and I have the right to contribute it.
## What & why Protocol 3.0 **§7** ([`docs/link/v3.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/link/v3.md)), order 4 of 6. 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. Part of a four-repo change: servuo-plugins #4 → link #18 → website (this) → docs #69. ### 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` instead. - **`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. Logging would grow `shard_events` without bound for something whose only interesting value is its latest version. - **`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, which is the right answer for a month-scale standing. - **`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` — please look at this one Part A pre-wired `FEATURES.leaderboards.fields = { characterName: 'anonymous' }`, and v3.md §7.4 specifies that name. But **`projectValue` matches on the literal JSON key**, and the wire key for a ranked player is `name`. As written the rule was **inert**: an admin tightening character names would have got no enforcement and no error. That is precisely the failure §3.6.1 records for the flattened `ownerAcct` spelling — a rule named for the field's *meaning* rather than its *key*. Fixed to key on `name`, with a test that fails if it is renamed back, and the admin panel's `FIELD_LABEL` carries the meaning to the operator instead ("Character names on leaderboards"). Within a leaderboards payload `name` can only be a character name — the board's own display name arrives as `nameString`/`nameNumber`. ### 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. The filter matches board name, system key, *or any ranked player* — the last is what makes it useful ("where do I appear, and who's around me?"). - 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 systems and cliloc-only names are the **majority** case (see servuo-plugins #4). ## How it was tested Verified end to end against the local MariaDB, the Rust sidecar from #18, **and the real ServUO shard** — not just unit tests. **Full chain** — fake shard → sidecar (TCP) → sidecar store + WS → `uoLinkSocket` → `shardIngest` → MariaDB → public REST → browser: - backfill landed (`snapshotted points boards from /points {"count":2}`); - **live SSE**: a board absent from the initial fetch appeared *without a reload*, and an existing board updated in place (participants 77 → 78, its `top` list replaced) — confirming latest-wins merge by system; - REST reflected the overwrite rather than accumulating; - the real shard fed five genuine boards through the same path. **The visibility gate, exercised live at every rung** (config toggled in the DB, endpoint re-read anonymously each time): | Config | Result | |---|---| | default | `200`, ranked names present | | `fieldRules: { name: 'staff' }` | names **stripped**, points retained | | `audience: 'staff'` | `403`, **and** dropped from `/features` so nav hides | | `enabled: false` | `404` (does not leak that it exists) | | reset | back to `200` with names | **Pages** — rendered at `/site/leaderboards`: podium colours, ranks 1–10, per-board participant counts, uncapped vs capped boards, the empty-board state ("Nobody has earned points here yet"), the cliloc fallback rendering `CleanUpBritannia` → "Clean Up Britannia", and player-name filtering. No console errors beyond the pre-existing React Router v7 future-flag warnings. **Suites** — `605 server tests pass` (7 new in `shardIngest.points.test.js`, 6 new controller tests, 2 new visibility tests including the guard on the `name` rule above). Client builds clean. `routes.manifest.json`, `routes.guards.json` and the OpenAPI spec regenerated and committed. **Not covered by an automated test:** the `CharacterSheet` points section is presentational React with no DOM test harness in this repo (the client suite covers pure-logic modules only). It builds clean and follows the skills section directly above it, but it was not rendered against a live linked-player profile — that needs a logged-in player with a linked game account and a shard answering a profile RPC. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [ ] No AI tools were used to produce this contribution. - [x] AI tools were used. Tool(s): `Claude Code (Opus 5)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it.
wtclaude added 1 commit 2026-07-29 02:07:09 +00:00
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>
whitlocktech approved these changes 2026-07-29 07:52:46 +00:00
whitlocktech merged commit 1e1a3d67c3 into edge 2026-07-29 07:52:54 +00:00
whitlocktech deleted branch feat/points-board 2026-07-29 07:52:55 +00:00
Sign in to join this conversation.
No description provided.