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:
@@ -36,6 +36,12 @@ namespace Server.Custom.Bridge
|
||||
public static int PresenceSweepSeconds { get; private set; }
|
||||
public static int HousingSweepSeconds { get; private set; }
|
||||
public static int PointsSweepSeconds { get; private set; }
|
||||
public static int MarketSweepSeconds { get; private set; }
|
||||
|
||||
// ---- player-vendor market index (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §8) ----
|
||||
public static bool MarketEnabled { get; private set; }
|
||||
public static int MarketSweepBatch { get; private set; }
|
||||
public static int MarketMaxListings { get; private set; }
|
||||
|
||||
// ---- points / loyalty leaderboards (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §7) ----
|
||||
public static bool PointsLeaderboardEnabled { get; private set; }
|
||||
@@ -149,6 +155,36 @@ namespace Server.Custom.Bridge
|
||||
// on every profile build. See BridgeProfile.WritePoints.
|
||||
PointsProfileRank = Config.Get("Bridge.PointsProfileRank", false);
|
||||
|
||||
// Player-vendor market index. Unlike every other sweep, this one does NOT walk its whole
|
||||
// collection per tick: MarketSweepBatch caps how many vendors are inventoried, 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 the world holds.
|
||||
MarketEnabled = Config.Get("Bridge.MarketEnabled", true);
|
||||
|
||||
MarketSweepSeconds = Config.Get("Bridge.MarketSweepSeconds", 60);
|
||||
if (MarketSweepSeconds < 1)
|
||||
MarketSweepSeconds = 1;
|
||||
|
||||
// Bounded below at 1 (a batch of 0 would advance the cursor nowhere and publish nothing,
|
||||
// silently) and above at 500, past which the batch stops bounding anything on any
|
||||
// realistic shard and the tick is a whole-world pass by another name.
|
||||
MarketSweepBatch = Config.Get("Bridge.MarketSweepBatch", 25);
|
||||
if (MarketSweepBatch < 1)
|
||||
MarketSweepBatch = 1;
|
||||
if (MarketSweepBatch > 500)
|
||||
MarketSweepBatch = 500;
|
||||
|
||||
// Per-vendor listing cap. BridgeJson.Parse caps INBOUND frames at 1 MB; outbound is
|
||||
// uncapped and the sidecar's read_line will allocate whatever arrives, so the cap here
|
||||
// is what keeps one commodity reseller with 8,000 stacked resources from emitting a
|
||||
// multi-megabyte frame. Over the cap the frame carries "truncated": true and the site
|
||||
// says so.
|
||||
MarketMaxListings = Config.Get("Bridge.MarketMaxListings", 250);
|
||||
if (MarketMaxListings < 1)
|
||||
MarketMaxListings = 1;
|
||||
if (MarketMaxListings > 5000)
|
||||
MarketMaxListings = 5000;
|
||||
|
||||
// The ruleset frame is not a sweep — it is emitted once per sidecar connect (and on
|
||||
// `[bridge reload`), so it has no interval. PublicConnectAddress is the ONE connection
|
||||
// detail the bridge will publish, and only because an operator typed it here for that
|
||||
|
||||
Reference in New Issue
Block a user