Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
wtclaude 3fb4b7dc9f feat(bridge): emit world.ruleset, the shard's published ruleset
Protocol 3.0 §5 (docs/link/v3.md). One frame describing how this shard is
actually configured — expansion, which optional systems are on, skill/stat
caps, account and house limits, champion scroll rules, the save/restart
schedule — so the website's rules page cannot drift from the server.

Modelled on BridgeBoot.EmitHello, not on the diff sweeps: the ruleset changes
only when an operator edits a .cfg, so there is nothing to poll. It subscribes
Connected_Core, so a sidecar that comes up second still learns the ruleset,
and `[bridge reload` re-emits for an operator who just edited a file.

The frame is built from an EXPLICIT ALLOWLIST of Config.Get calls. Config.Entries
is never enumerated — that would sweep in every key on the server, secrets
included — and Server.cfg, Staff.cfg, Email.cfg, DataPath.cfg, Bridge.cfg,
Compiler.cfg, Reports.cfg and Client.cfg are named as excluded both here and in
a code comment. The one connection detail published is Bridge.PublicConnectAddress,
blank by default, which an operator sets deliberately for this purpose.

`rev` is FNV-1a over the body so an unchanged reconnect is a site-side no-op.
String.GetHashCode() is deliberately not used: it is seeded per process, so it
would change on every restart and defeat the diff.

Verified by compiling the full ServUO Scripts tree (6,205 files, net48, EJ) with
this overlay substituted for the deployed Bridge copy — clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 11:14:36 -05:00

227 lines
12 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; }
// ---- 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; }
// ---- 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;
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;
// 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);
// 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})",
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled);
}
}
}