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
610 lines
34 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|