From 8e857a9c8d896c90d886964dce7459c4b6aa718e Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 28 Jul 2026 21:05:04 -0500 Subject: [PATCH] docs(link): points.board, the leaderboards API, and what a real shard changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- link/INTEGRATION.md | 89 +++++++++++++++++++++++++++++++++++++++ link/PLAN.md | 13 ++++-- link/v3.md | 48 +++++++++++++++++++-- website/BACKEND_DESIGN.md | 30 +++++++++++++ 4 files changed, 173 insertions(+), 7 deletions(-) diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index f46e686..697235f 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -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 diff --git a/link/PLAN.md b/link/PLAN.md index cb36025..709fead 100644 --- a/link/PLAN.md +++ b/link/PLAN.md @@ -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("Bridge.", 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. --- diff --git a/link/v3.md b/link/v3.md index d23b6a7..5b6862d 100644 --- a/link/v3.md +++ b/link/v3.md @@ -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 | ⬜ | diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index fd26513..4304050 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -379,6 +379,34 @@ they are cheap to display — the same payload-plus-hoisted-columns shape `shard served as `null` rather than `{}`: "not published yet" and "published, everything off" are different answers and the page renders them differently. +### shard_points_boards — points / loyalty leaderboards (Protocol 3.0) + +One row per point system, keyed by the shard's own `PointsType` name (`QueensLoyalty`, +`CleanUpBritannia`, …). The shard carries ~25 of these, each a standing players build over months. +Columns: `system` (PK), `name`, `name_cliloc`, `max_points`, `players`, `show_on_gump`, `payload` JSON +(the whole `points.board` frame), `t`, `updated_at`. + +**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` — so normalizing buys nothing until something needs a per-character +reverse lookup, and a character's own standings already ride inside `char.profile` instead. + +Board state, not events: `points.board` is **not** in `LOGGED_KINDS`, for the same reason +`guild.update` isn't. The shard emits a frame every time anyone's score moves a top ten, so logging +would grow `shard_events` without bound for something whose only interesting value is its latest +version. There is also **no delete path** — the shard's set of systems is fixed at startup, so there is +no `points.remove` to mirror. + +Two values carry non-obvious meanings, both set by the plugin and both documented in +[`link/INTEGRATION.md`](../link/INTEGRATION.md) §4: + +- **`max_points = 0` means uncapped**, and on a real shard that is the *common* case (ServUO's + uncapped idiom is `double.MaxValue`, which the plugin normalises to 0). Anything rendering + `points / max_points` must special-case it. +- **`name` is usually NULL**, with `name_cliloc` set instead — most systems name themselves with a + cliloc rather than a literal. Listing therefore orders by `COALESCE(name, system)`, so boards + awaiting cliloc resolution sort by their own key rather than clumping together under NULL. + ### shard_feature_visibility — per-feature audience config (Protocol 3.0) One row per shard feature: `feature` (PK), `enabled`, `audience` (a rung on the ladder in §6.5), @@ -657,6 +685,8 @@ from the per-route **siteMode** middleware (§5), never from an auth gate. | GET | `/wiki/:slug` | single page | | POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` | | GET | `/shard/ruleset` | the shard's own published ruleset (Protocol 3.0 `world.ruleset`): expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules, the save/restart schedule. Served from `shard_ruleset`, so it renders while the shard is down; live via `world.ruleset` on `/shard/stream`. Behind `requireFeature('ruleset')`. **`null`** means the shard has never published one — a real answer, distinct from a published ruleset. `caps.skill` / `caps.totalSkill` are in **tenths** (1000 = 100.0). | +| GET | `/shard/points` | every points/loyalty leaderboard the shard publishes (Protocol 3.0 `points.board`) — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, … Served from `shard_points_boards`, so it renders while the shard is down; live via `points.board` on `/shard/stream`. Behind `requireFeature('leaderboards')`, ordered by display name. **`maxPoints: 0` means uncapped** (the common case), and `nameString` is usually `null` with `nameNumber` holding a cliloc — resolve client-side or humanise the `system` key. | +| GET | `/shard/points/:system` | one board by the shard's `PointsType` name (e.g. `QueensLoyalty`); `:system` must match `/^[A-Za-z][A-Za-z0-9_]{0,47}$/` or **400** before any query runs. **404** = the shard has never published that system, which is distinct from a published board nobody has scored in yet (**200** with an empty `top`). | | GET | `/shard/features` | the shard features **this caller** may reach plus the audience rung they resolved to (§6.5), so a client hides nav it can't follow. Reports only what the caller can see — the list itself never discloses a gated feature. Consumed by the SPA header and (pending) the Android nav. | | GET | `/atlas/creatures?q=&facet=&limit=&offset=` | the bestiary, most numerous first, with an unpaginated `total`. Static content parsed from the shard's ServUO tree — **not** sidecar-backed, which is why the atlas sits outside `/shard`, and unlike `/shard/*` it **is** site-mode gated. Behind `requireFeature('atlas')`. `?facet=` is matched exactly and never validated against a list (no facet name exists in the code); the filter is an `EXISTS` over the points rather than a JSON path or `JSON_SEARCH` built from caller input, whose `%`/`_` wildcards would make `?facet=%` match everything. | | GET | `/atlas/creatures/:slug` | one creature: `places` (the point-in-rect aggregate — "lizardman → Shrines, Isamu-Jima, Yew"), `spawners` (the bounded raw list, with `spawnersTruncated`), `alsoHere`. **`points` is a COUNT and `spawners` is the LIST** — named apart so one key never means a number on one route and an array on another. `minDelay`/`maxDelay` are in **seconds**, normalised at parse time from the source's per-record minutes-or-seconds. 404 = no such creature in this atlas. |