docs(link): points.board, the leaderboards API, and what a real shard changed

Protocol 3.0 §7 lands across servuo-plugins, link and website; this is the
matching documentation.

INTEGRATION.md
  - points.board in the event catalog: one frame per system, never a delta, no
    points.remove (the shard's system set is fixed at startup). Four gotchas
    called out, all of them things a consumer gets wrong by default:
      1. maxPoints: 0 means UNCAPPED, not "zero allowed" — and on a real shard
         it is the COMMON case, so anything rendering points/maxPoints must
         special-case it or divide by zero on the happy path.
      2. nameString is usually null, with a cliloc in nameNumber — so
         humanising the system key is the primary display path, not a fallback.
      3. players counts players actually holding points, not table size: ten of
         the ~25 systems keep a zero-point row per character ever created, so
         the raw count would report the shard's whole census.
      4. Entries carry serial + name only, never acct/webId.
  - The char.profile `points` block, and why `rank` is absent by default.
  - GET /points and /points/:system, including why 404 (never published) and
    200-with-empty-top (published, nobody scored) are different answers.

v3.md
  - B/2 marked done in both the progress table and §9.
  - NEW §7.5, "what the run against a real shard changed" — the same record §6.1
    and §6.2 keep. Four corrections the plan could not have anticipated from
    reading PointsSystem.cs, the sharpest being that (long)double.MaxValue is an
    unchecked conversion yielding long.MinValue, which published
    "maxPoints": -9223372036854775808 on the first live sweep. Also records that
    GetEntry/GetPoints mutate the world on AutoAdd systems and so cannot be used
    in a read model, and the one deliberate deviation from §7.4: the visibility
    field rule must key on the wire's `name`, not the descriptive
    `characterName`, or it is silently inert.

BACKEND_DESIGN.md — shard_points_boards (including why the top-N list stays in
the payload and why listing orders by COALESCE(name, system)), plus the two new
public routes.

PLAN.md — 3.0 phasing brought current: the spawn atlas and points.board added to
what has shipped, and the Points* keys noted in the config-key paragraph.

PROJECT_TREE.md files are deliberately untouched — they are CI-generated and say
so.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-28 21:05:04 -05:00
parent 64fb7edc3e
commit 8e857a9c8d
4 changed files with 173 additions and 7 deletions

View File

@@ -381,6 +381,74 @@ Absent entirely if the shard runs `Bridge.RulesetEnabled=false` or an older plug
This **supersedes the `world.systems` frame** sketched in [`PROTOCOL_2.md`](PROTOCOL_2.md) §10.4 and
never implemented; the `systems` block above is what that asked for.
#### Points / loyalty leaderboards (Protocol 3.0)
ServUO carries ~25 separate point currencies — Queen's Loyalty, Void Pool, Casino, Clean Up Britannia,
the nine city loyalties, Blackthorn, the Doom / Khaldun / Kotl treasure systems — every one a standing
players accumulate over months, and none of them visible outside an in-game gump before 3.0.
A diff sweep (default 300 s), **one frame per system** rather than one large frame for all of them,
matching `champ.update` / `guild.update`. A system is emitted only when its top N or its participant
count actually changes.
| kind | fields | notes |
|------|--------|-------|
| `points.board` | `system`, `nameString`, `nameNumber`, `maxPoints`, `showOnGump`, `players`, `top[]` | One system's complete board — **never a delta**. The latest frame for a `system` replaces the previous one outright. `top[]` entries are `{rank, serial, name, points}`. |
`system` is the shard's own `PointsType` enum name (`QueensLoyalty`, `CleanUpBritannia`, …) and is the
board's stable key. There is deliberately **no `points.remove`**: the set of systems is fixed at startup
by `PointsSystem.Configure`, so a system cannot disappear at runtime — the same argument `city.update`
makes for cities.
```json
{"kind":"points.board","system":"QueensLoyalty",
"nameString":"Queen's Loyalty","nameNumber":1114938,
"maxPoints":15000,"showOnGump":true,"players":842,
"top":[{"rank":1,"serial":"0x1A2B","name":"Darrow","points":29500},
{"rank":2,"serial":"0x1A2C","name":"Mireille","points":21000}],
"t":1752489280000}
```
**Four things consumers get wrong.**
1. **`maxPoints` of `0` means UNCAPPED, not "zero points allowed".** ServUO's idiom for an uncapped
system is `double.MaxValue` (`DespiseCrystals`, `ShameCrystals` and `VoidPool` all use it), which
the plugin normalises to `0` rather than emitting a nonsense integer. On a real shard **most
systems are uncapped**, so a UI that renders `points / maxPoints` must special-case this or it will
divide by zero on the common path.
2. **`nameString` is usually `null`.** The shard's `Name` is a `TextDefinition`, which may carry a
literal *or* a cliloc id, and in practice most systems use the cliloc — so `nameNumber` is set and
`nameString` is `null`. Resolve clilocs consumer-side; failing that, humanising the `system` key
("CleanUpBritannia" → "Clean Up Britannia") reads better than showing a bare number. This is the
same contract `titles.reward` already documents.
3. **`players` counts players who actually hold points**, not the size of the system's table. Ten of
the ~25 systems have `AutoAdd = true` and therefore keep a zero-point row for every character that
has ever logged in, so the raw table size would report the shard's entire character census as that
system's participants.
4. **Entries carry `serial` and `name` only — never `acct` or `webId`.** A board is the widest-audience
surface the bridge has, so the account name of every ranked player deliberately does not cross the
wire; resolve serial → site user from your own link mirror if you need it.
Absent entirely if the shard runs `Bridge.PointsLeaderboardEnabled=false` or an older plugin. Render
from `GET /points` (§6) on connect, then keep live with this event.
##### `char.profile` gains a `points` block
Read-model enrichment on the existing kind — there is **no** request kind for one character's points,
the same precedent `titles` set in [`PROTOCOL_2.md`](PROTOCOL_2.md) §10.3:
```json
"points":[{"system":"QueensLoyalty","nameString":"Queen's Loyalty","nameNumber":1114938,
"points":29500,"maxPoints":15000}]
```
Systems where the character has no entry, or an entry at zero, are **omitted** — otherwise every sheet
would carry ~25 zeroes. `maxPoints` follows the same `0 == uncapped` rule as the board.
`rank` is **absent by default** and appears only when the shard runs `Bridge.PointsProfileRank=true`:
a points lookup stops at the character's own row, but a rank must count every row that beats them, in
every system, on every profile build. Derive rank from `points.board` instead for anyone in the top N.
---
## 5. REST — read queries
@@ -740,6 +808,27 @@ worse than one that is briefly stale. Keep it current with the `world.ruleset` s
`Bridge.RulesetEnabled=false`. That is a real answer distinct from a published ruleset, and worth
rendering differently ("not published yet") rather than as an empty ruleset.
### Points / loyalty leaderboards (Protocol 3.0)
```
GET /points
→ { "boards": [ {"kind":"points.board","system":"QueensLoyalty","nameString":"Queen's Loyalty",
"nameNumber":1114938,"maxPoints":15000,"showOnGump":true,"players":842,
"top":[{"rank":1,"serial":"0x1A2B","name":"Darrow","points":29500}, ...],"t":...}, ... ] }
GET /points/{system} # e.g. /points/QueensLoyalty
→ {"kind":"points.board","system":"QueensLoyalty", ... }
```
Every system's latest board, or one by its `PointsType` name (§4 for the frame and its four gotchas).
Served from the sidecar's projection, kept current by the `points.board` stream, ordered by display
name. Survives a sidecar restart — which matters more here than for live state, since these are
standings built over months and blanking them during a restart reads as data loss.
`GET /points/{system}` returns **404** for a system the shard has never published (an unknown name, or
one excluded by `Bridge.PointsSystems`). That is distinct from a published board nobody has scored in
yet, which is **200** with an empty `top[]` — and the two are worth rendering differently.
---
## 7. Status codes

View File

@@ -318,10 +318,14 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val
**Beyond 1.0.** Phases above are the 1.0 read/event plane. Protocol 2.0's phasing (provisioning +
world-state boards) is [`PROTOCOL_2.md`](PROTOCOL_2.md) §13; Protocol 3.0's (visibility framework,
shard content and standings) is [`v3.md`](v3.md) §9, which also tracks what has landed. Shipped from
3.0 so far: **Part A** — the visibility framework — and **`world.ruleset`** ([`v3.md`](v3.md) §5),
`BridgeRuleset.cs`, the first bridge stream that is neither an event subscription nor a sweep: it is
3.0 so far: **Part A** — the visibility framework — **`world.ruleset`** ([`v3.md`](v3.md) §5),
`BridgeRuleset.cs`, the first bridge stream that is neither an event subscription nor a sweep (it is
emitted once per connect, like `server.hello`, because shard config changes only when an operator
edits a file.
edits a file) — the **spawn atlas** ([`v3.md`](v3.md) §6), which is website-only and touches no wire
at all — and **`points.board`** ([`v3.md`](v3.md) §7), `BridgePoints.cs`, the loyalty/points
leaderboards. `BridgePoints` is the widest read the bridge performs: ten of ServUO's ~25 point systems
keep a row for every character ever created, so it selects the top N in a single bounded pass rather
than sorting, and runs on a deliberately slow 300 s interval.
### Config keys (`Config/Bridge.cfg`)
@@ -338,7 +342,8 @@ Read in `Configure()` via `Config.Get<T>("Bridge.<Key>", default)`. Key scope is
The set above is the 1.0 sample, not the current one — every later phase added keys (sweep intervals
for each board, the town-crier/news caps, the admin write plane, account provisioning, and 3.0's
`RulesetEnabled` / `PublicConnectAddress` / `RulesetIncludeSchedule`). **`servuo-plugins/overlay/Config/Bridge.cfg`
`RulesetEnabled` / `PublicConnectAddress` / `RulesetIncludeSchedule`, and the `Points*` block).
**`servuo-plugins/overlay/Config/Bridge.cfg`
is the authoritative, commented list**; `BridgeConfig.cs` holds the defaults.
---

View File

@@ -14,7 +14,7 @@ Each part is marked off here as it lands on `edge`. §9 carries the same state p
| 1 | **A** — visibility framework + actor-leak fix (§3) | ✅ **Done** | website [#109](https://gitea.whitlocktech.com/RunicGateway/website/pulls/109) + [#110](https://gitea.whitlocktech.com/RunicGateway/website/pulls/110), docs [#64](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/64) + [#65](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/65) |
| 2 | **B/1**`world.ruleset` (§5) | ✅ **Done** | servuo-plugins [#3](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/3), link [#17](https://gitea.whitlocktech.com/RunicGateway/link/pulls/17), website [#111](https://gitea.whitlocktech.com/RunicGateway/website/pulls/111), docs [#66](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/66) |
| 3 | **C** — spawn atlas (§6) | ✅ **Done** | website [#112](https://gitea.whitlocktech.com/RunicGateway/website/pulls/112) (parsers + CLI + tables) + [#113](https://gitea.whitlocktech.com/RunicGateway/website/pulls/113) (API + pages + admin panel), docs [#67](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/67) + [#68](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/68) |
| 4 | **B/2**`points.board` (§7) | ⬜ Not started | — |
| 4 | **B/2**`points.board` (§7) | **Done** | servuo-plugins [#4](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/4), link [#18](https://gitea.whitlocktech.com/RunicGateway/link/pulls/18), website [#114](https://gitea.whitlocktech.com/RunicGateway/website/pulls/114), docs [#69](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/69) |
| 5 | **B/3**`vendor.listing` (§8) | ⬜ Not started | — |
| 6 | **Cutover**`PROTOCOL_VERSION` 2→3 (§4) | ⬜ Not started | — |
@@ -507,7 +507,12 @@ retrofit nobody remembers to do.
---
## 7. Part B/2 — `points.board`
## 7. Part B/2 — `points.board` ✅ Done
*Landed on `edge`: servuo-plugins [#4](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/4),
link [#18](https://gitea.whitlocktech.com/RunicGateway/link/pulls/18), website [#114](https://gitea.whitlocktech.com/RunicGateway/website/pulls/114),
docs [#69](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/69). Verified against the real ServUO tree
per §11 — see §7.5 for what that run changed.*
Two deliverables: a diff sweep for the boards, and a `points` block folded into `char.profile`
the `PROTOCOL_2.md` §10.3 `titles` precedent (read-model enrichment, no new request kind).
@@ -590,6 +595,43 @@ Client — NEW `routes/public/Leaderboards.jsx` at `/site/leaderboards`; a "Loya
added to `components/CharacterSheet.jsx`, one edit serving both `PlayerCharacter.jsx` and
`AdminCharacter.jsx`.
### 7.5 What the run against a real shard changed
The plan above was written from reading `PointsSystem.cs`. Booting the actual shard (ServUO 57.4, a
43,011-mobile world) and letting one sweep run corrected four things — all of them invisible to a
fake-shard test, because a fake shard emits whatever the spec says it should.
1. **`maxPoints` overflowed to `long.MinValue`.** `MaxPoints` is a `double`, and ServUO's idiom for an
uncapped system is `double.MaxValue` — which `DespiseCrystals`, `ShameCrystals` and `VoidPool` all
use. `(long)double.MaxValue` in C# is an **unchecked** conversion: it does not throw, it yields
`long.MinValue`, and the first real sweep published
`"maxPoints": -9223372036854775808` for three of the five live boards. Fixed with `Cap()` /
`Score()` converters that normalise anything unrepresentable to `0`, which is now the wire's
documented **"uncapped"** value. Worth stating plainly because it inverts the obvious reading:
**on a real shard, `maxPoints: 0` is the common case, not an edge case**, so any UI dividing by it
must special-case it.
2. **`nameString` is usually `null`.** Most systems define their `Name` as a cliloc rather than a
literal: four of the five boards on the live shard came back `nameString: null` with only
`nameNumber` set. The humanise-the-`system`-key fallback is therefore the *primary* display path,
not a defensive nicety, and both the leaderboards page and the character sheet lead with it.
3. **`GetEntry`/`GetPoints` cannot be used in the read model.** `GetEntry(from, create: false)` still
calls `AddEntry` when the system has `AutoAdd` (`PointsSystem.cs:207`) — it **mutates the world**.
Ten of the ~25 systems have `AutoAdd = true`, so a profile built with the obvious accessor would
have appended up to ten rows to the points save file every time anyone viewed a character sheet.
`BridgeProfile.WritePoints` hand-rolls a read-only scan instead, and says so loudly.
4. **`players` had to be redefined.** §7.2 called for "the entry count", but those same ten `AutoAdd`
systems hold a zero-point row per character ever created — so the raw count reports the shard's
whole census as one system's participants. It is now the number of players actually holding points,
which is both the honest number and a strictly better diff signal (it moves when someone scores,
not when someone logs in for the first time).
One deviation from the plan as written, for the same class of reason: §7.4 named the per-field
visibility rule `characterName`, but `projectValue` matches on the **literal JSON key**, and the wire
key is `name`. A rule under the descriptive name would have been silently inert — an admin tightening
character names would have got no enforcement and no error, exactly the failure §3.6.1 records for the
flattened `ownerAcct`. `FEATURES.leaderboards.fields` therefore keys on `name`, with a test that fails
if it is renamed back.
---
## 8. Part B/3 — `vendor.listing`
@@ -700,7 +742,7 @@ inherently up to one full cycle old, and the UI must say so.
| 1 | **A** — visibility framework + actor-leak fix | website, docs | none | ✅ Done |
| 2 | **B/1**`world.ruleset` (§5) | all four | new kind | ✅ Done |
| 3 | **C** — spawn atlas (§6) | website, docs | none | ✅ Done |
| 4 | **B/2**`points.board` (§7) | all four | new kind + `char.profile` field | |
| 4 | **B/2**`points.board` (§7) | all four | new kind + `char.profile` field | ✅ Done |
| 5 | **B/3**`vendor.listing` (§8) | all four | new kinds | ⬜ |
| 6 | **Cutover**`PROTOCOL_VERSION` 2→3, `edge``main` | all four | the bump | ⬜ |