Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeMarket.cs
wtclaude 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

592 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
{
/// <summary>
/// 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
/// <c>vendor.listing</c> 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 <c>market.snapshot</c> 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 <see cref="BridgeHousing"/>:
/// one frame per vendor, authoritative for that vendor, plus vendor.listing.remove when one
/// goes away. The per-account <c>vendor.snapshot</c> 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&lt;Item&gt;) 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.
/// </summary>
public static class BridgeMarket
{
private static Timer _timer;
// vendor serial -> last-emitted signature.
private static readonly Dictionary<Serial, string> _last = new Dictionary<Serial, string>();
// 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<Item> per vendor per tick, which at 25 vendors × every 60 s is pure garbage.
private static readonly List<Item> _items = new List<Item>();
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;
}
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
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; }
}
/// <summary>
/// A bare (key-less) string value, or JSON null.
///
/// <see cref="BridgeJson.Escape"/> takes a non-null string — it dereferences
/// <c>value.Length</c> immediately — and <see cref="BridgeJson.Str"/> 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.
/// </summary>
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);
}
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
public static void SweepOnce()
{
MarketSweep();
}
/// <summary>
/// One tick: at most <c>MarketSweepBatch</c> 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.
/// </summary>
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<Serial>();
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();
}
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// The same filter DoSearch applies, so the website's index is the in-game index.
/// <c>VendorSearch</c> is the player's own opt-out toggle and is honoured first.
/// </summary>
private static bool Eligible(PlayerVendor vendor)
{
return vendor != null
&& !vendor.Deleted
&& vendor.VendorSearch
&& vendor.Map != null
&& vendor.Map != Map.Internal
&& vendor.Backpack != null;
}
/// <summary>
/// Drops from the board every tracked vendor that is no longer eligible.
/// <paramref name="present"/> null means "there are no vendors at all", which clears it.
/// </summary>
private static void Reap(HashSet<Serial> present)
{
if (_last.Count == 0)
return;
List<Serial> gone = null;
foreach (var serial in _last.Keys)
{
if (present != null && present.Contains(serial))
continue;
if (gone == null)
gone = new List<Serial>();
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++;
}
/// <summary>
/// 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&lt;Item&gt;) 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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();
}
}
}