Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
Claude 5e0b42b948 feat(protocol2): Town Cryer news-gump integration (§16, Protocol 2.1)
Website news articles now land in the modern Town Cryer News gump
(TownCryerSystem.NewsEntries), separate from the scrolling-crier lines.

Overlay BridgeNews (new): news.add / news.remove insert/remove a
TownCryerNewsEntry directly in the public NewsEntries list (no stock edit),
tracking our own id->entry map so stock uo.com news is left intact. Title,
HTML body, image, and URL are all supported (the stock gumps already branch on
TextDefinition.Number, so string content renders). On add the article title is
also proclaimed via GlobalTownCrierEntryList (announce defaults on; set
announce:false to suppress). Config caps: NewsMaxTitleLength/BodyLength/
External, NewsAnnounceDurationSec.

Sidecar: POST /news (add/replace, id-correlated), DELETE /news/{id}; news table
stores each article as its news.add command; on shard server.hello the sidecar
replays the stored set with announce:false (the shard rebuilds NewsEntries each
boot and does not persist ours, so the website is the source of truth).

Docs: PROTOCOL_2 §16 (design + verified), INTEGRATION.md /news endpoints.

Verified live: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors); booted shard + sidecar and exercised add/replace/
remove/error paths and the reconnect replay end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:27:13 -05:00

214 lines
10 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 (docs/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 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 (docs/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 (docs/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 (docs/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;
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);
}
}
}