Files
servuo-plugins/overlay/Config/Bridge.cfg
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

194 lines
10 KiB
INI

# uo-link bridge settings.
#
# Key scope is the filename: Bridge.cfg + StatSweepSeconds => "Bridge.StatSweepSeconds".
# Read in Configure(), which runs before World.Load.
# Loopback only. The socket being local is the trust boundary for inbound commands;
# if the sidecar ever moves off-host, add a shared secret first.
Host=127.0.0.1
Port=7788
# Outbound queue cap. On overflow the plugin drops oldest and counts the drops,
# because a stalled sidecar must never OOM the shard.
QueueCap=10000
# Sweep intervals, seconds. Measured on a 150-character shard: a vitals sweep costs
# 0.0015 ms/char, so 1000 online players is ~1.5 ms per sweep. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §1.
StatSweepSeconds=30
DecaySweepSeconds=60
EconomySweepSeconds=300
# Champion-spawn board poll. ChampionSpawn has no EventSink, so every spawn is diffed on
# this interval to emit champ.update on any status/level/kills/boss change. The world holds
# only a handful of spawns, so the pass is trivial; 5-10s is well within site tolerance.
ChampSweepSeconds=10
# Help-page queue poll. The in-game page queue has no EventSink, so it is diffed on this
# interval to emit page.new / page.closed / page.updated. A few seconds is fine for a
# support queue; the full open queue is also available on demand via pages.snapshot.
PageSweepSeconds=5
# Guild roster poll (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part B). Guilds expose only EventSink.JoinGuild, so
# create/disband/leave/leader/alliance changes are found by diffing BaseGuild.List on this
# interval (emit guild.update / guild.remove). Guild membership moves slowly; 60s is ample.
GuildSweepSeconds=60
# Town-governor poll. Each city's Governor / election is diffed on this interval to emit
# city.update on change. Governors turn over on the order of weeks, so a slow sweep is fine.
# Idle (emits nothing) unless the City Loyalty system is enabled (CityLoyalty.Enabled).
CitySweepSeconds=300
# Presence poll. Online population (total, per-facet, per-region) is snapshotted on this
# interval and emitted as presence.online only when it changes. Region transitions come
# through separately in real time as region.enter (EventSink.OnEnterRegion).
PresenceSweepSeconds=30
# Housing registry poll. Every house is diffed on this interval to emit house.update /
# house.remove (owner, region, location, decay). Houses change slowly; a few minutes is fine.
HousingSweepSeconds=300
# Points / loyalty leaderboards (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §7). ServUO carries ~25 point
# currencies (Queen's Loyalty, Void Pool, Casino, Clean Up Britannia, the nine city loyalties,
# the Doom/Khaldun/Kotl treasure systems, …). Each is diffed on this interval and emitted as
# one points.board frame per system when its top N moves.
#
# Slow on purpose: these are month-scale standings, and ten of the systems keep a row for
# every character ever created, so the pass is the widest read in the bridge. It is still
# cheap — a single bounded pass, never a sort — but there is nothing to gain by hurrying it.
PointsSweepSeconds=300
# Master switch for the boards. Off leaves char.profile points alone (see below).
PointsLeaderboardEnabled=true
# How many players per board. Clamped to 1..100 — the frame is emitted PER SYSTEM, so a big
# N is multiplied by ~25.
PointsTopN=10
# Which systems to publish, as a comma-separated list of PointsType names, e.g.
# PointsSystems=QueensLoyalty,CleanUpBritannia,VoidPool
# Blank (the default) publishes whatever the shard itself shows on the in-game loyalty gump
# (ShowOnLoyaltyGump), so a subsystem you add later gets a board without an edit here.
# An unrecognized name is logged and ignored, never silently dropped.
PointsSystems=
# Include a per-character "points" block in char.profile (the website character sheet). This
# is a lookup across every published system's table, so it is the dominant cost of building a
# profile; turn it off on a very large shard that does not want the sheet paying for it.
PointsProfileEnabled=true
# Also compute each system's rank in that block. OFF by default and worth leaving off: a
# points lookup stops at the character's own row, but a rank must count every row that beats
# them, in every system, on every profile build. The website already derives rank from the
# 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
# cannot drift from the server. Not a sweep: it changes only when you edit a .cfg.
#
# The frame is built from an explicit allowlist of keys in BridgeRuleset.cs. Server.cfg,
# Staff.cfg, Email.cfg, DataPath.cfg, Bridge.cfg, Compiler.cfg, Reports.cfg and Client.cfg
# are never read.
RulesetEnabled=true
# The one connection detail the bridge will publish, e.g. play.myshard.com,2593. Blank
# (the default) omits it entirely. Server.cfg's Address/Listen/Port are NEVER published —
# if you want a connect string on the site, put it here deliberately.
PublicConnectAddress=
# Include the save/restart schedule (AutoSave frequency, AutoRestart hour) in the frame.
# Turn off if you would rather not advertise a predictable restart window.
RulesetIncludeSchedule=true
# Shown to a player when they run [link. The website page where they enter the code.
LinkUrl=https://yoursite/link
# Town-crier news pushed from the website. Caps are defense in depth on top of the
# loopback trust boundary: a buggy or compromised sidecar still cannot flood the criers.
TownCrierMaxLines=6
TownCrierMaxLineLength=200
TownCrierMaxActive=20
TownCrierMaxDurationSec=86400
# Town Cryer news gump. Website articles (news.add) become entries in the modern Town
# Cryer News gump (TownCryerSystem.NewsEntries), separate from the scrolling-crier lines
# above. The article title is also proclaimed by the criers (announce defaults on). Caps
# are defense in depth on top of the loopback trust boundary.
NewsMaxTitleLength=100
NewsMaxBodyLength=2000
NewsMaxExternal=20
NewsAnnounceDurationSec=300
# Admin write plane (staff moderation from the website). OFF by default: the whole
# feature is opt-in per shard. When enabled, inbound admin.* commands (kick/ban/unban/
# broadcast) are honored. Authorization is enforced on the website; the shard trusts the
# loopback socket and applies a hard floor below.
AdminWriteEnabled=false
# The one shard-side safety floor. An admin.* command refuses any target whose AccessLevel
# is at or above this, so even a compromised sidecar can never touch the Owner. Values are
# AccessLevel names (Player, VIP, Counselor, Decorator, Spawner, GameMaster, Seer,
# Administrator, Developer, CoOwner, Owner). Default CoOwner => only Owner/CoOwners shielded.
AdminAccessFloor=CoOwner
# Defense-in-depth caps on admin.* payloads (mirroring the town-crier caps).
AdminBroadcastMaxLength=300
AdminReasonMaxLength=400
# Clamp on a timed ban's duration, seconds. A ban with no/zero duration is indefinite.
AdminBanMaxDurationSec=31536000
# Account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A). Which side may mint game accounts:
# website — the website is the authority; pair with Accounts.AutoCreateAccounts=false
# (else an in-game login of any new name still mints an account).
# game — the game server is the authority; website account.create is refused.
# hybrid — either side may create (the default).
# The bridge governs only the account.create verb; the in-game first-login auto-create is
# the core Accounts.AutoCreateAccounts setting, which you pair with the mode above. On boot
# the bridge warns if the two contradict. An unrecognized value here falls back to 'game'
# (the safest — no website creation).
SignupMode=hybrid
# Master switch for the account.create verb. Absent, it follows the mode (on unless
# SignupMode=game). Set explicitly to force it on or off regardless of mode.
AccountCreateEnabled=true
# Fail closed if account.create omits a usable browser IP. The per-IP cap
# (Accounts.AccountsPerIp) only means something if a missing/loopback IP is refused rather
# than waved through. Turn off only for a deployment that deliberately does not cap website
# signups by IP (MaxAccountsPerIP still applies in-game either way).
RequireIpForCreate=true
# Length caps on a website-supplied username / password, checked before the account is made.
AccountNameMaxLength=16
AccountPasswordMaxLength=30
# The test scaffolding in tools/scaffolding/ reads its own flags from this file
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
# Config.Get returns the default of false when a key is missing, so a deployed
# server never runs the scaffolding even if its .cs files are present.