Three emitter changes and the overlay's protocol declaration, in one PR because
"The bridge is a contract": overlay.toml must be bumped in the same change as the
emitters or the next bundle silently fails to compose.
BridgeSweeps — house.decay gains ownerName and a nested `schedule`
{dynamicDecay, nextStage, decayPeriodSec, estimatedCollapse}.
estimatedCollapse is emitted only where ServUO can actually know it. Dynamic decay
(Core.ML) draws each stage's duration at RANDOM when the stage is entered, so
NextDecayStage is exact for the next transition and nothing beyond it is known —
collapse becomes exact only at IDOC, where the next transition IS the collapse.
Static decay is a pure function of LastRefreshed and DecayPeriod, so it is exact at
every stage. Emitting it anywhere else would publish a guess as a fact, and on the
website's side that becomes a dated promise in someone's mail.
BridgeMarket — vendor.listing gains ownerAcct and a nested `fees` block.
ownerAcct is the one that matters structurally: the frame has carried ownerName
since v3, but a character name joins to nothing — only the game account is the
website's link key. The fees block resolves PlayerVendor.PayTimer's dismissal rule
(pay > totalGold => Destroy) on the shard, because both halves of that comparison
differ between ServUO's two vendor systems and re-deriving them downstream would be
a second implementation of a rule that lives in core.
No daysRemaining: under the old vendor system a pay period is a UO day
(Clock.MinutesPerUODay, about two real hours), so the obvious name would be wrong
by a factor of twelve on exactly the shards least likely to notice. periodsRemaining
plus the interval, and dismissalAt as an instant. A commission vendor has no pay
timer at all and reports exempt with no schedule — "never dismissed" is not the same
as "dismissed in 400 days".
BridgeEvents — a new account.login.result kind.
EventSink.AccountLogin is a veto hook that fires BEFORE the auth decision, and
AccountLoginEventArgs constructs with Accepted = true, so the existing
account.login.attempt fires on successful logins too and cannot carry a verdict. A
security rule built on it would have mailed "someone tried to get into your account"
every time the player logged in.
The verdict is read one Core slice later via DelayCall(Zero). That needs no core
patch AND does not depend on handler subscription order, which ServUO does not define
and a shard's own scripts can change. reason is omitted on an accept, because
ALRReason's zero value is Invalid and would read as a failure reason. The address is
resolved inside the handler, since AccountLogin_ReplyRej disposes the NetState before
the deferred read runs. The password is never read, logged or emitted.
tools/scaffolding/BridgeProtocol5Probe.cs drives all three on a live shard, and the
README records the two traps it took to get there — both of which produce SILENCE
rather than an error, so each looks exactly like a broken emitter:
* An in-process login probe can never produce accepted:true. AccountHandler calls
acct.HasAccess(e.State) BEFORE it checks the password, and a null NetState fails
that. Only a real socket proves the accepted half — and it is the better test
anyway, since it also produces the real ip.
* Forcing a decay stage on a house that cannot decay emits nothing at all. Only
Condemned and ManualRefresh houses decay; an AutoRefresh one — and the owner's
NEWEST house is always AutoRefresh — has a DecayLevel getter that calls
ResetDynamicDecay() and reports Ageless, wiping the forced stage before the sweep
reads it.
Verified on the local rig against the release sidecar: a house walked
Fairly -> Greatly -> IDOC carried estimatedCollapse on the IDOC frame and only there;
every vendor's periodsRemaining matched funds/chargePerPeriod, including one at 0
whose dismissalAt equals its next tick; a real socket login gave
accepted:false reason:BadPass and then accepted:true. Compiles clean against ServUO
57.4 reference assemblies.
Docs: RunicGateway/docs link/v5.md.
Co-Authored-By: Claude <noreply@anthropic.com>
671 lines
28 KiB
C#
671 lines
28 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Text;
|
||
|
||
using Server.Accounting;
|
||
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<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.
|
||
/// </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<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.
|
||
/// </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);
|
||
|
||
// Protocol 5. Without this the listing names an owner the website cannot resolve to
|
||
// a person: ownerName is a character name, and only the account is the link key.
|
||
var acct = owner.Account as Account;
|
||
if (acct != null)
|
||
sb.Str("ownerAcct", acct.Username);
|
||
}
|
||
|
||
AppendFees(sb, vendor);
|
||
|
||
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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Protocol 5. The vendor's fee state, which is what makes "your vendor is about to be
|
||
/// dismissed" a thing the website can say BEFORE it happens instead of after.
|
||
///
|
||
/// The dismissal rule is PlayerVendor.PayTimer.OnTick: at every tick the charge is
|
||
/// compared with the funds, and `if (pay > totalGold) Destroy()`. Both halves of that
|
||
/// comparison differ between ServUO's two vendor systems, so both are resolved here
|
||
/// rather than left for the sidecar or the website to guess at:
|
||
///
|
||
/// | charge | funds | interval
|
||
/// NewVendorSystem | ChargePerRealWorldDay | HoldGold | 1 real day
|
||
/// old system | ChargePerDay | BankAccount + HoldGold | 1 UO day
|
||
///
|
||
/// Two consequences worth stating, because both are easy to get wrong downstream:
|
||
///
|
||
/// * A field called `daysRemaining` would be WRONG on an old-system shard, where a pay
|
||
/// period is a UO day (Clock.MinutesPerUODay, roughly two real hours) rather than a
|
||
/// real one. So this emits `periodsRemaining` plus the interval that gives it meaning,
|
||
/// and resolves the arithmetic into `dismissalAt` -- an instant, which needs no units.
|
||
/// * A commission vendor (IsCommission) has no PayTimer at all and is never dismissed
|
||
/// for fees. It reports exempt:true and no schedule, rather than a misleading
|
||
/// "infinite days".
|
||
///
|
||
/// `dismissalAt` assumes no further sales or deposits, exactly as a bank balance
|
||
/// projection does. Unlike a dynamic-decay house, though, there is no randomness in it:
|
||
/// given the current funds it is the exact tick the vendor is destroyed on.
|
||
/// </summary>
|
||
private static void AppendFees(StringBuilder sb, PlayerVendor vendor)
|
||
{
|
||
sb.Append(",\"fees\":{");
|
||
|
||
if (vendor.IsCommission)
|
||
{
|
||
sb.Append("\"exempt\":true}");
|
||
return;
|
||
}
|
||
|
||
bool newSystem = BaseHouse.NewVendorSystem;
|
||
|
||
int charge = newSystem ? vendor.ChargePerRealWorldDay : vendor.ChargePerDay;
|
||
int funds = newSystem ? vendor.HoldGold : vendor.BankAccount + vendor.HoldGold;
|
||
|
||
sb.Append("\"exempt\":false");
|
||
sb.Append(",\"newVendorSystem\":").Append(newSystem ? "true" : "false");
|
||
sb.Append(",\"chargePerPeriod\":").Append(charge);
|
||
sb.Append(",\"funds\":").Append(funds);
|
||
sb.Append(",\"holdGold\":").Append(vendor.HoldGold);
|
||
sb.Append(",\"bankAccount\":").Append(vendor.BankAccount);
|
||
|
||
var interval = newSystem ? TimeSpan.FromDays(1.0) : TimeSpan.FromMinutes(Clock.MinutesPerUODay);
|
||
sb.Append(",\"payIntervalSec\":").Append((long)interval.TotalSeconds);
|
||
|
||
var nextPay = vendor.NextPayTime.ToUniversalTime();
|
||
sb.Append(",\"nextPayAt\":");
|
||
Text(sb, nextPay.ToString("o"));
|
||
|
||
// A free vendor (no priced stock under the old system can reach charge 0) never runs out.
|
||
if (charge > 0)
|
||
{
|
||
// Ticks it survives before the one that finds pay > totalGold.
|
||
long periods = funds / charge;
|
||
sb.Append(",\"periodsRemaining\":").Append(periods);
|
||
|
||
sb.Append(",\"dismissalAt\":");
|
||
Text(sb, nextPay.AddSeconds(periods * interval.TotalSeconds).ToString("o"));
|
||
}
|
||
|
||
sb.Append('}');
|
||
}
|
||
}
|
||
}
|