Five verbs an author sees -- creatures, an enhanced "boss", an oracle NPC, a temporary gate, decoration -- and ONE command family underneath them, because every one of them ends in the same sentence: an object exists, and this run owns it. `world.spawn` / `world.despawn` / `world.owned` carry a `what` discriminator, and the per-verb differences are fields rather than kinds. The ownership registry is PERSISTED, and that is forced rather than chosen. A spawned creature is in the world save, so it survives the restart that proves a town-crier line gone -- which already rules out reconcile-by-boot-stamp. But the record of which run owns which serial has nowhere else to live: in memory it is lost in the restart the creatures survive, and only in the website's ledger it is not held here at all, so `world.despawn` would delete whatever serial it was handed and "never touches a creature it did not create" would have no mechanism behind it. So the Bridge gains its second persisted file beside `Participation.bin` -- written by the same world save as the objects it describes, so the two cannot get out of step. The oracle is ours rather than `XmlSpawner2.XmlDialog`'s, and that engine is the reason for both halves of the decision. Its `SpeechEntry` is the evidence the shape is right -- `Text` plus comma-separated `Keywords`, a keyword-less entry as the greeting, a proximity range, a conversation lock. It is also why not to build on it: `SpeechEntry` carries an `Action` string, XmlSpawner's command-scripting language, which would leave an arbitrary-command field one step from an event author. `Mobile.OnMovement` (delivered to every mobile in range -- the `HandlesOnMovement` filter applies only to Items) and `Mobile.HandlesOnSpeech`/`OnSpeech` are native virtuals and are all it needs. Every `Bridge.EventsMax*` REFUSES rather than clamps, on `LeaseMaxDurationSec`'s argument from 11b: the shard's bound exists for the case where the website is wrong. `Bridge.EventsEnabled` gates all of it -- spawning is the same consent 11b introduced that switch for, not a third one. Decoration carries an `itemId`, because `Static` accounts for 5031 of the tree's decoration placements under 1992 different graphics: for that class the graphic IS the identity. Never applied to a `BaseAddon`, whose own ItemID is not what a player sees. Containers are refused outright -- teardown would delete whatever a player had left inside. `tools/scaffolding` gains `worldgone <serial>`, which deletes an object behind the registry's back. It is the one outcome the rig cannot reach by asking the bridge -- every bridge verb that removes an object also drops its row -- and it is what a player's sword does every time they kill an event creature. Verified on a real ServUO 57.4 world (206k items, 42k mobiles) against the release sidecar: all five verbs place; every ceiling refuses; a container and an unknown type refuse; one run cannot despawn another's object; the registry and its objects both survive a save and a clean restart (`pruned: 0`); a creature deleted behind the registry's back comes back `gone` rather than `removed`; and a five-second gate is collected by the shard's own deadline with `world.expired` on the wire. Refs: docs/link/v7.md, docs/website/EVENTS_PLAN.md Phase 12a Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
484 lines
26 KiB
C#
484 lines
26 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; }
|
|
|
|
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; }
|
|
|
|
// ---- 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);
|
|
|
|
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;
|
|
|
|
// 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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|