Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
wtclaude 1be1f24562 feat(asset-bridge): item and land art on demand, hued where the files are (Phase 5)
The body catalogue is a set; this is not. This client addresses 49,152 static
ids and has art for 39,189 of them, plus 4,244 land tiles of 16,384 -- and hues
multiply the statics by three thousand. So there is no manifest and no scan:
`assets.fetch` grows two more families (`static`, `land`) and answers the keys
the website's own data names.

`assets.fetch` becomes shared plumbing. BridgeAssets now owns the command, does
the reqId/consent/key-ceiling checks once, derives the family from the keys
themselves (§5 made the key the address; a request that also named its family
would have two places to be wrong and one of them silent) and dispatches to the
reader that registered it. A batch must be of one family, because the reply
carries one `catalog` id. `assets.sources` gains `families` -- additive, so the
protocol stays 8, and EXTRACTOR_VERSION stays 2 because no existing key's bytes
change.

Two traps, both in §4.5's family -- a confident, plausible, wrong picture:

- `Art.GetStatic` memoises into a static Bitmap[0xFFFF] and hands back the SAME
  instance, while `Hue.ApplyTo` repaints in place. Hue a static once and the
  plain key comes back hued from then on, and the next hue stacks on the last.
  Measured on this client before the fix. `Files.CacheData` is now off for the
  life of the process; `TryHue` re-checks it and refuses rather than risk it,
  and the same flag decides whether a bitmap is ours to dispose.

- `PartialHue` decides whether a hue repaints every pixel or only the grey ones,
  per item id, out of `tiledata.mul` -- 13,259 of 65,536 ids on this client.
  Item 597 is a wooden screen with painted flowers: one mode reddens the
  flowers, the other the whole screen. Both decode. The first cut of this reader
  bound `TileData` to ServUO's OWN `Server.TileData` (the enclosing namespace
  beats `using Ultima;`, and it has a PartialHue flag too), which compiled and
  refused every hued key at runtime. Every such type is spelled `Ultima.` now.

Land takes no hue segment: the mode that decides how is an item flag and land
has no equivalent, so `land/3/h33` is refused rather than guessed. `h0` is not a
key either -- hue 0 means "not hued", and the plain key already names it.

Measured through the reader over the whole range: 39,189 statics and 4,244 land
tiles served, and the only refusals are the 9,963 + 12,140 empty index slots
§4.5 predicted. Nothing that carries art is refused.

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

610 lines
34 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; }
// 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;
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);
}
}
}