The spawn atlas was the one place the platform's rule -- only the sidecar
bridges the shard -- was broken, and it was broken by the component that faces
the internet: SPAWN_ATLAS.md required the website to read the ServUO tree off a
bind mount or a shared volume. This serves those files over the loopback link
instead (docs/link/v8.md 10).
The measurement came first and changed the shape. 10 said the shard would serve
`tree/<label>` -> bytes; against a stock 57.4 tree it cannot. Spawns/trammel.xml
is 4.03 MB, the sidecar discards any inbound line over 1 MiB, and that file as
one base64 row is 5.4 MiB -- it would be dropped, time out, and be re-requested
forever with no error anywhere. Two files on a STOCK tree are in that state.
So a file crosses as 512 KiB chunks, each gzipped: tree/Spawns/trammel.xml/c0
and so on, which is 5's depth scheme doing the same job it does for
body/400/a0/f0 and needing no protocol change to do it. The chunk is the bound
and the compression is only the saving -- nothing guarantees an operator's files
compress, so the ceiling has to hold when they do not, and a 512 KiB chunk that
refuses to compress is still ~683 KiB of base64, inside the wire cap that
AssetBatchBytes' deliberate factor of two leaves room for.
It is a `tree` FAMILY on assets.fetch rather than 14's separate tree.* commands:
phase 5 had already learned that the command is the transport and the family is
a property of the key, and assets.manifest is generalised here the same way.
That reuses the single slot, the paging envelope, the key ceiling and the
mid-import guard -- and leaves `link` with nothing to do for the third phase
running.
But it gets its OWN consent, Bridge.TreeEnabled. AssetsEnabled is an operator
agreeing the website may read their EA-licensed UO client; this is the shard's
own configuration, which they wrote, and which the public bestiary is built
from. One switch could not express both, and the thing that would silently
disappear for an operator who declined the first is their spawn atlas. So the
consent check moved into the family lookup, and assets.sources answers whenever
either plane is on, reporting `families` filtered to what is actually enabled --
which is how a tree-only shard's website discovers there is anything to ask for.
Two defects found, and which harness found which is the part worth keeping:
- An empty `catalog` is not an absent one. `expected != null` refused every
fetch from a caller that sent "", with a sentence naming no catalog at all.
Found by an offline probe that passed one by accident.
- GZipStream writes NOTHING for zero bytes of input -- the header is emitted
lazily, so a stream opened and closed without a write yields a zero-length
buffer rather than the 20-byte empty member. Stock ServUO ships two empty
decoration files, so this broke every import off an untouched tree. The
offline probe reassembled all 141 files and reported success, because .NET's
own decompressor reads an empty stream as empty data and the chunk's
declared length (0) and hash (of nothing) both agreed. Only the live walk,
through a reader on another runtime, disagreed.
Measured end to end against a live shard, the real sidecar and the website's own
reader: 141 files, 11,895,427 bytes, 158 chunks, 3 pages, 1.33 MB on the wire,
512 ms; every file byte-identical to disk; the atlas built over the bridge
identical to the one built off it. A drift check is the manifest alone -- 32 KB,
~70 ms, no file bytes.
The label set is this shard's, never the caller's: a fetch resolves against the
set the shard itself enumerated, and tree/../../Scripts/..., Config/Bridge.cfg
and Saves/Accounts/accounts.xml are all answered `absent` before a path is built
out of them.
Protocol stays 8 and EXTRACTOR_VERSION stays 3 -- this family derives nothing,
it forwards an operator's own file unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
653 lines
37 KiB
C#
653 lines
37 KiB
C#
using System;
|
|
|
|
namespace Server.Custom.Bridge
|
|
{
|
|
/// <summary>
|
|
/// Which side may mint game accounts. Governs the bridge's inbound account.create verb;
|
|
/// the in-game first-login auto-create is a separate core setting (Accounts.AutoCreateAccounts)
|
|
/// the operator pairs with this (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §2).
|
|
/// </summary>
|
|
public enum SignupMode
|
|
{
|
|
Website, // website is the account authority; in-game auto-create should be off
|
|
Game, // game server is the authority; account.create is refused
|
|
Hybrid // either side may create
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tunables from Config/Bridge.cfg. Key scope is the filename, so `Port=7788` there
|
|
/// reads as "Bridge.Port" here.
|
|
///
|
|
/// Loaded in Configure(), which ScriptCompiler invokes before World.Load.
|
|
/// </summary>
|
|
public static class BridgeConfig
|
|
{
|
|
public static string Host { get; private set; }
|
|
public static int Port { get; private set; }
|
|
public static int QueueCap { get; private set; }
|
|
|
|
public static int StatSweepSeconds { get; private set; }
|
|
public static int DecaySweepSeconds { get; private set; }
|
|
public static int EconomySweepSeconds { get; private set; }
|
|
public static int PageSweepSeconds { get; private set; }
|
|
public static int ChampSweepSeconds { get; private set; }
|
|
public static int GuildSweepSeconds { get; private set; }
|
|
public static int CitySweepSeconds { get; private set; }
|
|
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; }
|
|
|
|
// ---- guild rosters (Protocol 4) ----
|
|
public static int GuildRosterMembersPerLine { get; private set; }
|
|
public static int GuildRosterGuildsPerTick { 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; }
|
|
public static int PointsTopN { get; private set; }
|
|
public static string PointsSystems { get; private set; }
|
|
public static bool PointsProfileEnabled { get; private set; }
|
|
public static bool PointsProfileRank { get; private set; }
|
|
|
|
// ---- shard ruleset (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §5) ----
|
|
public static bool RulesetEnabled { get; private set; }
|
|
public static string PublicConnectAddress { get; private set; }
|
|
public static bool RulesetIncludeSchedule { get; private set; }
|
|
|
|
public static string LinkUrl { get; private set; }
|
|
|
|
public static int TownCrierMaxLines { get; private set; }
|
|
public static int TownCrierMaxLineLength { get; private set; }
|
|
public static int TownCrierMaxActive { get; private set; }
|
|
public static int TownCrierMaxDurationSec { get; private set; }
|
|
|
|
// Town Cryer news gump (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §16).
|
|
public static int NewsMaxTitleLength { get; private set; }
|
|
public static int NewsMaxBodyLength { get; private set; }
|
|
public static int NewsMaxExternal { get; private set; }
|
|
public static int NewsAnnounceDurationSec { get; private set; }
|
|
|
|
public static bool AdminWriteEnabled { get; private set; }
|
|
public static AccessLevel AdminAccessFloor { get; private set; }
|
|
public static int AdminBroadcastMaxLength { get; private set; }
|
|
public static int AdminReasonMaxLength { get; private set; }
|
|
public static int AdminBanMaxDurationSec { get; private set; }
|
|
|
|
// ---- the event plane (docs/link/v6.md §8, EVENTS_PLAN.md Phase 11b) ----
|
|
//
|
|
// **Its own gate, deliberately not AdminWriteEnabled** (org lead, 2026-09-04). Enabling the
|
|
// admin plane is an operator consenting to staff moderation driven from the website - a
|
|
// human pressing kick or ban on a screen. A lease and a participation ledger are the
|
|
// website changing and watching the world on a SCHEDULE, unattended, at four in the
|
|
// morning. Those are different consents, and one switch cannot express both.
|
|
public static bool EventsEnabled { get; private set; }
|
|
|
|
// ---- the asset plane (docs/link/v8.md §3, protocol 8) ----
|
|
//
|
|
// Its own gate again, and for the same reason the event plane got one: enabling this is
|
|
// an operator consenting to the WEBSITE READING THEIR CLIENT FILES -- art, animations and
|
|
// the string table, off the host's disk, over the link. That is a different consent from
|
|
// publishing world state, and one switch cannot express both. Reads only: nothing on this
|
|
// plane writes anything, anywhere.
|
|
public static bool AssetsEnabled { get; private set; }
|
|
public static int AssetBatchBytes { get; private set; }
|
|
|
|
// How many types one `assets.bodies` request may name (§8, phase 3). This is the ONLY
|
|
// asset-plane bound counted in items rather than bytes, and deliberately so: the cost it
|
|
// bounds is not the size of the reply, it is constructing and deleting that many real
|
|
// mobiles ON THE CORE THREAD, between two ticks of the world.
|
|
public static int AssetBodyBatch { get; private set; }
|
|
|
|
// How many keys one `assets.fetch` request may name. Bytes still cut the page; this only
|
|
// bounds how large a request the shard will parse and walk at all.
|
|
public static int AssetFetchKeys { get; private set; }
|
|
|
|
// The wall-clock budget for one catalogue page (§4.8, phase 3). The catalogue's rows are
|
|
// ninety bytes, so the byte budget never stops it -- but building them means decoding
|
|
// hundreds of animations, and the sidecar gives a reply ten seconds. Kept well under that,
|
|
// because the reply still has to be built, serialised and cross the wire afterwards.
|
|
public static int AssetScanMs { get; private set; }
|
|
|
|
// Which direction the catalogue renders (§5.1). Both are settings and neither is in the
|
|
// asset key, because five directions would five-fold every count in §11 to express a
|
|
// choice nobody is going to vary.
|
|
//
|
|
// The split is not arbitrary and was found by RENDERING all five rather than from a table:
|
|
// index 0 is head-on, which is what a character portrait wants and the least legible view
|
|
// there is of a four-legged creature. A wolf seen from the front is a dark blob; at index
|
|
// 1, the front three-quarter, it is unmistakably a wolf.
|
|
public static int AssetPlayerDirection { get; private set; }
|
|
public static int AssetCreatureDirection { get; private set; }
|
|
|
|
// ---- the tree plane (docs/link/v8.md §10, phase 7) ----
|
|
//
|
|
// Its OWN gate, and the third one on this link for the third kind of consent. The asset
|
|
// gate above is the operator agreeing that the website may read THEIR UO CLIENT -- art
|
|
// and animations and a string table that came from EA. This one is the operator agreeing
|
|
// that it may read THE SHARD'S OWN CONFIGURATION: the spawn files, the region and
|
|
// location definitions, the champion table, the decoration lists. Those are the
|
|
// operator's own work rather than a licensed client, and they are what the spawn atlas is
|
|
// built out of -- so a shard that declines to serve client art must still be able to
|
|
// publish where its creatures live. One switch could not have expressed both, and the
|
|
// atlas would have been the thing that silently disappeared.
|
|
//
|
|
// Reads only, and only the five labelled groups SPAWN_ATLAS.md already names. Nothing
|
|
// here joins a path the website sent: a request names a label this shard enumerated, or
|
|
// it is refused.
|
|
public static bool TreeEnabled { get; private set; }
|
|
|
|
// How much of a tree file one chunk carries, BEFORE compression (§10). The chunk is the
|
|
// thing that makes this transferable at all: a stock Spawns/trammel.xml is 4.03 MB and
|
|
// the sidecar discards any inbound line over 1 MiB, so the file as a single base64 row
|
|
// could never arrive -- it would time out and be re-requested forever, which is a failure
|
|
// with no error in it anywhere.
|
|
//
|
|
// Compression is what makes it cheap (a spawn file gzips ~18x, so a chunk is typically
|
|
// 40 KB on the wire) and the chunk is what makes it BOUNDED: gzip cannot be relied on to
|
|
// shrink anything, so the ceiling has to hold for input that does not compress at all.
|
|
// At 512 KiB a worst-case incompressible chunk is ~683 KiB of base64, which still fits
|
|
// the wire under AssetBatchBytes' deliberate factor of two.
|
|
public static int TreeChunkBytes { get; private set; }
|
|
|
|
// How many bytes of rendered item and land art the shard holds between requests (§11,
|
|
// phase 5). This is a convenience, not a store: the website keeps every picture it fetches
|
|
// and does not ask twice, so what this actually buys is the second page of a batch, a
|
|
// retry after a 425, and the same item appearing in two rows of one page. Sized so a
|
|
// full 512 KB batch and the one before it both fit with room over.
|
|
public static int AssetArtCacheBytes { get; private set; }
|
|
|
|
public static int LeaseMaxDurationSec { get; private set; }
|
|
public static int LeaseGraceSec { get; private set; }
|
|
|
|
public static int ParticipationSweepSeconds { get; private set; }
|
|
public static double ParticipationKillWeight { get; private set; }
|
|
public static int ParticipationMaxRuns { get; private set; }
|
|
public static int ParticipationMaxMembers { get; private set; }
|
|
public static int ParticipationMaxRadius { get; private set; }
|
|
public static int ParticipationGraceSec { get; private set; }
|
|
public static int ParticipationSnapshotChunk { get; private set; }
|
|
|
|
// The world verbs (protocol 7, EVENTS_PLAN.md Phase 12a). Each of these is the shard's
|
|
// OWN ceiling rather than a mirror of the module's budget dimension, and each REFUSES
|
|
// rather than clamps -- BridgeLeases' argument for LeaseMaxDurationSec, unchanged: the
|
|
// 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.
|
|
public static int EventsMaxCreatures { get; private set; }
|
|
public static int EventsMaxBosses { get; private set; }
|
|
public static int EventsMaxNpcs { get; private set; }
|
|
public static int EventsMaxDecor { get; private set; }
|
|
public static int EventsMaxGateMinutes { get; private set; }
|
|
public static int EventsMaxOwnedPerRun { get; private set; }
|
|
public static int EventsMaxSpread { get; private set; }
|
|
public static double EventsMaxBossMultiplier { get; private set; }
|
|
public static int EventsOracleMaxLines { get; private set; }
|
|
public static int EventsOracleGreetRange { get; private set; }
|
|
public static int EventsOracleSpeechRange { get; private set; }
|
|
public static int EventsOracleGreetCooldownSec { get; private set; }
|
|
public static int EventsOracleAnswerCooldownSec { get; private set; }
|
|
public static int EventsSweepSeconds { get; private set; }
|
|
public static int EventsMaxGrantPerRun { get; private set; }
|
|
public static int EventsMaxGrantStack { get; private set; }
|
|
public static int EventsMinSaveIntervalSec { get; private set; }
|
|
|
|
// ---- account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A) ----
|
|
public static SignupMode Signup { get; private set; }
|
|
public static bool AccountCreateEnabled { get; private set; }
|
|
public static bool RequireIpForCreate { get; private set; }
|
|
public static int AccountNameMaxLength { get; private set; }
|
|
public static int AccountPasswordMaxLength { get; private set; }
|
|
|
|
public static bool Enabled { get; private set; }
|
|
|
|
public static void Configure()
|
|
{
|
|
Load();
|
|
}
|
|
|
|
/// <summary>Re-readable at runtime via `[bridge reload`.</summary>
|
|
public static void Load()
|
|
{
|
|
Enabled = Config.Get("Bridge.Enabled", true);
|
|
|
|
Host = Config.Get("Bridge.Host", "127.0.0.1");
|
|
Port = Config.Get("Bridge.Port", 7788);
|
|
QueueCap = Config.Get("Bridge.QueueCap", 10000);
|
|
|
|
AssetsEnabled = Config.Get("Bridge.AssetsEnabled", true);
|
|
|
|
// The largest reply this plane will build, in ENCODED bytes -- not items, because the
|
|
// ceiling it has to live inside is a byte ceiling. Clamped to half the sidecar's 1 MiB
|
|
// inbound line cap, and the halving is load-bearing rather than cautious: a page
|
|
// always admits its first item even when that item alone exceeds the budget (the
|
|
// alternative is an oversized item being skipped forever and its family never making
|
|
// progress), so the wire must still have room for one such overshoot.
|
|
AssetBatchBytes = Config.Get("Bridge.AssetBatchBytes", 512 * 1024);
|
|
if (AssetBatchBytes < 64 * 1024)
|
|
AssetBatchBytes = 64 * 1024;
|
|
if (AssetBatchBytes > 512 * 1024)
|
|
AssetBatchBytes = 512 * 1024;
|
|
|
|
AssetBodyBatch = Config.Get("Bridge.AssetBodyBatch", 100);
|
|
if (AssetBodyBatch < 1)
|
|
AssetBodyBatch = 1;
|
|
if (AssetBodyBatch > 500)
|
|
AssetBodyBatch = 500;
|
|
|
|
AssetFetchKeys = Config.Get("Bridge.AssetFetchKeys", 2000);
|
|
if (AssetFetchKeys < 1)
|
|
AssetFetchKeys = 1;
|
|
if (AssetFetchKeys > 10000)
|
|
AssetFetchKeys = 10000;
|
|
|
|
AssetScanMs = Config.Get("Bridge.AssetScanMs", 3000);
|
|
if (AssetScanMs < 250)
|
|
AssetScanMs = 250;
|
|
// Half the sidecar's 10 s reply timeout, so the page still has time to be serialised
|
|
// and written after the scan stops. A budget set at the timeout would produce replies
|
|
// that are always thrown away.
|
|
if (AssetScanMs > 5000)
|
|
AssetScanMs = 5000;
|
|
|
|
// Clamped to 0-4: 5-7 are the client MIRRORING 1-3, which `Frame` decodes through a
|
|
// different pointer-arithmetic branch that nothing in BridgeAssetValidator has
|
|
// checked. Accepting one would hand an unverified write path a bitmap to fill.
|
|
AssetPlayerDirection = Clamp(Config.Get("Bridge.AssetPlayerDirection", 0), 0, 4);
|
|
AssetCreatureDirection = Clamp(Config.Get("Bridge.AssetCreatureDirection", 1), 0, 4);
|
|
|
|
// The floor is one batch: a cache that cannot hold the page being built evicts rows
|
|
// while they are still being written, which is a cache that costs and never pays. The
|
|
// ceiling is a game server's memory, and 64 MB of PNG is already ~34,000 sprites --
|
|
// most of this client's art, held for a working set that is measured in hundreds.
|
|
AssetArtCacheBytes = Config.Get("Bridge.AssetArtCacheBytes", 16 * 1024 * 1024);
|
|
if (AssetArtCacheBytes < AssetBatchBytes)
|
|
AssetArtCacheBytes = AssetBatchBytes;
|
|
if (AssetArtCacheBytes > 64 * 1024 * 1024)
|
|
AssetArtCacheBytes = 64 * 1024 * 1024;
|
|
|
|
TreeEnabled = Config.Get("Bridge.TreeEnabled", true);
|
|
|
|
// Floor and ceiling both matter. Below 64 KiB a stock tree is thousands of chunks and
|
|
// the per-row overhead starts to dominate the payload; above 512 KiB an incompressible
|
|
// chunk stops fitting inside the sidecar's inbound line cap, which is the one bound
|
|
// this number exists to respect. Kept equal to AssetBatchBytes' own ceiling so the two
|
|
// budgets cannot drift into disagreeing about the same wire.
|
|
TreeChunkBytes = Config.Get("Bridge.TreeChunkBytes", 512 * 1024);
|
|
if (TreeChunkBytes < 64 * 1024)
|
|
TreeChunkBytes = 64 * 1024;
|
|
if (TreeChunkBytes > 512 * 1024)
|
|
TreeChunkBytes = 512 * 1024;
|
|
|
|
StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30);
|
|
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
|
|
EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);
|
|
PageSweepSeconds = Config.Get("Bridge.PageSweepSeconds", 5);
|
|
if (PageSweepSeconds < 1)
|
|
PageSweepSeconds = 1;
|
|
|
|
ChampSweepSeconds = Config.Get("Bridge.ChampSweepSeconds", 10);
|
|
if (ChampSweepSeconds < 1)
|
|
ChampSweepSeconds = 1;
|
|
|
|
// Social/political sweeps (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part B). Both change slowly, so the
|
|
// defaults are unhurried; the pass is a handful of field reads over a small set.
|
|
GuildSweepSeconds = Config.Get("Bridge.GuildSweepSeconds", 60);
|
|
if (GuildSweepSeconds < 1)
|
|
GuildSweepSeconds = 1;
|
|
|
|
// A roster line is the only fat frame this plugin emits — measured at roughly 69 bytes
|
|
// per member — and the sidecar reads a line with no length bound. The cap turns an
|
|
// unbounded frame into a bounded one; a guild above it is split across continuation
|
|
// lines. 500 members is ~35 KB, comfortably past any real guild, so the split path is
|
|
// an edge case rather than the norm.
|
|
GuildRosterMembersPerLine = Config.Get("Bridge.GuildRosterMembersPerLine", 500);
|
|
if (GuildRosterMembersPerLine < 16)
|
|
GuildRosterMembersPerLine = 16;
|
|
|
|
// How many guilds may emit a roster in a single sweep. Every guild re-emits after a
|
|
// reconnect (the diff caches are cleared), and building a few hundred fat JSON frames in
|
|
// one Core-thread pass is exactly the stall this bridge exists to avoid. The sweep
|
|
// re-arms itself promptly while a baseline is still draining, so this throttles the work
|
|
// without making the site wait a full sweep interval per batch.
|
|
GuildRosterGuildsPerTick = Config.Get("Bridge.GuildRosterGuildsPerTick", 25);
|
|
if (GuildRosterGuildsPerTick < 1)
|
|
GuildRosterGuildsPerTick = 1;
|
|
|
|
CitySweepSeconds = Config.Get("Bridge.CitySweepSeconds", 300);
|
|
if (CitySweepSeconds < 1)
|
|
CitySweepSeconds = 1;
|
|
|
|
PresenceSweepSeconds = Config.Get("Bridge.PresenceSweepSeconds", 30);
|
|
if (PresenceSweepSeconds < 1)
|
|
PresenceSweepSeconds = 1;
|
|
|
|
HousingSweepSeconds = Config.Get("Bridge.HousingSweepSeconds", 300);
|
|
if (HousingSweepSeconds < 1)
|
|
HousingSweepSeconds = 1;
|
|
|
|
// Points/loyalty boards. The sweep touches every point entry on the shard, and ten of
|
|
// the ~25 systems keep a row per character ever created, so the default interval is
|
|
// deliberately slow — these are month-scale standings, not live state.
|
|
PointsSweepSeconds = Config.Get("Bridge.PointsSweepSeconds", 300);
|
|
if (PointsSweepSeconds < 1)
|
|
PointsSweepSeconds = 1;
|
|
|
|
PointsLeaderboardEnabled = Config.Get("Bridge.PointsLeaderboardEnabled", true);
|
|
|
|
// Board size. Bounded below at 1 because the selection indexes the Nth slot directly,
|
|
// and above at 100 because the frame is emitted per system — a large N multiplied by
|
|
// ~25 systems is how a "board" turns into a bandwidth problem.
|
|
PointsTopN = Config.Get("Bridge.PointsTopN", 10);
|
|
if (PointsTopN < 1)
|
|
PointsTopN = 1;
|
|
if (PointsTopN > 100)
|
|
PointsTopN = 100;
|
|
|
|
// Blank (the default) means "publish whatever the shard itself shows on the loyalty
|
|
// gump", so a shard that adds a subsystem gets its board without an edit here.
|
|
PointsSystems = Config.Get("Bridge.PointsSystems", "");
|
|
|
|
PointsProfileEnabled = Config.Get("Bridge.PointsProfileEnabled", true);
|
|
|
|
// Off by default, and the default is the point: a rank cannot early-exit the way a
|
|
// points lookup can — it must count every row that beats the player, in every system,
|
|
// 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
|
|
// purpose; Server.cfg's Address/Port are never read (see BridgeRuleset's allowlist note).
|
|
RulesetEnabled = Config.Get("Bridge.RulesetEnabled", true);
|
|
PublicConnectAddress = Config.Get("Bridge.PublicConnectAddress", "");
|
|
RulesetIncludeSchedule = Config.Get("Bridge.RulesetIncludeSchedule", true);
|
|
|
|
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
|
|
|
|
TownCrierMaxLines = Config.Get("Bridge.TownCrierMaxLines", 6);
|
|
TownCrierMaxLineLength = Config.Get("Bridge.TownCrierMaxLineLength", 200);
|
|
TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20);
|
|
TownCrierMaxDurationSec = Config.Get("Bridge.TownCrierMaxDurationSec", 86400);
|
|
|
|
NewsMaxTitleLength = Config.Get("Bridge.NewsMaxTitleLength", 100);
|
|
NewsMaxBodyLength = Config.Get("Bridge.NewsMaxBodyLength", 2000);
|
|
NewsMaxExternal = Config.Get("Bridge.NewsMaxExternal", 20);
|
|
NewsAnnounceDurationSec = Config.Get("Bridge.NewsAnnounceDurationSec", 300);
|
|
if (NewsAnnounceDurationSec < 1)
|
|
NewsAnnounceDurationSec = 1;
|
|
|
|
AdminWriteEnabled = Config.Get("Bridge.AdminWriteEnabled", false);
|
|
AdminAccessFloor = ParseAccessLevel(Config.Get("Bridge.AdminAccessFloor", "CoOwner"), AccessLevel.CoOwner);
|
|
AdminBroadcastMaxLength = Config.Get("Bridge.AdminBroadcastMaxLength", 300);
|
|
AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400);
|
|
AdminBanMaxDurationSec = Config.Get("Bridge.AdminBanMaxDurationSec", 31536000);
|
|
|
|
// The event plane. Off until an operator says otherwise - see the field block above for
|
|
// why this is not AdminWriteEnabled.
|
|
EventsEnabled = Config.Get("Bridge.EventsEnabled", false);
|
|
|
|
// Thirty days, matching core's own MAX_LEASE_MS. This is the shard's INDEPENDENT
|
|
// ceiling rather than a mirror of it: the website bounds what it will ask for, and a
|
|
// shard that trusted the asking would have no bound of its own at the one moment it
|
|
// matters, which is when the website is wrong.
|
|
LeaseMaxDurationSec = Config.Get("Bridge.LeaseMaxDurationSec", 2592000);
|
|
if (LeaseMaxDurationSec < 1)
|
|
LeaseMaxDurationSec = 1;
|
|
|
|
// How long a finished lease stays listed after its deadline restored it, so teardown
|
|
// still gets a definite verdict rather than finding nothing and having to guess.
|
|
LeaseGraceSec = Config.Get("Bridge.LeaseGraceSec", 86400);
|
|
if (LeaseGraceSec < 0)
|
|
LeaseGraceSec = 0;
|
|
|
|
ParticipationSweepSeconds = Config.Get("Bridge.ParticipationSweepSeconds", 30);
|
|
if (ParticipationSweepSeconds < 1)
|
|
ParticipationSweepSeconds = 1;
|
|
|
|
// What one kill inside the area is worth against one minute of standing in it. Both
|
|
// halves live on the shard because the score IS the shard's number: core stores an
|
|
// opaque decimal it never interprets, so a weight core could edit would be a weight
|
|
// nobody could explain from either side.
|
|
ParticipationKillWeight = Config.Get("Bridge.ParticipationKillWeight", 5.0);
|
|
if (ParticipationKillWeight < 0.0)
|
|
ParticipationKillWeight = 0.0;
|
|
|
|
ParticipationMaxRuns = Config.Get("Bridge.ParticipationMaxRuns", 8);
|
|
if (ParticipationMaxRuns < 1)
|
|
ParticipationMaxRuns = 1;
|
|
|
|
ParticipationMaxMembers = Config.Get("Bridge.ParticipationMaxMembers", 2000);
|
|
if (ParticipationMaxMembers < 1)
|
|
ParticipationMaxMembers = 1;
|
|
|
|
// A radius, not a rectangle, and bounded: an area big enough to cover a facet makes
|
|
// "took part" meaningless and the sweep expensive in the same stroke.
|
|
ParticipationMaxRadius = Config.Get("Bridge.ParticipationMaxRadius", 300);
|
|
if (ParticipationMaxRadius < 1)
|
|
ParticipationMaxRadius = 1;
|
|
|
|
ParticipationGraceSec = Config.Get("Bridge.ParticipationGraceSec", 86400);
|
|
if (ParticipationGraceSec < 0)
|
|
ParticipationGraceSec = 0;
|
|
|
|
// How many members one snapshot resolves before yielding the Core thread. See
|
|
// BridgeParticipation: this is what makes the handler DEFER, which is what makes
|
|
// `bridge.busy` reachable at all.
|
|
ParticipationSnapshotChunk = Config.Get("Bridge.ParticipationSnapshotChunk", 100);
|
|
if (ParticipationSnapshotChunk < 1)
|
|
ParticipationSnapshotChunk = 1;
|
|
|
|
// The world verbs. PEC's published quotas are the defaults, because they are the only
|
|
// numbers anyone has ever defended in public: 30 creatures, a handful of bosses, five
|
|
// NPCs of five lines each, a four-hour gate. See EVENTS.md's PEC section.
|
|
EventsMaxCreatures = Config.Get("Bridge.EventsMaxCreatures", 30);
|
|
if (EventsMaxCreatures < 1)
|
|
EventsMaxCreatures = 1;
|
|
|
|
EventsMaxBosses = Config.Get("Bridge.EventsMaxBosses", 4);
|
|
if (EventsMaxBosses < 1)
|
|
EventsMaxBosses = 1;
|
|
|
|
EventsMaxNpcs = Config.Get("Bridge.EventsMaxNpcs", 5);
|
|
if (EventsMaxNpcs < 1)
|
|
EventsMaxNpcs = 1;
|
|
|
|
EventsMaxDecor = Config.Get("Bridge.EventsMaxDecor", 60);
|
|
if (EventsMaxDecor < 1)
|
|
EventsMaxDecor = 1;
|
|
|
|
EventsMaxGateMinutes = Config.Get("Bridge.EventsMaxGateMinutes", 240);
|
|
if (EventsMaxGateMinutes < 1)
|
|
EventsMaxGateMinutes = 1;
|
|
|
|
// The whole run, across every verb. The per-verb ceilings above bound one CALL; this
|
|
// bounds a run that calls a verb in a loop, which is the shape a runaway schedule
|
|
// actually takes.
|
|
EventsMaxOwnedPerRun = Config.Get("Bridge.EventsMaxOwnedPerRun", 200);
|
|
if (EventsMaxOwnedPerRun < 1)
|
|
EventsMaxOwnedPerRun = 1;
|
|
|
|
EventsMaxSpread = Config.Get("Bridge.EventsMaxSpread", 40);
|
|
if (EventsMaxSpread < 0)
|
|
EventsMaxSpread = 0;
|
|
|
|
// "An enhanced regular mob", per EVENTS.md's boss row -- so a ceiling low enough that
|
|
// the result is still recognisably the creature the author picked.
|
|
EventsMaxBossMultiplier = Config.Get("Bridge.EventsMaxBossMultiplier", 10.0);
|
|
if (EventsMaxBossMultiplier < 1.0)
|
|
EventsMaxBossMultiplier = 1.0;
|
|
|
|
EventsOracleMaxLines = Config.Get("Bridge.EventsOracleMaxLines", 5);
|
|
if (EventsOracleMaxLines < 1)
|
|
EventsOracleMaxLines = 1;
|
|
|
|
EventsOracleGreetRange = Config.Get("Bridge.EventsOracleGreetRange", 4);
|
|
if (EventsOracleGreetRange < 1)
|
|
EventsOracleGreetRange = 1;
|
|
|
|
EventsOracleSpeechRange = Config.Get("Bridge.EventsOracleSpeechRange", 8);
|
|
if (EventsOracleSpeechRange < 1)
|
|
EventsOracleSpeechRange = 1;
|
|
|
|
EventsOracleGreetCooldownSec = Config.Get("Bridge.EventsOracleGreetCooldownSec", 60);
|
|
if (EventsOracleGreetCooldownSec < 0)
|
|
EventsOracleGreetCooldownSec = 0;
|
|
|
|
EventsOracleAnswerCooldownSec = Config.Get("Bridge.EventsOracleAnswerCooldownSec", 5);
|
|
if (EventsOracleAnswerCooldownSec < 0)
|
|
EventsOracleAnswerCooldownSec = 0;
|
|
|
|
// How often expired gates are collected and dead ownership rows pruned. Gates are a
|
|
// minutes-scale deadline, so one slow sweep beats a timer per object.
|
|
EventsSweepSeconds = Config.Get("Bridge.EventsSweepSeconds", 30);
|
|
if (EventsSweepSeconds < 1)
|
|
EventsSweepSeconds = 1;
|
|
|
|
// Phase 12b. How many characters one grant may reach, and how many of one item may go
|
|
// into one hand. Both refuse rather than clamp, on `LeaseMaxDurationSec`'s argument:
|
|
// the website records what was handed out, and a silent clamp would make its ledger a
|
|
// description of a grant that did not happen.
|
|
EventsMaxGrantPerRun = Config.Get("Bridge.EventsMaxGrantPerRun", 200);
|
|
if (EventsMaxGrantPerRun < 0)
|
|
EventsMaxGrantPerRun = 0;
|
|
|
|
EventsMaxGrantStack = Config.Get("Bridge.EventsMaxGrantStack", 1000);
|
|
if (EventsMaxGrantStack < 1)
|
|
EventsMaxGrantStack = 1;
|
|
|
|
// A save stops the world, so this one is a rate limit rather than a cap. It counts from
|
|
// the last save by ANYBODY -- ServUO's own autosave included -- because an event save
|
|
// thirty seconds after the hourly one is the same freeze twice, and this shard is the
|
|
// only half that can see both.
|
|
EventsMinSaveIntervalSec = Config.Get("Bridge.EventsMinSaveIntervalSec", 300);
|
|
if (EventsMinSaveIntervalSec < 0)
|
|
EventsMinSaveIntervalSec = 0;
|
|
|
|
// Account provisioning. An absent SignupMode defaults to Hybrid; a *present but
|
|
// unrecognized* value falls back to Game (the safest — no website creation), so a
|
|
// typo can never accidentally open provisioning.
|
|
Signup = ParseSignupMode(Config.Get("Bridge.SignupMode", "hybrid"), SignupMode.Game);
|
|
// Default follows the mode: creation is on unless the shard is game-authority.
|
|
AccountCreateEnabled = Config.Get("Bridge.AccountCreateEnabled", Signup != SignupMode.Game);
|
|
RequireIpForCreate = Config.Get("Bridge.RequireIpForCreate", true);
|
|
AccountNameMaxLength = Config.Get("Bridge.AccountNameMaxLength", 16);
|
|
AccountPasswordMaxLength = Config.Get("Bridge.AccountPasswordMaxLength", 30);
|
|
if (AccountNameMaxLength < 1)
|
|
AccountNameMaxLength = 1;
|
|
if (AccountPasswordMaxLength < 1)
|
|
AccountPasswordMaxLength = 1;
|
|
|
|
if (QueueCap < 16)
|
|
QueueCap = 16;
|
|
|
|
WarnOnSignupMismatch();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The bridge governs only the account.create verb; ServUO's in-game first-login
|
|
/// auto-create is the core Accounts.AutoCreateAccounts setting. A shard whose two halves
|
|
/// disagree is quietly broken (website-only that still auto-creates in game, or a mode
|
|
/// that expects in-game creation with it switched off), so surface the contradiction
|
|
/// loudly rather than silently doing the permissive thing.
|
|
/// </summary>
|
|
private static void WarnOnSignupMismatch()
|
|
{
|
|
var autoCreate = Config.Get("Accounts.AutoCreateAccounts", true);
|
|
|
|
if (Signup == SignupMode.Website && autoCreate)
|
|
Console.WriteLine(
|
|
"[Bridge] WARNING: SignupMode=website but Accounts.AutoCreateAccounts=true; "
|
|
+ "an in-game login of any new name still mints an account. Set it false for website-only.");
|
|
else if (Signup == SignupMode.Game && !autoCreate)
|
|
Console.WriteLine(
|
|
"[Bridge] WARNING: SignupMode=game but Accounts.AutoCreateAccounts=false; "
|
|
+ "in-game creation is off and account.create is refused, so no account can be created.");
|
|
else if (Signup == SignupMode.Hybrid && !autoCreate)
|
|
Console.WriteLine(
|
|
"[Bridge] WARNING: SignupMode=hybrid but Accounts.AutoCreateAccounts=false; "
|
|
+ "in-game first-login creation is off. Only website account.create will work.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a SignupMode name, case-insensitively, falling back to <paramref name="fallback"/>
|
|
/// on anything unrecognized so a typo can never open provisioning wider than intended.
|
|
/// </summary>
|
|
private static SignupMode ParseSignupMode(string value, SignupMode fallback)
|
|
{
|
|
SignupMode parsed;
|
|
if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) &&
|
|
Enum.IsDefined(typeof(SignupMode), parsed))
|
|
return parsed;
|
|
|
|
Console.WriteLine("[Bridge] unrecognized SignupMode '{0}', using {1}", value, fallback);
|
|
return fallback;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses an AccessLevel name from config, case-insensitively, falling back to the given
|
|
/// default on anything unrecognized so a typo can never open the floor wider than intended.
|
|
/// </summary>
|
|
private static AccessLevel ParseAccessLevel(string value, AccessLevel fallback)
|
|
{
|
|
AccessLevel parsed;
|
|
if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) &&
|
|
Enum.IsDefined(typeof(AccessLevel), parsed))
|
|
return parsed;
|
|
|
|
Console.WriteLine("[Bridge] unrecognized AdminAccessFloor '{0}', using {1}", value, fallback);
|
|
return fallback;
|
|
}
|
|
|
|
private static int Clamp(int value, int min, int max)
|
|
{
|
|
if (value < min)
|
|
return min;
|
|
|
|
return value > max ? max : value;
|
|
}
|
|
|
|
public static string Describe()
|
|
{
|
|
return String.Format(
|
|
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9}) signup={10}(create={11}) events={12}",
|
|
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
|
|
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled,
|
|
EventsEnabled);
|
|
}
|
|
}
|
|
}
|