Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
wtclaude cc4f58317e feat(bridge)!: guild rosters and per-member leaves, on protocol 4
Protocol 2 could say how many members a guild had, not who they were, and there
is no EventSink for leaving a guild — so PROTOCOL_2.md §10.1 deferred the whole
membership half. This closes it.

The sweep now holds each guild's member serial **set** instead of folding it into
the signature as a sum. That buys two things. A set comparison cannot collide,
where a sum could: one member joining and another leaving between two passes
offset each other and the guild looked unchanged. And a set can be *differenced*,
which is what makes a per-member `guild.leave` possible without a core tap —
departures are simply the prior set minus the current one.

A changed set also re-emits `guild.roster`, the full member list. That is what
lets the departure events stay advisory: a consumer building a "so-and-so left"
feed wants them, but a consumer holding a membership table only needs the roster,
so nothing downstream has to replay deltas to stay correct. On a guild's first
sweep there is no prior set, so nothing is reported as leaving — an unknown
roster becoming known is not 155 people leaving at once.

A roster is the only fat frame this plugin emits — measured at roughly 69 bytes
per member against a real 155-member guild — and the sidecar reads a line with no
length bound. So members per frame are capped (default 500, about 35 KB), and a
guild over the cap is split into frames carrying `seq`, `more` and `total`. Every
realistic guild emits exactly one frame with `seq` 0 and `more` false, which is
the same shape as if chunking did not exist. Verified against the real sidecar
with the cap forced down to 50, which produced 50/50/50/5 across four frames.

The reconnect baseline is spread rather than fired in one pass. `OnConnected`
clears the diff caches, so every guild looks changed at once, and building
hundreds of fat frames in a single Core-thread tick is exactly the stall this
bridge exists to avoid. At most GuildRosterGuildsPerTick guilds emit a roster per
sweep; a guild over budget keeps its old member set, so it still reads as changed
next pass. The sweep re-arms itself after 2s while a baseline is draining, so
catch-up takes seconds rather than one full sweep interval per batch.

BridgeJson gained the array writer it never had — there was no way to express a
list of objects at all. Every field helper emits a leading `,"name":`, so Actor
is split into a bare-object writer that both the single and array forms use.

overlay.toml protocol -> 4, in this commit rather than a later one: CI folds it
into the release manifest and the installer refuses to pair an overlay and a
sidecar that disagree, so a bump landing separately from the emitters would
silently fail to compose into a bundle.

Verified on a live ServUO shard against the real Rust sidecar (not a stub): 155
members seeded from real PlayerMobiles, four roster frames reassembled to 153
entries on the board after two members were removed, two guild.leave frames with
the correct serials, and the departed serials absent from the re-emitted roster.

Refs: docs/website/TEAMS.md Part 12 Phase 1

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 12:52:07 -05:00

322 lines
18 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; }
// ---- 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);
// 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);
}
}
}