Files
servuo-plugins/overlay/Config/Bridge.cfg
wtclaude f6a86ff8c2 feat(bridge): what an event borrows, and the two one-shots (Phase 12b)
The shard half of protocol 7 part b. Two lease planes whose value lives on
something already in the world, and two verbs that cannot be taken back.

A LEASE HERE MUST BE PERSISTED, AND THE CONFIG PLANE'S MUST NOT

11b's fail-safe is stated plainly in its own header: a lease that never reaches
disk means a shard restart is a FREE restore. That argument depends entirely on
the leased value being memory-only too, and here it is not. A spawner is an Item
and is in the world save; a seasonal entry is written to
`Saves/Misc/SeasonalEvents.bin` by ServUO's own `EventSink.WorldSave`. So a
restart does not put either back -- it puts the CHANGE back and throws away the
deadline timer that was going to undo it, leaving the world at the leased value
with nothing here remembering it is borrowed.

So the Bridge gains its THIRD save file, `Saves/Bridge/Leases.bin`, written by
the same `EventSink.WorldSave` that writes what it describes, with deadlines
re-armed at load. A deadline that passed while the shard was down fires AT ONCE:
the promise was "back at baseline by then", and extending it would silently turn
a two-hour lease into however long the outage was. Config holds are still not
written down -- the same argument, applied to planes where its premise is false.

A TARGET IS A SERIAL OR A UniqueId, AND BOTH ARE NEEDED

A serial is what `[props` shows a GM. An `XmlSpawner.UniqueId` is what the
shard's own `Spawns/*.xml` carry -- and it is not a convenience: a dropdown built
from serials is IMPOSSIBLE, because serials are assigned when the world is built
and nothing off-shard knows them. A lease addressable only by serial could have
no authoring list at all.

`Spawner` and `XmlSpawner` share all four property names, which is a fact about
this tree rather than a convenience: the spawn files load as XmlSpawners while
`[add spawner` makes the native one. And it is `MaxCount`, not the `Amount`
EVENTS_PLAN.md named -- there is no such property. `MinDelay`/`MaxDelay` are
TimeSpans, so the wire carries seconds.

The allowlist is checked against the object's OWN type, which is the sentence the
whole plane rests on: a serial is a number a caller chooses, so that check is all
that stands between `Spawner.MaxCount` and any item on the shard. Reflection is
bounded three ways -- the pair must be in the catalog, the property must carry
`CommandProperty` (so this can never reach further than `[set` could), and its
CLR type must be one this file renders.

THE SELF-CHECK, AND THE ONE FAILURE NO PROBE CAN CATCH

§N10 in full: a config key is probed live (write, read back, restore) because
there is exactly one of it. A property CANNOT be -- thousands of instances and no
canonical one, so probing would mean writing to somebody's spawner at boot. What
is verified instead is everything verifiable without touching the world.

And `TreasuresOfTokuno` is excluded by name, because `IsActive()` reads its own
`DropEra` rather than `Status`: the write succeeds, the value reads back, a
compare-and-set restore passes, and the capability does nothing at all. That is
N10's "capability that lies" in its purest form and the only way to find it is to
read the source. §G also called this toggle "small and safe" -- it is safe, but
`OnStatusChange()` generates or removes world content for six of the eight.

THE ONE-SHOTS

Who receives a grant is answered HERE. The website has the list too, but a module
cannot read core's tables, so the alternative was a new core surface handing
participants to a module. Not needed: 11b's participation ledger already holds
them, keyed by the same serials. A run with no ledger is a 404; a run whose
ledger is open and empty is a 200 with `granted: 0`, because an event nobody
attended still happened. An undeliverable grant is DELETED rather than dropped --
`AddItem` failing on a full backpack would otherwise leave it at (0,0).

A save stops the world, so it is rate-limited rather than capped, counting
ServUO's own autosave as the last one. Refused, never queued: a queued save would
land at a moment nobody chose.

VERIFIED

Compiles clean (0 warnings, 0 errors). Then a full walk on the real local ServUO
57.4 world against the release sidecar:

- all six catalog keys survive the boot self-check; `current` is correctly absent
  on the targeted rows and filled when a target is named;
- a spawner reads the same by UniqueId and by serial;
- TWO RUNS ON TWO SPAWNERS BOTH SUCCEED while a second run on the SAME spawner is
  refused -- the whole reason for the targeted ref;
- a GM edit behind the plane's back yields `lease.drifted` and the world is left
  at 55, not reverted; a clean release restores to baseline;
- ToT refused with its own reason, a bad status refused, Fellowship toggled;
- grant: no ledger 404, empty ledger 200 `granted: 0`, unknown item 400, over the
  stack bound 400; save 200 then 429 inside the interval;
- BOTH HOLDS AND BOTH LEASED VALUES SURVIVE save + clean shutdown + restart, the
  deadlines re-arm, and a release across the restart still compare-and-sets;
- with only a CONFIG lease held, `Leases.bin` is 8 bytes and names nothing;
- refusals: targeted-with-no-target, untargeted-with-a-target, out of range, over
  30 days, a target that is not there, and a `ChainChest` refused as a spawner;
- 90 seconds becomes `00:01:30` and the baseline reads back as 18000;
- a deleted target reads `unreadable` and releases `targetGone: true`.

The test world was never saved after the deliberate deletion, so it is intact.

Refs: docs/link/v7.md §11-§14, docs/website/EVENTS_PLAN.md Phase 12b

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 08:07:12 -05:00

302 lines
16 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
# Members per guild.roster frame (Protocol 4). A roster is the only fat frame the bridge emits
# (~69 bytes per member) and the sidecar reads a line with no length bound, so this caps it; a
# guild over the cap is split across continuation frames carrying seq/more. 500 members is ~35 KB,
# past any realistic guild, so the split path is an edge case rather than the norm.
GuildRosterMembersPerLine=500
# Guilds that may emit a roster in one sweep. Every guild looks changed right after a sidecar
# reconnect, and building hundreds of fat frames in a single Core-thread pass is exactly the stall
# the bridge exists to avoid. The sweep re-arms itself every 2s while a baseline is draining, so
# lowering this slows the catch-up without making the site wait a full sweep interval per batch.
GuildRosterGuildsPerTick=25
# 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 event plane (docs/link/v6.md 8) ──────────────────────────────────────
#
# Leases and the participation ledger: the website holding a live config value for a bounded
# time, and this shard counting who took part in a run. Both are driven on a SCHEDULE, by an
# event the website starts unattended.
#
# This is deliberately NOT AdminWriteEnabled. Turning the admin plane on is consenting to
# staff moderation driven from a screen a human is looking at; turning this on is consenting
# to the website changing and watching your world at four in the morning. One switch could
# not honestly express both.
#
# A lease always carries its own deadline and this shard restores the baseline when it
# passes, whether or not the website is ever heard from again -- and a lease is never written
# to disk, so a restart puts every leased value back too.
EventsEnabled=false
# The longest this shard will hold a lease, whatever the website asks for. Thirty days.
# A longer request is REFUSED rather than shortened: a silently-clamped lease would leave the
# two halves disagreeing about when the world comes back.
LeaseMaxDurationSec=2592000
# How long a finished lease stays listed after its deadline restored it, so a teardown that
# arrives late still gets a definite verdict instead of finding nothing.
LeaseGraceSec=86400
# How often the participation sweep credits everyone standing in a run's area, and what one
# kill inside it is worth against one minute of being there.
ParticipationSweepSeconds=30
ParticipationKillWeight=5.0
# Bounds. Runs counted at once, members per run, and the widest area an event may declare.
ParticipationMaxRuns=8
ParticipationMaxMembers=2000
ParticipationMaxRadius=300
# How long a closed run's tally stays readable before this shard forgets it, and how many
# members one snapshot resolves before yielding the Core thread.
ParticipationGraceSec=86400
ParticipationSnapshotChunk=100
# ---- The world verbs (protocol 7) ----------------------------------------------------
# What an event may PLACE in the world, all of it owned by the run that placed it and
# deleted when the run tears down. Every ceiling here REFUSES rather than clamps: this
# shard's bound exists for the case where the website is wrong, and a quiet clamp would
# leave the two halves disagreeing about what was actually placed.
#
# The defaults are the EM Program's published quotas, because they are the only numbers
# anyone has defended in public.
# Per CALL: creatures, enhanced "boss" variants, oracle NPCs and decoration items.
EventsMaxCreatures=30
EventsMaxBosses=4
EventsMaxNpcs=5
EventsMaxDecor=60
# The longest a temporary gate may stand. The shard closes it on its own when the time
# passes, whether or not the website is ever heard from again.
EventsMaxGateMinutes=240
# Per RUN, across every verb above. The per-call ceilings bound one request; this bounds
# a run that calls a verb in a loop, which is the shape a runaway schedule takes.
EventsMaxOwnedPerRun=200
# How far from the chosen spot things may be scattered.
EventsMaxSpread=40
# How much harder than normal a "boss" may be made. EVENTS.md calls it an enhanced
# regular mob, so this is low enough that the result is still the creature that was
# picked.
EventsMaxBossMultiplier=10.0
# The oracle NPC: how many keyword lines it answers to, how close a player must be to be
# greeted and to be heard, and how often it will speak to the same player.
EventsOracleMaxLines=5
EventsOracleGreetRange=4
EventsOracleSpeechRange=8
EventsOracleGreetCooldownSec=60
EventsOracleAnswerCooldownSec=5
# How often expired gates are collected and rows for objects the world has already lost
# are pruned.
EventsSweepSeconds=30
# Item grants (Phase 12b). The first bounds how many characters one grant may reach --
# the run's participation ledger is the recipient list, so this is a bound on the size of
# an event rather than on a number somebody typed. The second bounds one hand.
# Both REFUSE rather than clamp: the website records what was handed out.
EventsMaxGrantPerRun=200
EventsMaxGrantStack=1000
# The shortest gap between world saves, counted from the last save by anybody --
# ServUO's own autosave included. A save stops the world, so this is a rate limit rather
# than a cap, and a save asked for too soon is refused rather than queued: a queued save
# would land at a moment nobody chose. Set to 0 to allow a save at any time.
EventsMinSaveIntervalSec=300
# 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.