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>
This commit is contained in:
2026-07-29 09:51:00 -05:00
parent a38afe4c90
commit 48d57e6278
4 changed files with 654 additions and 0 deletions

View File

@@ -83,6 +83,29 @@ PointsProfileEnabled=true
# board for anyone in the top N.
PointsProfileRank=false
# Player-vendor market index (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §8). Every player vendor's shop name,
# owner, location and priced inventory, published as one vendor.listing frame per vendor so the
# website can offer the search the in-game Vendor Search gump offers. Honours each player's own
# in-game opt-out (the vendor's VendorSearch flag) — hide your vendor in game and it is hidden
# on the site too.
MarketEnabled=true
# Sweep interval. UNLIKE every other sweep here, a tick does NOT walk the whole world: it
# inventories at most MarketSweepBatch vendors and a persistent cursor round-robins through the
# rest, so the per-tick cost is bounded by the batch rather than by how many vendors exist. Full
# coverage takes ceil(vendors / batch) x MarketSweepSeconds — 500 vendors at the defaults is one
# complete pass every 20 minutes, and the site labels the data with how stale it may be.
#
# Lower this (or raise the batch) for faster coverage; both trade directly against per-tick cost,
# and the expensive part is the item walk, which recurses into every container a vendor is selling.
MarketSweepSeconds=60
MarketSweepBatch=25
# Per-vendor listing cap, after which the frame carries "truncated": true. A commodity reseller
# with thousands of stacked resources is a real thing, and an uncapped frame for one is measured
# in megabytes. Clamped to 1..5000.
MarketMaxListings=250
# Shard ruleset (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §5). One world.ruleset frame — expansion, which
# systems are on, skill/stat caps, account and house limits, champion scroll rules —
# emitted on every sidecar connect (and on [bridge reload), so the website's rules page