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

6 Commits

Author SHA1 Message Date
7215ae5fe1 Merge pull request 'feat(bridge): publish the player-vendor market index as vendor.listing' (#5) from feat/vendor-listing into edge
Reviewed-on: #5
2026-07-29 20:06:43 +00:00
48d57e6278 feat(bridge): publish the player-vendor market index as vendor.listing
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>
2026-07-29 09:51:00 -05:00
a38afe4c90 Merge pull request 'feat(bridge): publish points/loyalty leaderboards as points.board' (#4) from feat/points-board into edge
Reviewed-on: #4
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-29 07:54:09 +00:00
ed8f568d94 feat(bridge): publish points/loyalty leaderboards as points.board
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>
2026-07-28 21:04:08 -05:00
a7a383e6d9 Merge pull request 'feat(bridge): emit world.ruleset, the shard's published ruleset' (#3) from feat/bridge-ruleset into edge
Reviewed-on: #3
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 20:37:05 +00:00
3fb4b7dc9f feat(bridge): emit world.ruleset, the shard's published ruleset
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>
2026-07-28 11:14:36 -05:00