diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg
index df1edb6..0245269 100644
--- a/overlay/Config/Bridge.cfg
+++ b/overlay/Config/Bridge.cfg
@@ -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
diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
index 256cda9..c7e7f4b 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
@@ -166,6 +166,7 @@ namespace Server.Custom.Bridge
BridgePresence.Rearm();
BridgeHousing.Rearm();
BridgePoints.Rearm();
+ BridgeMarket.Rearm();
// Not a sweep, so it has nothing to re-arm — but an operator who just edited a
// .cfg wants the change on the site now, not after a shard restart.
BridgeRuleset.Emit();
@@ -186,6 +187,7 @@ namespace Server.Custom.Bridge
BridgePresence.SweepOnce();
BridgeHousing.SweepOnce();
BridgePoints.SweepOnce();
+ BridgeMarket.SweepOnce();
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
@@ -194,6 +196,7 @@ namespace Server.Custom.Bridge
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
+ e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
break;
default:
@@ -209,6 +212,7 @@ namespace Server.Custom.Bridge
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
+ e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeRuleset.Status());
break;
diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
index c9a6343..bd1362a 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
@@ -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
diff --git a/overlay/Scripts/Custom/Bridge/BridgeMarket.cs b/overlay/Scripts/Custom/Bridge/BridgeMarket.cs
new file mode 100644
index 0000000..9c2fe80
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgeMarket.cs
@@ -0,0 +1,591 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+using Server.Items;
+using Server.Mobiles;
+using Server.Multis;
+using Server.Engines.VendorSearching;
+
+namespace Server.Custom.Bridge
+{
+ ///
+ /// The shard-wide player-vendor 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 authoritative
+ /// vendor.listing frame per vendor, so the website can offer the search the in-game
+ /// Vendor Search gump offers — from outside the game.
+ ///
+ /// ---- Why this is a sweep and not an RPC ----
+ ///
+ /// The obvious shape is a market.snapshot request/reply like vendor.snapshot next
+ /// door. It cannot work: the sidecar's rpc router correlates on the FIRST frame carrying a
+ /// matching reqId and resolves a single oneshot, 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 in one frame is not an option either — the reply timeout is 10 s and
+ /// 40,000 listings do not serialize in time.
+ ///
+ /// So it is a diff sweep on the broadcast stream, shaped like :
+ /// one frame per vendor, authoritative for that vendor, plus vendor.listing.remove when one
+ /// goes away. The per-account vendor.snapshot RPC is untouched; the player portal
+ /// keeps using it.
+ ///
+ /// ---- The two perf traps, and what this does about them ----
+ ///
+ /// 1. **VendorSearch.GetItemName is a packet builder, not a field read.** It constructs an
+ /// ObjectPropertyList, calls GetProperties, 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. It is never called here. The frame carries `itemId`, `hue`,
+ /// `amount`, `price`, the plain `item.Name` field (null for most items) and
+ /// `item.LabelNumber`; the website resolves display names against its own cliloc table,
+ /// exactly as char.profile.equipment already does.
+ ///
+ /// (On any modern client the call would not even work: every current client ships its
+ /// Cliloc.* files compressed, ServUO's bundled Ultima.StringList reads only the old plain
+ /// layout, so VendorSearch.StringList is null and GetItemName returns item.Name anyway.
+ /// The in-game gump has the same gap.)
+ ///
+ /// 2. **A full pass is unbounded in world size.** 500 vendors × 80 listings is ~40,000 item
+ /// reads, and the reusable public GetItems(Container, List<Item>) recurses into
+ /// sub-containers, so the real count runs ABOVE the top-level pack.Items a naive estimate
+ /// would use. So the sweep is amortized: a persistent round-robin cursor over
+ /// PlayerVendor.PlayerVendors advances at most MarketSweepBatch vendors per tick, which
+ /// makes the PER-TICK cost bounded independently of how many vendors exist. Full coverage
+ /// takes ceil(vendors / batch) × MarketSweepSeconds. This is the one genuinely new pattern
+ /// versus the other sweeps, which all walk their whole collection every tick.
+ ///
+ /// ---- Privacy ----
+ ///
+ /// `pv.VendorSearch` is ServUO's own per-vendor opt-out and DoSearch filters on it, so a
+ /// player who hid their vendor in game is hidden on the website too: an opted-out vendor is
+ /// skipped entirely and the seen-set removal then drops it from the board. Map.Internal and
+ /// a null Backpack are skipped for the same reason DoSearch skips them.
+ ///
+ /// Owner is written as flat `ownerSerial`/`ownerName` — never through BridgeJson.Actor,
+ /// which would add `acct` and `webId`. Same argument BridgePoints makes: this is the widest-
+ /// audience surface the bridge has, and the site resolves serial → user from its own
+ /// shard_account_links mirror when staff need it.
+ ///
+ public static class BridgeMarket
+ {
+ private static Timer _timer;
+
+ // vendor serial -> last-emitted signature.
+ private static readonly Dictionary _last = new Dictionary();
+
+ // Round-robin cursor: an INDEX into PlayerVendor.PlayerVendors, not a serial. The list is
+ // mutated by placement/deletion between ticks, so the cursor is a hint, not a promise — it
+ // is wrapped and clamped every tick, and a shifted list at worst re-visits or defers a
+ // vendor by one cycle. Tracking a serial instead would cost a lookup to find "where was I"
+ // and buy nothing: the sweep is idempotent per vendor.
+ private static int _cursor;
+
+ private static long _sweeps, _emitted, _removed, _scanned, _skipped, _truncated;
+
+ // Per-tick cost, in milliseconds. Reported by `[bridge status` because the
+ // whole design of this sweep is a claim about that number — the batch cap is what makes it
+ // independent of world size — and an operator tuning MarketSweepBatch is otherwise tuning
+ // blind. `_maxMs` is the one that matters: the Core thread runs this between frames, so the
+ // worst tick is the budget, not the average.
+ private static double _lastMs, _maxMs;
+ private static readonly System.Diagnostics.Stopwatch _clock = new System.Diagnostics.Stopwatch();
+
+ // Reused across ticks. The item walk is single-threaded (Core thread) and the list is
+ // cleared before each vendor, so one buffer serves the whole sweep — the alternative is a
+ // fresh List- per vendor per tick, which at 25 vendors × every 60 s is pure garbage.
+ private static readonly List
- _items = new List
- ();
+
+ public static void Initialize()
+ {
+ if (!BridgeConfig.Enabled)
+ return;
+
+ EventSink.ServerStarted += OnServerStarted;
+ }
+
+ private static void OnServerStarted()
+ {
+ BridgeLink.Connected_Core += OnConnected;
+ Rearm();
+ }
+
+ private static void OnConnected()
+ {
+ // A new sidecar knows nothing, so drop the diff state and start the round-robin from
+ // the top. The re-emit of the whole world is self-throttled by the batch window — this
+ // is the one place the amortized sweep pays for itself twice, because a reconnect on a
+ // whole-world sweep would otherwise be the biggest burst the bridge ever produces.
+ _last.Clear();
+ _cursor = 0;
+ }
+
+ /// Stops and recreates the timer from current config. Called by `[bridge reload`.
+ public static void Rearm()
+ {
+ Stop();
+
+ _timer = Timer.DelayCall(
+ TimeSpan.FromSeconds(BridgeConfig.MarketSweepSeconds),
+ TimeSpan.FromSeconds(BridgeConfig.MarketSweepSeconds),
+ MarketSweep);
+ }
+
+ public static void Stop()
+ {
+ if (_timer != null) { _timer.Stop(); _timer = null; }
+ }
+
+ ///
+ /// A bare (key-less) string value, or JSON null.
+ ///
+ /// takes a non-null string — it dereferences
+ /// value.Length immediately — and writes the `,"key":`
+ /// prefix itself, so neither serves a value written inside a hand-built object. Most of
+ /// what this frame writes is legitimately null (an item's plain Name is null for nearly
+ /// every item, a vendor standing in the street has no house), so this is the common path
+ /// rather than an edge case.
+ ///
+ private static void Text(StringBuilder sb, string value)
+ {
+ if (value == null)
+ sb.Append("null");
+ else
+ BridgeJson.Escape(sb, value);
+ }
+
+ public static string Status()
+ {
+ var all = PlayerVendor.PlayerVendors;
+
+ return String.Format(
+ "market(enabled={0} sweeps={1} scanned={2} emitted={3} removed={4} skipped={5} truncated={6} tracked={7} vendors={8} cursor={9} batch={10} lastMs={11:F2} maxMs={12:F2})",
+ BridgeConfig.MarketEnabled, _sweeps, _scanned, _emitted, _removed, _skipped,
+ _truncated, _last.Count, all == null ? 0 : all.Count, _cursor,
+ BridgeConfig.MarketSweepBatch, _lastMs, _maxMs);
+ }
+
+ /// Runs one sweep now. Wired into `[bridge sweepnow`.
+ public static void SweepOnce()
+ {
+ MarketSweep();
+ }
+
+ ///
+ /// One tick: at most MarketSweepBatch vendors starting at the cursor, then the
+ /// removal pass.
+ ///
+ /// The removal pass is the part the batching makes subtle. `_last` holds every vendor
+ /// seen in ANY previous tick, but this tick only visited a window — so "not in this
+ /// tick's seen set" does NOT mean gone. Removals are therefore decided against the
+ /// CURRENT vendor list (plus the opt-out/validity rules), not against the window, which
+ /// is a cheap pass over serials rather than a second inventory walk.
+ ///
+ private static void MarketSweep()
+ {
+ try
+ {
+ if (!BridgeConfig.MarketEnabled)
+ return;
+
+ _sweeps++;
+
+ if (!BridgeLink.Connected)
+ return; // nothing is listening; do not fill the queue with perishable snapshots
+
+ _clock.Restart();
+
+ var all = PlayerVendor.PlayerVendors;
+
+ if (all == null || all.Count == 0)
+ {
+ Reap(null);
+ return;
+ }
+
+ // A live set of every serial that SHOULD be on the board right now, built as the
+ // window is walked plus a cheap pass over the rest. Built here rather than reusing
+ // a field so a throwing vendor cannot leave a half-built set behind.
+ var present = new HashSet();
+
+ var count = all.Count;
+ var batch = Math.Min(BridgeConfig.MarketSweepBatch, count);
+
+ if (_cursor >= count)
+ _cursor = 0;
+
+ for (int i = 0; i < count; i++)
+ {
+ var vendor = all[i];
+
+ if (Eligible(vendor))
+ present.Add(vendor.Serial);
+ }
+
+ for (int n = 0; n < batch; n++)
+ {
+ var index = (_cursor + n) % count;
+ var vendor = all[index];
+
+ if (!Eligible(vendor))
+ {
+ _skipped++;
+ continue;
+ }
+
+ // One bad vendor must not cost the rest of the window: the item walk touches
+ // arbitrary Item subclasses on a shard running modified scripts.
+ try
+ {
+ SweepVendor(vendor);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[Bridge] market sweep threw for 0x{0:X}: {1}",
+ vendor.Serial.Value, ex.Message);
+ }
+ }
+
+ _cursor = count == 0 ? 0 : (_cursor + batch) % count;
+
+ Reap(present);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[Bridge] market sweep threw: {0}", ex.Message);
+ }
+ finally
+ {
+ // In `finally` so a throwing tick still records what it cost — a sweep that blows
+ // the budget and then throws is exactly the one worth seeing in the status line.
+ if (_clock.IsRunning)
+ {
+ _clock.Stop();
+ _lastMs = _clock.Elapsed.TotalMilliseconds;
+ if (_lastMs > _maxMs)
+ _maxMs = _lastMs;
+
+ WarnIfSlow();
+ }
+ }
+ }
+
+ ///
+ /// Per-tick budget, milliseconds. The batch cap exists to hold a tick under this
+ /// regardless of world size, so exceeding it means MarketSweepBatch is too large for
+ /// this shard's shops — the one thing an operator needs told, and the one thing
+ /// `[bridge status` cannot tell them unprompted. Generous: a tick is off the frame
+ /// budget, and the alternative to a rare 50 ms tick is a permanently stale market.
+ ///
+ private const double SlowTickMs = 50.0;
+
+ // At most one warning a minute. A shard whose batch is genuinely too big would otherwise
+ // print every MarketSweepSeconds forever, and a log nobody can read is a log nobody reads.
+ private static DateTime _lastWarn = DateTime.MinValue;
+
+ private static void WarnIfSlow()
+ {
+ if (_lastMs <= SlowTickMs)
+ return;
+
+ var now = DateTime.UtcNow;
+
+ if (now - _lastWarn < TimeSpan.FromMinutes(1))
+ return;
+
+ _lastWarn = now;
+
+ Console.WriteLine(
+ // ASCII only. The ServUO console writes in the OS code page, so an em dash here
+ // renders as "???" in the log an operator would paste into an issue.
+ "[Bridge] market sweep took {0:F1} ms (budget {1:F0} ms) - lower Bridge.MarketSweepBatch (now {2}) if this persists",
+ _lastMs, SlowTickMs, BridgeConfig.MarketSweepBatch);
+ }
+
+ ///
+ /// The same filter DoSearch applies, so the website's index is the in-game index.
+ /// VendorSearch is the player's own opt-out toggle and is honoured first.
+ ///
+ private static bool Eligible(PlayerVendor vendor)
+ {
+ return vendor != null
+ && !vendor.Deleted
+ && vendor.VendorSearch
+ && vendor.Map != null
+ && vendor.Map != Map.Internal
+ && vendor.Backpack != null;
+ }
+
+ ///
+ /// Drops from the board every tracked vendor that is no longer eligible.
+ /// null means "there are no vendors at all", which clears it.
+ ///
+ private static void Reap(HashSet present)
+ {
+ if (_last.Count == 0)
+ return;
+
+ List gone = null;
+
+ foreach (var serial in _last.Keys)
+ {
+ if (present != null && present.Contains(serial))
+ continue;
+
+ if (gone == null)
+ gone = new List();
+
+ gone.Add(serial);
+ }
+
+ if (gone == null)
+ return;
+
+ for (int i = 0; i < gone.Count; i++)
+ {
+ _last.Remove(gone[i]);
+ BridgeLink.Emit(BridgeJson.Begin("vendor.listing.remove").Ser("serial", gone[i]).End());
+ _removed++;
+ }
+ }
+
+ private static void SweepVendor(PlayerVendor vendor)
+ {
+ _scanned++;
+
+ CollectItems(vendor);
+
+ var sig = Signature(vendor);
+
+ string prior;
+ if (_last.TryGetValue(vendor.Serial, out prior) && prior == sig)
+ return; // nothing about this shop changed since it was last published
+
+ _last[vendor.Serial] = sig;
+ BridgeLink.Emit(WriteVendor(vendor));
+ _emitted++;
+ }
+
+ ///
+ /// Every sellable item on one vendor, into the shared buffer.
+ ///
+ /// Mirrors VendorSearch's own private GetItems(PlayerVendor): the vendor's own movable
+ /// equipment (minus the backpack itself and hair layers, which are not merchandise)
+ /// followed by a recursive walk of the backpack. The recursion uses the PUBLIC
+ /// GetItems(Container, List<Item>) rather than a hand-rolled one so that 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 the first time that list changes.
+ ///
+ private static void CollectItems(PlayerVendor vendor)
+ {
+ _items.Clear();
+
+ var own = vendor.Items;
+
+ if (own != null)
+ {
+ for (int i = 0; i < own.Count; i++)
+ {
+ var item = own[i];
+
+ if (item == null || !item.Movable || item == vendor.Backpack)
+ continue;
+
+ if (item.Layer == Layer.Hair || item.Layer == Layer.FacialHair)
+ continue;
+
+ _items.Add(item);
+ }
+ }
+
+ if (vendor.Backpack != null)
+ VendorSearch.GetItems(vendor.Backpack, _items);
+ }
+
+ ///
+ /// A listing's price, and whether it was priced by an enclosing container.
+ ///
+ /// ServUO prices a container as a unit: an item inside a priced bag has no VendorItem of
+ /// its own and inherits the bag's price, which DoSearch surfaces as `isChild`. Reproduced
+ /// exactly, because a website that priced every item in a 40k bag at 40k would be lying
+ /// about the shard.
+ ///
+ private static int PriceOf(PlayerVendor vendor, Item item, out bool child)
+ {
+ child = false;
+
+ var vi = vendor.GetVendorItem(item);
+
+ if (vi != null)
+ return vi.Price;
+
+ var parent = item.Parent as Container;
+
+ while (parent != null)
+ {
+ vi = vendor.GetVendorItem(parent);
+
+ if (vi != null)
+ {
+ child = true;
+ return vi.Price;
+ }
+
+ parent = parent.Parent as Container;
+ }
+
+ return 0;
+ }
+
+ ///
+ /// The diff key. Location, shop name and owner are in it because they move a vendor's
+ /// row on the site; every listing's serial, price and amount are in it because those are
+ /// what a shopper searches on.
+ ///
+ /// Built over the SAME buffer the frame is written from, in the same order, so a
+ /// signature match really does mean an identical frame — a cheaper hash (count + a sum
+ /// of serial^price, as §8.3 first proposed) collides on the common case of two items
+ /// swapping prices, which is exactly what re-pricing a shop looks like.
+ ///
+ private static string Signature(PlayerVendor vendor)
+ {
+ var sb = new StringBuilder(256);
+
+ sb.Append(vendor.ShopName ?? "").Append('|');
+ sb.Append(vendor.Owner == null ? 0 : vendor.Owner.Serial.Value).Append('|');
+ sb.Append(vendor.Map == null ? "" : vendor.Map.Name).Append('|');
+ sb.Append(vendor.X).Append(',').Append(vendor.Y).Append('|');
+
+ var limit = Math.Min(_items.Count, BridgeConfig.MarketMaxListings);
+
+ sb.Append(_items.Count).Append('|');
+
+ for (int i = 0; i < limit; i++)
+ {
+ var item = _items[i];
+
+ if (item == null || item.Deleted)
+ continue;
+
+ bool child;
+ var price = PriceOf(vendor, item, out child);
+
+ if (price <= 0)
+ continue;
+
+ sb.Append(item.Serial.Value.ToString("X")).Append(':')
+ .Append(price).Append(':')
+ .Append(item.Amount).Append(';');
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// One vendor frame — authoritative for that vendor, so the website replaces its whole
+ /// listing set from it rather than merging.
+ ///
+ /// `location` is one nested object rather than flat map/x/y/region because it is ONE
+ /// admin-configurable field on the site (`market.location`): the visibility projection
+ /// matches literal JSON keys, so a nested object is what lets a single rule 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.
+ ///
+ /// `count` is the number of listings PUBLISHED, and `truncated` says the shop holds
+ /// more. A shop over the cap is a real thing (commodity resellers run thousands of
+ /// stacks) and the site says so rather than quietly showing a partial shop as complete.
+ ///
+ private static string WriteVendor(PlayerVendor vendor)
+ {
+ var sb = BridgeJson.Begin("vendor.listing")
+ .Ser("serial", vendor.Serial)
+ .Str("shopName", vendor.ShopName);
+
+ var owner = vendor.Owner;
+
+ if (owner != null)
+ {
+ sb.Ser("ownerSerial", owner.Serial);
+ sb.Str("ownerName", owner.Name);
+ }
+
+ sb.Append(",\"location\":{\"map\":");
+ Text(sb, vendor.Map == null ? null : vendor.Map.Name);
+ sb.Append(",\"x\":").Append(vendor.X);
+ sb.Append(",\"y\":").Append(vendor.Y);
+ sb.Append(",\"z\":").Append(vendor.Z);
+
+ var region = vendor.Region;
+ sb.Append(",\"region\":");
+ Text(sb, region == null ? null : region.Name);
+
+ // The house name is the sign's, which is what a player would be told to look for
+ // ("Bob's Villa"), not the house type. Null for a vendor standing outside one.
+ var house = vendor.House;
+ var sign = house == null ? null : house.Sign;
+ sb.Append(",\"house\":");
+ Text(sb, sign == null ? null : sign.GetName());
+
+ sb.Append('}');
+
+ var max = BridgeConfig.MarketMaxListings;
+ var published = 0;
+ var considered = 0;
+
+ var items = new StringBuilder(512);
+
+ for (int i = 0; i < _items.Count; i++)
+ {
+ var item = _items[i];
+
+ if (item == null || item.Deleted)
+ continue;
+
+ bool child;
+ var price = PriceOf(vendor, item, out child);
+
+ // Unpriced items are inventory, not listings — DoSearch drops them the same way.
+ if (price <= 0)
+ continue;
+
+ considered++;
+
+ if (published >= max)
+ continue;
+
+ if (published > 0)
+ items.Append(',');
+
+ items.Append("{\"serial\":\"0x").Append(item.Serial.Value.ToString("X")).Append('"');
+ items.Append(",\"itemId\":").Append(item.ItemID);
+ items.Append(",\"hue\":").Append(item.Hue);
+ items.Append(",\"amount\":").Append(item.Amount);
+ items.Append(",\"price\":").Append(price);
+
+ // The PLAIN Name field, which is null for most items — never GetItemName, which
+ // builds and parses a property packet per item. LabelNumber is the cliloc the
+ // website resolves against its own table.
+ items.Append(",\"name\":");
+ Text(items, item.Name);
+ items.Append(",\"cliloc\":").Append(item.LabelNumber);
+
+ if (child)
+ items.Append(",\"child\":true");
+
+ items.Append('}');
+
+ published++;
+ }
+
+ sb.Num("count", published);
+ sb.Num("total", considered);
+ sb.Bool("truncated", considered > published);
+
+ if (considered > published)
+ _truncated++;
+
+ sb.Append(",\"items\":[").Append(items).Append(']');
+
+ return sb.End();
+ }
+ }
+}