feat(bridge)!: Protocol 3.0 cutover — world.ruleset, points.board, vendor.listing #6

Merged
whitlocktech merged 6 commits from edge into main 2026-08-01 06:33:28 +00:00
Member

What & why

Order 6 of the Protocol 3.0 plan (v3.md §4) — the edgemain cutover, plugin side. One of four PRs that merge together; that merge is the version bump.

3.0's whole point was that the bridge covered live activity well and shard content and standings almost not at all. Three new emitters close that on the plugin side:

  • BridgeRuleset.csworld.ruleset (v3.md §5, #3). The shard's published ruleset: expansion, skill/stat caps, housing, PvP and the systems block. The first bridge stream that is neither an event subscription nor a sweep — it rides BridgeLink.Connected_Core, like server.hello, because shard config only changes when an operator edits a file. Where a system's on/off state is derived rather than configured (shadowguard, factions) it reads the system's own static instead of inventing a .cfg key that doesn't exist.
  • BridgePoints.cspoints.board (v3.md §7, #4). The loyalty/points leaderboards, and 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, on a deliberately slow 300 s interval.
  • BridgeMarket.csvendor.listing / vendor.listing.remove (v3.md §8, #5). The shard-wide player-vendor index, and the one sweep shape the bridge did not previously have: an amortized round-robin. Every other sweep walks its whole collection per tick, which is fine for tens of houses and not fine for a world of shops whose inventories recurse into containers, so it inventories at most MarketSweepBatch vendors per tick from a persistent cursor — per-tick cost bounded by the batch, not by world size. It is also the first stream to honour a per-player privacy toggle: ServUO's own PlayerVendor.VendorSearch, so a shop hidden in game is hidden on the site.

How it was tested

Each phase was verified on the live ServUO tree before it landed on edge (see the individual PRs). The plugin also compiles standalone, contrary to "no standalone build": Roslyn over the whole Scripts tree with overlay/Scripts/Custom/Bridge/*.cs substituted, 6,205 files, which catches every signature error a boot would.

Measured on a shard of 209k items / 43k mobiles: a cold market tick of 25 vendors × 40 listings is 15.4 ms, steady state 0.3 ms; [bridge status reports lastMs/maxMs and warns past 50 ms. End-to-end, 27 real vendors / 1,040 listings swept off the tree, through the sidecar, onto the site.

Merge order

Merge with the other three cutover PRs — link #21, website #118, docs #73. Nothing in this repo carries the protocol version, so this half is version-agnostic — but the shard emits kinds a v2 sidecar has no table for, so it should not go out ahead of link.

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. I have reviewed and understand
    every change, and take responsibility for it. AI-authored commits are
    marked with a Co-Authored-By / Assisted-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 **Order 6 of the Protocol 3.0 plan ([`v3.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/link/v3.md) §4) — the `edge` → `main` cutover, plugin side.** One of four PRs that merge together; that merge *is* the version bump. 3.0's whole point was that the bridge covered live *activity* well and shard *content and standings* almost not at all. Three new emitters close that on the plugin side: - **`BridgeRuleset.cs` — `world.ruleset`** ([`v3.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/link/v3.md) §5, #3). The shard's published ruleset: expansion, skill/stat caps, housing, PvP and the systems block. The first bridge stream that is neither an event subscription nor a sweep — it rides `BridgeLink.Connected_Core`, like `server.hello`, because shard config only changes when an operator edits a file. Where a system's on/off state is *derived* rather than configured (`shadowguard`, `factions`) it reads the system's own static instead of inventing a `.cfg` key that doesn't exist. - **`BridgePoints.cs` — `points.board`** ([`v3.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/link/v3.md) §7, #4). The loyalty/points leaderboards, and 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, on a deliberately slow 300 s interval. - **`BridgeMarket.cs` — `vendor.listing` / `vendor.listing.remove`** ([`v3.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/link/v3.md) §8, #5). The shard-wide player-vendor index, and the one sweep shape the bridge did not previously have: an **amortized round-robin**. Every other sweep walks its whole collection per tick, which is fine for tens of houses and not fine for a world of shops whose inventories recurse into containers, so it inventories at most `MarketSweepBatch` vendors per tick from a persistent cursor — per-tick cost bounded by the batch, not by world size. It is also the first stream to honour a per-player privacy toggle: ServUO's own `PlayerVendor.VendorSearch`, so a shop hidden in game is hidden on the site. ## How it was tested Each phase was verified on the live ServUO tree before it landed on `edge` (see the individual PRs). The plugin also compiles standalone, contrary to "no standalone build": Roslyn over the whole `Scripts` tree with `overlay/Scripts/Custom/Bridge/*.cs` substituted, 6,205 files, which catches every signature error a boot would. Measured on a shard of 209k items / 43k mobiles: a cold market tick of 25 vendors × 40 listings is **15.4 ms**, steady state **0.3 ms**; `[bridge status` reports `lastMs`/`maxMs` and warns past 50 ms. End-to-end, 27 real vendors / 1,040 listings swept off the tree, through the sidecar, onto the site. ## Merge order Merge with the other three cutover PRs — link [#21](https://gitea.whitlocktech.com/RunicGateway/link/pulls/21), website [#118](https://gitea.whitlocktech.com/RunicGateway/website/pulls/118), docs [#73](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/73). Nothing in this repo carries the protocol version, so this half is version-agnostic — but the shard emits kinds a v2 sidecar has no table for, so it should not go out ahead of link. ## 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`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` / `Assisted-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 6 commits 2026-07-29 23:06:40 +00:00
Protocol 3.0 §5 (docs/link/v3.md). One frame describing how this shard is
actually configured — expansion, which optional systems are on, skill/stat
caps, account and house limits, champion scroll rules, the save/restart
schedule — so the website's rules page cannot drift from the server.

Modelled on BridgeBoot.EmitHello, not on the diff sweeps: the ruleset changes
only when an operator edits a .cfg, so there is nothing to poll. It subscribes
Connected_Core, so a sidecar that comes up second still learns the ruleset,
and `[bridge reload` re-emits for an operator who just edited a file.

The frame is built from an EXPLICIT ALLOWLIST of Config.Get calls. Config.Entries
is never enumerated — that would sweep in every key on the server, secrets
included — and Server.cfg, Staff.cfg, Email.cfg, DataPath.cfg, Bridge.cfg,
Compiler.cfg, Reports.cfg and Client.cfg are named as excluded both here and in
a code comment. The one connection detail published is Bridge.PublicConnectAddress,
blank by default, which an operator sets deliberately for this purpose.

`rev` is FNV-1a over the body so an unchanged reconnect is a site-side no-op.
String.GetHashCode() is deliberately not used: it is seeded per process, so it
would change on every restart and defeat the diff.

Verified by compiling the full ServUO Scripts tree (6,205 files, net48, EJ) with
this overlay substituted for the deployed Bridge copy — clean.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #3
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
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>
Reviewed-on: #4
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
Protocol 3.0 §8. Every player vendor's shop name, owner, location and priced
inventory, so the website can offer the search the in-game Vendor Search gump
offers — from outside the game, and honouring the same per-player opt-out.

It cannot be an RPC. rpc.rs correlates a reply on the FIRST frame carrying a
matching reqId, so a chunked reply sharing one reqId would deliver chunk 1 to the
HTTP caller and leak chunks 2..N onto the broadcast feed; a whole-world snapshot
would not fit in one frame inside the 10 s timeout either. So it is a diff sweep
on the broadcast stream, one authoritative frame per vendor.

The one genuinely new pattern here is an amortized round-robin: every other sweep
walks its whole collection per tick, which is fine for tens of houses and is not
fine for a world of shops whose inventories recurse into containers.
MarketSweepBatch (25) vendors are inventoried per tick from a persistent cursor,
so per-tick cost is bounded by the batch rather than by world size.

VendorSearch.GetItemName is never called: it builds an ObjectPropertyList,
serialises it and byte-parses the packet per item. The frame carries itemId, hue,
amount, price, the plain item.Name field and item.LabelNumber; the website
resolves names against its own cliloc table. (It would not work anyway — every
current client ships its cliloc files compressed and ServUO's Ultima.StringList
cannot read them, so the in-game gump has the same gap.)

Measured on the live shard (27 vendors x 40 listings, 209k items / 43k mobiles):
15.4 ms for the first cold tick of 25 vendors, 3.4 ms for the next, 0.3 ms in
steady state. `[bridge status` now reports lastMs/maxMs and a tick over 50 ms
warns, naming the knob — the batch cap is a claim about that number and an
operator tuning it was otherwise tuning blind.

- location is ONE nested object, not flat map/x/y/region, so the website's single
  market.location visibility rule can hide a vendor's whereabouts on both the
  live frame and the stored read model. Flat keys would need five rules.
- Owner is flat ownerSerial/ownerName, never BridgeJson.Actor, which would add
  acct and webId. Same argument points.board makes.
- pv.VendorSearch is honoured, so a shop hidden in game is hidden on the site;
  the seen-set removal then emits vendor.listing.remove.
- Container-priced items carry child:true, exactly as DoSearch reports them.
- Over MarketMaxListings (250) the frame says truncated and carries the real
  total, so the site shows "250 of 3,104" rather than a partial shop as complete.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #5
whitlocktech approved these changes 2026-08-01 06:33:20 +00:00
whitlocktech merged commit 968b526fac into main 2026-08-01 06:33:28 +00:00
whitlocktech deleted branch edge 2026-08-01 06:33:29 +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#6
No description provided.