feat(bridge): publish the player-vendor market index as vendor.listing #5
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/vendor-listing"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What & why
Protocol 3.0 §8 (
docs/link/v3.md) — order 5b, the shard side. Docs in docs #71; sidecar in link #19; website in website #116.Every player vendor's shop name, owner, location and priced inventory, published as one authoritative
vendor.listingframe per vendor, 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, and that shapes everything
The obvious design is a
market.snapshotrequest/reply likevendor.snapshotnext door. It cannot work:rpc.rs::try_routecorrelates a reply on the first frame carrying a matchingreqIdand resolves a single oneshot, so a chunked reply sharing onereqIdwould 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 reply timeout either.So it is a diff sweep on the broadcast stream. The per-account
vendor.snapshotRPC is untouched; the player portal keeps using it.The one genuinely new pattern: an amortized round-robin
Worth a reviewer's attention because it is unlike every other sweep here.
BridgeHousing,BridgePoints,BridgeSocialand the rest walk their whole collection each tick — fine for tens of houses or a fixed set of ~25 point systems, and not fine for a world of shops whose inventories recurse into containers.BridgeMarketinventories at mostMarketSweepBatch(25) vendors per tick from a persistent cursor, so per-tick cost is bounded by the batch rather than by world size. Full coverage takesceil(vendors / batch) × MarketSweepSeconds.The subtlety the batching creates is the removal pass:
_lastholds every vendor seen in any previous tick, so "not in this tick's window" does not mean gone. Removals are decided against the current vendor list, which is a cheap pass over serials rather than a second inventory walk.VendorSearch.GetItemNameis never calledIt builds an
ObjectPropertyList, callsGetProperties, serialises it and then byte-parses the resulting packet — per item. Across a full pass that is a multi-hundred-millisecond stall on the Core thread. The frame carriesitemId,hue,amount,price, the plainitem.Namefield (null for most items) anditem.LabelNumber; the website resolves names against its own cliloc table, exactly aschar.profile.equipmentalready does.It would not work anyway: every current client ships its cliloc files compressed, ServUO's bundled
Ultima.StringListreads only the older plain layout, soVendorSearch.StringListis null andGetItemNamereturnsitem.Nameregardless — the in-game gump has the same gap.Design notes worth a look
locationis ONE nested object, not flatmap/x/y/region. The website's visibility projection matches literal JSON keys, so nesting is what lets a singlemarket.locationrule hide a vendor's whereabouts on both the live frame and the stored read model. Flat keys would need five rules that could drift apart — and Part A had already pre-wired alocationrule that flat keys would have made inert, exactly like thecharacterNamemiss one part earlier.ownerSerial/ownerName, neverBridgeJson.Actor, which would addacctandwebId. Same argumentpoints.boardmakes: this is the widest-audience surface the bridge has.pv.VendorSearchis honoured — ServUO's own per-vendor opt-out, whichDoSearchfilters on. A shop hidden in game is hidden on the site, and the seen-set removal then emitsvendor.listing.remove, so revoking consent takes effect rather than merely stopping refreshes.child: true, exactly asDoSearchreports them. ServUO prices a container as a unit; a site that printed the container's price against each item inside it would be lying about the shard.count | Σ(serial ^ price)— that hash collides on two items swapping prices, which is exactly what re-pricing a shop looks like.VendorSearch.GetItems(Container, List<Item>)rather than a hand-rolled recursion, so ServUO's rule about which containers are sold whole (quivers, seed boxes, jewelry boxes, …) stays ServUO's to define — the predicate that decides it is private, and a copy here would silently diverge.How it was tested
Deployed to the local ServUO tree and compiled offline first (
dotnet build Scripts.csproj -c Release→0 Error(s)), because a boot-time rebuild failure silently reloads the previousScripts.dll. Boot confirmed the compile banner, not merely the absence of errors.Run against the real shard (209,116 items / 43,011 mobiles / 27 player vendors) with the Rust sidecar listening:
Every frame checked for shape: nested
locationwith real facets, regions and house-sign names (26 of 27 in a house — the null path exercised), flat owner fields,count/total/truncated, and 1,040 items carryingclilocwithname: null, which is the normal case.Sweep cost is now reported, because the batch cap is a claim about it
[bridge statusgainedlastMs/maxMs, and a tick over 50 ms prints a rate-limited warning naming the knob. An operator tuningMarketSweepBatchwas otherwise tuning blind.Note the arithmetic the warning exists for: 25 full shops at the 250-listing cap is 6,250 items ≈ 95 ms, over budget. Real shops hold tens, which is why 25 is the default — but a shard of commodity resellers should lower the batch, and now it will be told to.
Two small things the live run caught
BridgeJson.Escapetakes a NON-NULL string (it dereferencesvalue.Length) andBridgeJson.Strwrites its own,"key":prefix, so neither serves a value inside a hand-built object. Nearly everything this frame writes is legitimately null.BridgeMarket.Text()is the two-line writer that was missing.Console.WriteLinerenders as???in the log an operator would paste into an issue. Fixed to ASCII.Not covered:
vendor.listing.removefrom the plugin side needs a vendor dismissed or opted out in game, which needs a client. The removal path was verified at the sidecar and website layers instead (synthetic insert-then-remove over the loopback socket, and a routing test).CI note:
pr-checks.ymlonly triggers on PRs intomain, so this PR (targetingedge) runs no CI — local verification above is the gate.Checklist
AI-assisted contributions (required)
Claude Code (Opus 5). I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with aCo-Authored-Bytrailer.License