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. |