feat(bridge): publish points/loyalty leaderboards as points.board #4

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

What & why

Protocol 3.0 §7 (docs/link/v3.md), order 4 of 6. ServUO carries ~25 separate point currencies — Queen's Loyalty, Void Pool, Casino, Clean Up Britannia, the nine city loyalties, the Doom/Khaldun/Kotl treasure systems — every one a standing players build over months, and none of them visible outside an in-game gump until now.

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

BridgePoints.cs — the board sweep

A diff sweep shaped like BridgeHousing: ServerStarted arms the timer, a sidecar connect clears the diff state so a fresh sidecar gets every board, and each pass emits only the systems whose top N or participant count actually moved. One ~600 B frame per system rather than one 12 KB frame, matching champ.update / guild.update.

No points.remove — the system set is fixed at startup by PointsSystem.Configure, the same argument city.update already makes for cities.

The perf trap, and why the selection looks like it does. PlayerTable is a plain List<PointsEntry>, and ten of the ~25 systems have AutoAdd = true, so they hold a row for every character that has ever logged in. The obvious .OrderByDescending().Take(N) is a full sort per system on the Core thread — which BRIDGE_PLUGIN_PLAN.md §1 measured as the second thing in the whole bridge capable of blowing a frame budget. Instead: a single bounded pass into a fixed N-element array kept sorted by insertion, one allocation for the whole sweep, and the common case is one comparison against the running Nth place before a row is rejected.

Which systems publish defaults to the shard's own answer — ShowOnLoyaltyGump — rather than a list invented here that would drift every time a subsystem is added. Bridge.cfg PointsSystems= overrides it; an unrecognised name is logged, never silently dropped.

Entries are written inline as {serial, name}, never via BridgeJson.Actor. A board is the widest-audience surface the bridge has, so acct/webId deliberately do not cross the wire; the site resolves serial → user from its own link mirror.

char.profile gains a points block

Read-model enrichment on an existing kind — the titles precedent from PROTOCOL_2.md §10.3, no new request kind.

⚠️ This code must never call PointsSystem.GetEntry/GetPoints, and the reason is worth a reviewer's attention. Both look like read accessors and both mutate the world: GetEntry(from, create: false) still calls AddEntry when the system has AutoAdd (PointsSystem.cs:207), appending a row and firing OnPlayerAdded. Using them would have grown the points save file by up to ten rows every time anyone opened a character sheet. Hand-rolled read-only scan instead.

rank is off by default (PointsProfileRank): 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.

How it was tested

Verified by running it, not by reading it.

Compile — the whole Scripts tree (6,207 files) compiles clean against real ServUO 57.4 assemblies: dotnet build Scripts/Scripts.csproj -c ReleaseBuild succeeded, 0 Warning(s), 0 Error(s). (Note the trap BRIDGE_PLUGIN_PLAN.md §1 warns about: the shard's dynamic rebuild can't overwrite ServUO.exe while it is running, so the first boot after a script change silently reloads the previous Scripts.dll. Confirmed the compile banner and the new code being live — /ruleset answering — rather than trusting the absence of errors.)

Runtime, against the real shard — booted the local ServUO tree with a 43,011-mobile / 209,116-item world against the Rust sidecar, and let a sweep run. Five live boards emitted: QueensLoyalty, VoidPool, DespiseCrystals, ShameCrystals, ViceVsVirtue.

That run caught a bug no fake-shard test could. ServUO's idiom for an uncapped system is MaxPoints = double.MaxValue (DespiseCrystals, ShameCrystals, VoidPool all use it), and (long) on that in C# is an unchecked conversion — it doesn't throw, it yields long.MinValue. The first live sweep published:

"maxPoints": -9223372036854775808     ← three of the five boards

Fixed with Cap()/Score() converters that normalise anything unrepresentable to 0, now the wire's documented "uncapped" value. Re-deployed, rebuilt, re-booted, re-swept:

DespiseCrystals   maxPoints=0        ShameCrystals   maxPoints=0
VoidPool          maxPoints=0        QueensLoyalty   maxPoints=15000
ViceVsVirtue      maxPoints=10000

Worth flagging for review because it inverts the obvious reading: on a real shard maxPoints: 0 is the common case, not an edge case. The same run also showed four of five boards sending nameString: null with only a cliloc — so the consumer-side "humanise the system key" fallback is the primary display path, not a defensive nicety. Both are documented in docs #69.

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. (Docs in docs #69; this repo has no test suite — it is deployed as source and compiled by ServUO at boot.)
  • 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. ServUO carries ~25 separate point currencies — Queen's Loyalty, Void Pool, Casino, Clean Up Britannia, the nine city loyalties, the Doom/Khaldun/Kotl treasure systems — every one a standing players build over months, and **none of them visible outside an in-game gump until now**. Part of a four-repo change: servuo-plugins (this) → link #18 → website #114 → docs #69. ### `BridgePoints.cs` — the board sweep A diff sweep shaped like `BridgeHousing`: `ServerStarted` arms the timer, a sidecar connect clears the diff state so a fresh sidecar gets every board, and each pass emits only the systems whose top N or participant count actually moved. One ~600 B frame **per system** rather than one 12 KB frame, matching `champ.update` / `guild.update`. **No `points.remove`** — the system set is fixed at startup by `PointsSystem.Configure`, the same argument `city.update` already makes for cities. **The perf trap, and why the selection looks like it does.** `PlayerTable` is a plain `List<PointsEntry>`, and **ten** of the ~25 systems have `AutoAdd = true`, so they hold a row for every character that has ever logged in. The obvious `.OrderByDescending().Take(N)` is a full sort *per system* on the Core thread — which `BRIDGE_PLUGIN_PLAN.md` §1 measured as the second thing in the whole bridge capable of blowing a frame budget. Instead: a single bounded pass into a fixed N-element array kept sorted by insertion, one allocation for the whole sweep, and the common case is one comparison against the running Nth place before a row is rejected. Which systems publish defaults to the shard's **own** answer — `ShowOnLoyaltyGump` — rather than a list invented here that would drift every time a subsystem is added. `Bridge.cfg PointsSystems=` overrides it; an unrecognised name is logged, never silently dropped. Entries are written inline as `{serial, name}`, **never via `BridgeJson.Actor`**. A board is the widest-audience surface the bridge has, so `acct`/`webId` deliberately do not cross the wire; the site resolves serial → user from its own link mirror. ### `char.profile` gains a `points` block Read-model enrichment on an existing kind — the `titles` precedent from `PROTOCOL_2.md` §10.3, no new request kind. ⚠️ **This code must never call `PointsSystem.GetEntry`/`GetPoints`, and the reason is worth a reviewer's attention.** Both look like read accessors and both **mutate the world**: `GetEntry(from, create: false)` still calls `AddEntry` when the system has `AutoAdd` (`PointsSystem.cs:207`), appending a row and firing `OnPlayerAdded`. Using them would have grown the points save file by up to ten rows *every time anyone opened a character sheet*. Hand-rolled read-only scan instead. `rank` is off by default (`PointsProfileRank`): 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. ## How it was tested Verified by **running it**, not by reading it. **Compile** — the whole `Scripts` tree (6,207 files) compiles clean against real ServUO 57.4 assemblies: `dotnet build Scripts/Scripts.csproj -c Release` → **Build succeeded, 0 Warning(s), 0 Error(s)**. (Note the trap `BRIDGE_PLUGIN_PLAN.md` §1 warns about: the shard's *dynamic* rebuild can't overwrite `ServUO.exe` while it is running, so the first boot after a script change silently reloads the previous `Scripts.dll`. Confirmed the compile banner and the new code being live — `/ruleset` answering — rather than trusting the absence of errors.) **Runtime, against the real shard** — booted the local ServUO tree with a **43,011-mobile / 209,116-item world** against the Rust sidecar, and let a sweep run. Five live boards emitted: `QueensLoyalty`, `VoidPool`, `DespiseCrystals`, `ShameCrystals`, `ViceVsVirtue`. **That run caught a bug no fake-shard test could.** ServUO's idiom for an uncapped system is `MaxPoints = double.MaxValue` (`DespiseCrystals`, `ShameCrystals`, `VoidPool` all use it), and `(long)` on that in C# is an **unchecked** conversion — it doesn't throw, it yields `long.MinValue`. The first live sweep published: ``` "maxPoints": -9223372036854775808 ← three of the five boards ``` Fixed with `Cap()`/`Score()` converters that normalise anything unrepresentable to `0`, now the wire's documented **"uncapped"** value. Re-deployed, rebuilt, re-booted, re-swept: ``` DespiseCrystals maxPoints=0 ShameCrystals maxPoints=0 VoidPool maxPoints=0 QueensLoyalty maxPoints=15000 ViceVsVirtue maxPoints=10000 ``` Worth flagging for review because it **inverts the obvious reading**: on a real shard `maxPoints: 0` is the *common* case, not an edge case. The same run also showed four of five boards sending `nameString: null` with only a cliloc — so the consumer-side "humanise the system key" fallback is the primary display path, not a defensive nicety. Both are documented in docs #69. ## 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. *(Docs in docs #69; this repo has no test suite — it is deployed as source and compiled by ServUO at boot.)* - [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:06:09 +00:00
Protocol 3.0 §7 (docs/link/v3.md). ServUO carries ~25 separate point currencies
— Queen's Loyalty, Void Pool, Casino, Clean Up Britannia, the nine city
loyalties, the Doom/Khaldun/Kotl treasure systems — every one a standing players
build over months, and none of them visible outside an in-game gump until now.

BridgePoints.cs
  - A diff sweep shaped like BridgeHousing: ServerStarted arms the timer, a
    sidecar connect clears the diff state so a fresh sidecar gets every board,
    and each pass emits only the systems whose top N or participant count moved.
    One ~600 B frame per system rather than one 12 KB frame, matching
    champ.update / guild.update. No points.remove — the system set is fixed at
    startup by PointsSystem.Configure, the same argument city.update makes.
  - Selection is a single bounded pass into a fixed N-element array kept sorted
    by insertion, NOT OrderByDescending().Take(N). PlayerTable is a plain List
    and ten of the ~25 systems have AutoAdd = true, so they hold a row for every
    character ever created: the naive version is ~25 full sorts on the Core
    thread, which BRIDGE_PLUGIN_PLAN.md §1 measured as the second thing in the
    bridge capable of blowing a frame budget.
  - Which systems publish defaults to the shard's OWN answer — ShowOnLoyaltyGump
    — rather than a list here that would drift; Bridge.cfg PointsSystems=
    overrides it, and an unrecognised name is logged rather than dropped.
  - Entries are written inline as {serial, name}, never via BridgeJson.Actor. A
    board is the widest-audience surface the bridge has, so acct/webId
    deliberately do not cross the wire; the site resolves serial → user from its
    own link mirror.

char.profile gains a points block, the titles precedent from PROTOCOL_2.md §10.3
  - Never uses PointsSystem.GetEntry/GetPoints: both MUTATE THE WORLD, since
    GetEntry(create: false) still calls AddEntry when the system has AutoAdd
    (PointsSystem.cs:207). Using them would have appended up to ten rows to the
    points save file every time anyone opened a character sheet. Hand-rolled
    read-only scan instead.
  - rank is off by default (PointsProfileRank). A points lookup stops at the
    character's own row; a rank must count every row that beats them, in every
    system, on every profile build.

Verified by running it, not by reading it: the whole Scripts tree (6,207 files)
compiles clean against real ServUO 57.4 assemblies, and a boot against the local
shard with a 43,011-mobile world emitted five live boards. That run caught a bug
no fake shard could — ServUO's uncapped idiom is MaxPoints = double.MaxValue,
and (long) on it is an UNCHECKED conversion yielding long.MinValue, so the first
sweep published "maxPoints": -9223372036854775808 for three of the five boards.
Cap()/Score() now normalise anything unrepresentable, and maxPoints: 0 is the
documented "uncapped" value — which on a real shard is the common case, not an
edge case. Re-verified after the fix: 0 for the uncapped systems, 15000 and
10000 for the two that genuinely cap.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech approved these changes 2026-07-29 07:53:32 +00:00
whitlocktech merged commit a38afe4c90 into edge 2026-07-29 07:54:10 +00:00
whitlocktech deleted branch feat/points-board 2026-07-29 07:54:11 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/servuo-plugins#4
No description provided.