From 5816c29c6789e0dff1e1659f8584261d5cd8d59c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 07:42:06 -0500 Subject: [PATCH 1/6] feat(protocol2): website account provisioning & unlinking (Part A) Adds the account-provisioning plane from docs/PROTOCOL_2.md Part A: the website can create game accounts and unlink them, gated by a shard-wide signup mode. The existing [link flow is unchanged. Overlay: - BridgeConfig: SignupMode (website|game|hybrid, default hybrid; unrecognized falls back to game), AccountCreateEnabled (mode-following default), RequireIpForCreate, name/password caps, and a boot warning when the core Accounts.AutoCreateAccounts setting contradicts the mode. - BridgeAccounts (new): account.create (mode gate, actor required, char-safety mirrored from AccountHandler, collision check, per-IP cap via CanCreate/ LogAccess with fail-closed missing/loopback IP, create + WebsiteUserId link, account.audit; password never logged or echoed) and account.unlink (Owner floor via BridgeAdmin.Protected, clears the tag). - BridgeAccountLink: in-game [unlink command, emits account.unlinked. - BridgeAdmin: Protected / ResolveTargetAccount promoted to public for reuse. Sidecar: - POST /accounts/create, DELETE /link/:account, respond_account status mapping (409 collision / 429 ip cap / 403 disabled|protected / 404 not-linked / 400). - store.record_unlink drops the mirrored link row. - PROTOCOL_VERSION -> 2 (outbound events additive; new endpoints need v2). Docs: INTEGRATION.md protocol bump, account.* events, endpoints, 409/429; PROTOCOL_2.md Part A marked built. Verified: sidecar cargo check clean; overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live end-to-end run still pending. Co-Authored-By: Claude Opus 4.8 --- overlay/Config/Bridge.cfg | 25 ++ .../Custom/Bridge/BridgeAccountLink.cs | 48 +++ .../Scripts/Custom/Bridge/BridgeAccounts.cs | 281 ++++++++++++++++++ overlay/Scripts/Custom/Bridge/BridgeAdmin.cs | 10 +- overlay/Scripts/Custom/Bridge/BridgeConfig.cs | 79 ++++- 5 files changed, 437 insertions(+), 6 deletions(-) create mode 100644 overlay/Scripts/Custom/Bridge/BridgeAccounts.cs diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg index c1beefe..7cff2d6 100644 --- a/overlay/Config/Bridge.cfg +++ b/overlay/Config/Bridge.cfg @@ -57,6 +57,31 @@ AdminReasonMaxLength=400 # Clamp on a timed ban's duration, seconds. A ban with no/zero duration is indefinite. AdminBanMaxDurationSec=31536000 +# Account provisioning (docs/PROTOCOL_2.md Part A). Which side may mint game accounts: +# website — the website is the authority; pair with Accounts.AutoCreateAccounts=false +# (else an in-game login of any new name still mints an account). +# game — the game server is the authority; website account.create is refused. +# hybrid — either side may create (the default). +# The bridge governs only the account.create verb; the in-game first-login auto-create is +# the core Accounts.AutoCreateAccounts setting, which you pair with the mode above. On boot +# the bridge warns if the two contradict. An unrecognized value here falls back to 'game' +# (the safest — no website creation). +SignupMode=hybrid + +# Master switch for the account.create verb. Absent, it follows the mode (on unless +# SignupMode=game). Set explicitly to force it on or off regardless of mode. +AccountCreateEnabled=true + +# Fail closed if account.create omits a usable browser IP. The per-IP cap +# (Accounts.AccountsPerIp) only means something if a missing/loopback IP is refused rather +# than waved through. Turn off only for a deployment that deliberately does not cap website +# signups by IP (MaxAccountsPerIP still applies in-game either way). +RequireIpForCreate=true + +# Length caps on a website-supplied username / password, checked before the account is made. +AccountNameMaxLength=16 +AccountPasswordMaxLength=30 + # The test scaffolding in tools/scaffolding/ reads its own flags from this file # (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose: # Config.Get returns the default of false when a key is missing, so a deployed diff --git a/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs b/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs index d9a34e2..5e0b1ef 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs @@ -52,6 +52,7 @@ namespace Server.Custom.Bridge return; CommandSystem.Register("link", AccessLevel.Player, OnLinkCommand); + CommandSystem.Register("unlink", AccessLevel.Player, OnUnlinkCommand); BridgeBoot.RegisterHandler("link.confirm", OnLinkConfirm); // Purge expired codes so an unconfirmed spam of [link cannot grow the table forever. @@ -127,6 +128,53 @@ namespace Server.Custom.Bridge url, (int)CodeTtl.TotalMinutes); } + // ---- [unlink ---- + + [Usage("unlink")] + [Description("Unlinks this game account from your website account.")] + private static void OnUnlinkCommand(CommandEventArgs e) + { + Unlink(e.Mobile); + } + + /// + /// Clears the WebsiteUserId tie from the caller's own account and tells the sidecar, so + /// the website can reconcile a player-initiated unlink. Player-scoped (own account only), + /// so it needs no access floor. After unlinking, [link works again. + /// + public static void Unlink(Mobile m) + { + if (m == null) + return; + + var acct = m.Account as Account; + + if (acct == null) + { + m.SendMessage("Bridge: no account on this character."); + return; + } + + var existing = acct.GetTag(Tag); + if (existing == null) + { + m.SendMessage("Your account is not linked to a website account."); + return; + } + + acct.RemoveTag(Tag); + DropCodesFor(acct.Username); // drop any pending codes so nothing dangles + + BridgeLink.Emit(BridgeJson.Begin("account.unlinked") + .Str("origin", "in-game") + .Str("account", acct.Username) + .Str("websiteUserId", existing) + .Str("char", m.Name) + .End()); + + m.SendMessage(0x40, "Your account is no longer linked to website user {0}.", existing); + } + // ---- inbound link.confirm ---- private static void OnLinkConfirm(Dictionary o) diff --git a/overlay/Scripts/Custom/Bridge/BridgeAccounts.cs b/overlay/Scripts/Custom/Bridge/BridgeAccounts.cs new file mode 100644 index 0000000..e076eab --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeAccounts.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using System.Net; + +using Server.Accounting; +using Server.Misc; + +namespace Server.Custom.Bridge +{ + /// + /// The account provisioning plane (docs/PROTOCOL_2.md Part A): website-driven account + /// creation and unlinking. Companion to BridgeAccountLink (the in-game [link flow), which + /// is unchanged. + /// + /// account.create — mint a game account and link it to a website user in one step. + /// account.unlink — sever the WebsiteUserId tie from the website side. + /// + /// Both handlers run on the Core thread (BridgeBoot marshals inbound lines through + /// Timer.DelayCall first), so they touch accounts freely. + /// + /// Trust model matches the admin plane (docs/ADMIN_CONTROLS.md §5): authorization lives on + /// the website; the shard trusts the loopback + token socket and a required "actor" field. + /// The one shard-side floor on unlink is BridgeAdmin.Protected — a protected staff account is + /// never unlinkable from the web. The whole create plane is opt-in via SignupMode / + /// AccountCreateEnabled. + /// + public static class BridgeAccounts + { + private const string Tag = "WebsiteUserId"; + + // Mirrors AccountHandler.m_ForbiddenChars so a website-created name behaves exactly like an + // in-game one (AccountHandler.cs). Kept local because that array is private. + private static readonly char[] ForbiddenChars = + { + '<', '>', ':', '"', '/', '\\', '|', '?', '*', ' ' + }; + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + BridgeBoot.RegisterHandler("account.create", OnCreate); + BridgeBoot.RegisterHandler("account.unlink", OnUnlink); + } + + // ---- account.create ---- + + /// + /// Creates a game account and links it to the given website user. Refused unless the + /// signup mode allows website creation. Enforces the same username/password character + /// safety and per-IP cap as ServUO's in-game create path; the password never leaves the + /// process in any reply, audit, or log. + /// + private static void OnCreate(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + var actor = BridgeJson.GetString(o, "actor"); + const string action = "create"; + + if (!BridgeConfig.AccountCreateEnabled || BridgeConfig.Signup == SignupMode.Game) + { + Err(reqId, action, "signups disabled for this mode"); + return; + } + + if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0) + { + Err(reqId, action, "missing actor"); + return; + } + + var account = BridgeJson.GetString(o, "account"); + var password = BridgeJson.GetString(o, "password"); + var webId = BridgeJson.GetString(o, "websiteUserId"); + var ipStr = BridgeJson.GetString(o, "ip"); + + if (String.IsNullOrEmpty(account)) + { + Err(reqId, action, "missing account"); + return; + } + + if (String.IsNullOrEmpty(password)) + { + Err(reqId, action, "missing password"); + return; + } + + if (String.IsNullOrEmpty(webId)) + { + Err(reqId, action, "missing websiteUserId"); + return; + } + + if (account.Length > BridgeConfig.AccountNameMaxLength || + password.Length > BridgeConfig.AccountPasswordMaxLength) + { + Err(reqId, action, "username or password too long"); + return; + } + + if (!IsSafeUsername(account) || !IsSafePassword(password)) + { + Err(reqId, action, "invalid username/password"); + return; + } + + // Collision: the only correct resolution of a website/in-game race for a name. + if (Accounts.GetAccount(account) != null) + { + Err(reqId, action, "account already exists"); + return; + } + + // Per-IP cap. Fail closed on a missing/loopback IP when RequireIpForCreate — loopback is + // exempt in IPLimiter, so accepting it would silently bypass the cap. + IPAddress ip; + bool haveIp = TryParseIp(ipStr, out ip); + + if (BridgeConfig.RequireIpForCreate && (!haveIp || IPAddress.IsLoopback(ip))) + { + Err(reqId, action, "client ip required"); + return; + } + + if (haveIp && !AccountHandler.CanCreate(ip)) + { + Err(reqId, action, "ip account limit reached"); + return; + } + + // Create + link. new Account self-registers (Accounts.Add) and hashes the password per + // the shard's ProtectPasswords; LogAccess records the IP and bumps IPTable exactly as an + // in-game first-login does; the tag persists on the next world save. + var acct = new Account(account, password); + + if (haveIp) + acct.LogAccess(ip); + + acct.SetTag(Tag, webId); + + Console.WriteLine("[Bridge][account] web:{0} create {1} websiteUserId={2} ip={3}", + actor, account, webId, haveIp ? ip.ToString() : "-"); + + BridgeLink.Emit(AuditBegin(action, actor, account) + .Str("websiteUserId", webId) + .End()); + + var sb = BridgeJson.Begin("account.ok"); + if (reqId != null) sb.Str("reqId", reqId); + sb.Str("action", action).Str("account", account).Str("websiteUserId", webId); + BridgeLink.Emit(sb.End()); + } + + // ---- account.unlink ---- + + /// + /// Removes the WebsiteUserId tie from an account. Symmetric with the in-game [unlink; the + /// Owner floor keeps a protected staff account unreachable from the web. + /// + private static void OnUnlink(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + var actor = BridgeJson.GetString(o, "actor"); + const string action = "unlink"; + + if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0) + { + Err(reqId, action, "missing actor"); + return; + } + + var acct = BridgeAdmin.ResolveTargetAccount(o); + if (acct == null) + { + Err(reqId, action, "unknown or accountless target"); + return; + } + + if (BridgeAdmin.Protected(acct)) + { + Err(reqId, action, "target is protected staff; refused"); + return; + } + + var existing = acct.GetTag(Tag); + if (existing == null) + { + Err(reqId, action, "not linked"); + return; + } + + acct.RemoveTag(Tag); + + Console.WriteLine("[Bridge][account] web:{0} unlink {1} (was websiteUserId={2})", + actor, acct.Username, existing); + + BridgeLink.Emit(AuditBegin(action, actor, acct.Username) + .Str("websiteUserId", existing) + .End()); + + var sb = BridgeJson.Begin("account.ok"); + if (reqId != null) sb.Str("reqId", reqId); + sb.Str("action", action).Str("account", acct.Username); + BridgeLink.Emit(sb.End()); + } + + // ---- helpers ---- + + private static void Err(string reqId, string action, string reason) + { + var sb = BridgeJson.Begin("account.error"); + if (reqId != null) sb.Str("reqId", reqId); + if (action != null) sb.Str("action", action); + sb.Str("reason", reason); + BridgeLink.Emit(sb.End()); + } + + /// + /// Opens an account.audit frame (origin=web) broadcast to every dashboard, parallel to + /// admin.audit. Never carries the password. + /// + private static System.Text.StringBuilder AuditBegin(string action, string actor, string target) + { + return BridgeJson.Begin("account.audit") + .Str("origin", "web") + .Str("action", action) + .Str("actor", "web:" + actor) + .Str("target", target); + } + + /// Mirrors the username safety rules in AccountHandler.CreateAccount. + private static bool IsSafeUsername(string un) + { + if (un.StartsWith(" ") || un.EndsWith(" ") || un.EndsWith(".")) + return false; + + for (int i = 0; i < un.Length; i++) + { + char c = un[i]; + if (c < 0x20 || c >= 0x7F || IsForbidden(c)) + return false; + } + + return true; + } + + /// Mirrors the password safety rules in AccountHandler.CreateAccount. + private static bool IsSafePassword(string pw) + { + for (int i = 0; i < pw.Length; i++) + { + char c = pw[i]; + if (c < 0x20 || c >= 0x7F) + return false; + } + + return true; + } + + private static bool IsForbidden(char c) + { + for (int i = 0; i < ForbiddenChars.Length; i++) + if (c == ForbiddenChars[i]) + return true; + + return false; + } + + private static bool TryParseIp(string s, out IPAddress ip) + { + ip = null; + + if (String.IsNullOrEmpty(s)) + return false; + + return IPAddress.TryParse(s.Trim(), out ip); + } + } +} diff --git a/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs b/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs index 1171713..b82d893 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs @@ -283,9 +283,10 @@ namespace Server.Custom.Bridge /// /// Resolves the command's target account, by "serial" (a player mobile's account) or by - /// "account" (username). Returns null if neither resolves to a real account. + /// "account" (username). Returns null if neither resolves to a real account. Public so the + /// account plane (unlink) resolves targets the same way the moderation plane does. /// - private static Account ResolveTargetAccount(Dictionary o) + public static Account ResolveTargetAccount(Dictionary o) { var serialStr = BridgeJson.GetString(o, "serial"); if (serialStr != null) @@ -301,9 +302,10 @@ namespace Server.Custom.Bridge /// /// The one shard-side safety floor. Protects any account whose effective access level — /// the account's own or the highest of its characters' — is at or above the configured - /// floor. Even under CoOwner authority the Owner is never reachable from the web. + /// floor. Even under CoOwner authority the Owner is never reachable from the web. Public + /// so the account plane (unlink) enforces the identical floor. /// - private static bool Protected(Account acct) + public static bool Protected(Account acct) { var lvl = acct.AccessLevel; diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs index 627e5e4..5ab17f2 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs @@ -2,6 +2,18 @@ using System; namespace Server.Custom.Bridge { + /// + /// 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). + /// + 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 + } + /// /// Tunables from Config/Bridge.cfg. Key scope is the filename, so `Port=7788` there /// reads as "Bridge.Port" here. @@ -33,6 +45,13 @@ namespace Server.Custom.Bridge 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() @@ -73,8 +92,64 @@ namespace Server.Custom.Bridge 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(); + } + + /// + /// 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. + /// + 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."); + } + + /// + /// Parses a SignupMode name, case-insensitively, falling back to + /// on anything unrecognized so a typo can never open provisioning wider than intended. + /// + 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; } /// @@ -95,9 +170,9 @@ namespace Server.Custom.Bridge 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})", + "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); + ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled); } } } From dd39d524c62bdbda485b73d3ad4f56b5ca1b93c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 07:54:11 -0500 Subject: [PATCH 2/6] feat(protocol2): guild and town-governor world-state streams (Part B ph.1) Adds the first Part B streams from docs/PROTOCOL_2.md: guild rosters and town governors ("mayors"), both outbound diff-board sweeps mirroring the existing champ board. Overlay: - BridgeSocial (new): guild sweep+diff over BaseGuild.List -> guild.update / guild.remove (full-state upsert; disband detected via Disbanded), plus a real-time guild.join from EventSink.JoinGuild. (EventSink.CreateGuild is only the load-time factory, so creation is derived sidecar-side from a first-seen id, as champs do.) - BridgeGovernance (new): city sweep over CityLoyaltySystem.Cities -> city.update (governor / governor-elect / election phase), gated on CityLoyaltySystem.Enabled. - BridgeJson.Actor: shared serial/name/acct/webId/player writer used by both. - BridgeConfig: GuildSweepSeconds (60s), CitySweepSeconds (300s). - BridgeBoot: both wired into [bridge reload|sweepnow|status. Sidecar: - store: guilds + governors board tables with upsert/delete/all. - main: route guild.update/remove and city.update into the boards. - web: GET /guilds, GET /governors served from the store (snapshot-companion rule, so a fresh page or a restarted sidecar hydrates without the shard). Docs: INTEGRATION.md event catalog (guild.*, city.update) + board endpoints; PROTOCOL_2.md Part B phase 1 marked built. Verified: sidecar cargo check clean; overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live end-to-end run still pending. Co-Authored-By: Claude Opus 4.8 --- overlay/Config/Bridge.cfg | 10 + overlay/Scripts/Custom/Bridge/BridgeBoot.cs | 8 + overlay/Scripts/Custom/Bridge/BridgeConfig.cs | 12 + .../Scripts/Custom/Bridge/BridgeGovernance.cs | 175 ++++++++++++++ overlay/Scripts/Custom/Bridge/BridgeJson.cs | 40 ++++ overlay/Scripts/Custom/Bridge/BridgeSocial.cs | 218 ++++++++++++++++++ 6 files changed, 463 insertions(+) create mode 100644 overlay/Scripts/Custom/Bridge/BridgeGovernance.cs create mode 100644 overlay/Scripts/Custom/Bridge/BridgeSocial.cs diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg index 7cff2d6..6cb896e 100644 --- a/overlay/Config/Bridge.cfg +++ b/overlay/Config/Bridge.cfg @@ -29,6 +29,16 @@ ChampSweepSeconds=10 # support queue; the full open queue is also available on demand via pages.snapshot. PageSweepSeconds=5 +# Guild roster poll (docs/PROTOCOL_2.md Part B). Guilds expose only EventSink.JoinGuild, so +# create/disband/leave/leader/alliance changes are found by diffing BaseGuild.List on this +# interval (emit guild.update / guild.remove). Guild membership moves slowly; 60s is ample. +GuildSweepSeconds=60 + +# Town-governor poll. Each city's Governor / election is diffed on this interval to emit +# city.update on change. Governors turn over on the order of weeks, so a slow sweep is fine. +# Idle (emits nothing) unless the City Loyalty system is enabled (CityLoyalty.Enabled). +CitySweepSeconds=300 + # Shown to a player when they run [link. The website page where they enter the code. LinkUrl=https://yoursite/link diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs index 80e6082..6629494 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs @@ -161,6 +161,8 @@ namespace Server.Custom.Bridge BridgeSweeps.Rearm(); BridgePages.Rearm(); BridgeChamps.Rearm(); + BridgeSocial.Rearm(); + BridgeGovernance.Rearm(); e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe()); e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect."); break; @@ -173,9 +175,13 @@ namespace Server.Custom.Bridge case "sweepnow": BridgeSweeps.SweepOnce(); BridgeChamps.SweepOnce(); + BridgeSocial.SweepOnce(); + BridgeGovernance.SweepOnce(); e.Mobile.SendMessage("Bridge: ran one sweep of each stream."); e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status()); + e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status()); + e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status()); break; default: @@ -186,6 +192,8 @@ namespace Server.Custom.Bridge BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors); e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status()); + e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status()); + e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status()); break; } diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs index 5ab17f2..68ee2c3 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs @@ -31,6 +31,8 @@ namespace Server.Custom.Bridge 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 string LinkUrl { get; private set; } @@ -79,6 +81,16 @@ namespace Server.Custom.Bridge 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; + LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link"); TownCrierMaxLines = Config.Get("Bridge.TownCrierMaxLines", 6); diff --git a/overlay/Scripts/Custom/Bridge/BridgeGovernance.cs b/overlay/Scripts/Custom/Bridge/BridgeGovernance.cs new file mode 100644 index 0000000..c2b33b6 --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeGovernance.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; + +using Server.Engines.CityLoyalty; + +namespace Server.Custom.Bridge +{ + /// + /// The town-governor stream (docs/PROTOCOL_2.md §10.2). In modern ServUO the "mayor of a + /// town" is the Governor in the City Loyalty System (King Blackthorn's governance): each of + /// the governed cities has a Governor, a GovernorElect, and an Election. None of these raises + /// an EventSink, so — like and — the set + /// is polled and each city emits `city.update` only when its signature changes. Governors turn + /// over on the order of weeks, so a slow sweep (default 5 min) is ample. + /// + /// The wire model is uniform with the rest of Part B: a full-state `city.update` upsert, with + /// "the governor changed" derived sidecar-side by comparing to the stored board — rather than a + /// discrete from→to event, which a sidecar reconnect (cache cleared, full re-emit) would + /// otherwise fire spuriously for every city. + /// + /// Gated on CityLoyaltySystem.Enabled: a shard running its own town system emits nothing here. + /// + public static class BridgeGovernance + { + private static Timer _timer; + + // City enum value -> last-emitted signature. + private static readonly Dictionary _last = new Dictionary(); + + private static long _sweeps, _emitted; + private static bool _warnedDisabled; + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + EventSink.ServerStarted += OnServerStarted; + } + + private static void OnServerStarted() + { + BridgeLink.Connected_Core += OnConnected; + Rearm(); + } + + private static void OnConnected() + { + _last.Clear(); + } + + /// Stops and recreates the timer from current config. Called by `[bridge reload`. + public static void Rearm() + { + Stop(); + + _timer = Timer.DelayCall( + TimeSpan.FromSeconds(BridgeConfig.CitySweepSeconds), + TimeSpan.FromSeconds(BridgeConfig.CitySweepSeconds), + CitySweep); + } + + public static void Stop() + { + if (_timer != null) { _timer.Stop(); _timer = null; } + } + + public static string Status() + { + return String.Format("cities(enabled={0} sweeps={1} emitted={2} tracked={3})", + CityLoyaltySystem.Enabled, _sweeps, _emitted, _last.Count); + } + + /// Runs one sweep now. Wired into `[bridge sweepnow`. + public static void SweepOnce() + { + CitySweep(); + } + + private static void CitySweep() + { + try + { + _sweeps++; + + if (!CityLoyaltySystem.Enabled || CityLoyaltySystem.Cities == null) + { + if (!_warnedDisabled) + { + Console.WriteLine("[Bridge] city loyalty disabled; governor stream idle."); + _warnedDisabled = true; + } + return; + } + + if (!BridgeLink.Connected) + return; // nothing is listening; do not fill the queue with perishable snapshots + + foreach (var city in CityLoyaltySystem.Cities) + { + if (city == null) + continue; + + var sig = Signature(city); + + int key = (int)city.City; + + string prior; + if (_last.TryGetValue(key, out prior) && prior == sig) + continue; // unchanged since last emit + + _last[key] = sig; + BridgeLink.Emit(WriteCity(city)); + _emitted++; + } + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] city sweep threw: {0}", ex.Message); + } + } + + // The volatile fields: governor, governor-elect, and the election phase / candidate count. + private static string Signature(CityLoyaltySystem city) + { + var gov = city.Governor == null ? 0 : city.Governor.Serial.Value; + var elect = city.GovernorElect == null ? 0 : city.GovernorElect.Serial.Value; + + var e = city.Election; + var phase = ElectionPhase(e); + var candidates = (e == null || e.Candidates == null) ? 0 : e.Candidates.Count; + + return String.Concat( + gov.ToString(), "|", elect.ToString(), "|", phase, "|", candidates.ToString()); + } + + private static string WriteCity(CityLoyaltySystem city) + { + var e = city.Election; + var phase = ElectionPhase(e); + var candidates = (e == null || e.Candidates == null) ? 0 : e.Candidates.Count; + + var sb = BridgeJson.Begin("city.update") + .Str("city", city.City.ToString()) + .Str("electionPhase", phase) + .Num("candidates", candidates); + + sb.Actor("governor", city.Governor); + sb.Actor("governorElect", city.GovernorElect); + + if (e != null && e.Ongoing) + sb.Str("autoPickAt", e.AutoPickGovernor.ToUniversalTime().ToString("o")); + + return sb.End(); + } + + /// Folds the election state into one of: none / nominate / vote / pending. + private static string ElectionPhase(CityElection e) + { + if (e == null) + return "none"; + + if (e.CanNominate()) + return "nominate"; + + if (e.CanVote()) + return "vote"; + + if (e.Ongoing) + return "pending"; + + return "none"; + } + } +} diff --git a/overlay/Scripts/Custom/Bridge/BridgeJson.cs b/overlay/Scripts/Custom/Bridge/BridgeJson.cs index 21a4d27..1b19135 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeJson.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeJson.cs @@ -76,6 +76,46 @@ namespace Server.Custom.Bridge return sb; } + /// + /// Writes a nested actor object: serial, name, account (when there is one), the linked + /// webId (when the account is linked), and the player flag. A `null` mobile writes null. + /// The richer counterpart to BridgeEvents' internal writer, used by the Part B streams so a + /// guild leader / joiner / governor can be attributed to a site user without a lookup. + /// + public static StringBuilder Actor(this StringBuilder sb, string name, Mobile m) + { + sb.Append(",\"").Append(name).Append("\":"); + + if (m == null) + { + sb.Append("null"); + return sb; + } + + sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"'); + + sb.Append(",\"name\":"); + Escape(sb, m.Name ?? ""); + + var acct = m.Account as Accounting.Account; + if (acct != null) + { + sb.Append(",\"acct\":"); + Escape(sb, acct.Username); + + var webId = BridgeAccountLink.WebIdFor(acct); + if (webId != null) + { + sb.Append(",\"webId\":"); + Escape(sb, webId); + } + } + + sb.Append(",\"player\":").Append(m.Player ? "true" : "false"); + sb.Append('}'); + return sb; + } + /// Closes the object. The trailing newline is the frame delimiter. public static string End(this StringBuilder sb) { diff --git a/overlay/Scripts/Custom/Bridge/BridgeSocial.cs b/overlay/Scripts/Custom/Bridge/BridgeSocial.cs new file mode 100644 index 0000000..2bd1b1b --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeSocial.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using Server.Guilds; + +namespace Server.Custom.Bridge +{ + /// + /// The guild stream (docs/PROTOCOL_2.md §10.1). Guilds have almost no useful EventSink: + /// EventSink.CreateGuild is only the load-time deserialization factory (Server/World.cs), and + /// leave/disband/leader/alliance changes raise nothing. Only EventSink.JoinGuild is real. So, + /// exactly like , the roster is polled: enumerate BaseGuild.List each + /// tick, fold each guild to a small signature, and emit `guild.update` only when it changes. + /// A guild that vanishes (or disbands — Disbanded == leader gone) leaves via `guild.remove`. + /// + /// On top of the board we emit a real-time `guild.join` from EventSink.JoinGuild, so a "so-and- + /// so joined" feed does not wait for the next sweep. A membership change also moves the board + /// signature (member count + serial sum), so a *leave* surfaces as the member count dropping in + /// the next `guild.update`; per-member leave events would need a core tap and are a later + /// refinement (§10.1). + /// + /// "Created" is derived sidecar-side from a first-seen id (as champs derive it), rather than a + /// wire event — otherwise a sidecar reconnect, which clears the diff cache and re-emits every + /// guild, would look like every guild being created at once. + /// + public static class BridgeSocial + { + private static Timer _timer; + + // guild id -> last-emitted signature. An id absent here has never been emitted (or the cache + // was cleared on reconnect), so its next sweep counts as a change. + private static readonly Dictionary _last = new Dictionary(); + + private static long _sweeps, _emitted, _removed, _joins; + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + EventSink.JoinGuild += OnJoinGuild; + EventSink.ServerStarted += OnServerStarted; + } + + private static void OnServerStarted() + { + BridgeLink.Connected_Core += OnConnected; + Rearm(); + } + + private static void OnConnected() + { + _last.Clear(); + } + + /// Stops and recreates the timer from current config. Called by `[bridge reload`. + public static void Rearm() + { + Stop(); + + _timer = Timer.DelayCall( + TimeSpan.FromSeconds(BridgeConfig.GuildSweepSeconds), + TimeSpan.FromSeconds(BridgeConfig.GuildSweepSeconds), + GuildSweep); + } + + public static void Stop() + { + if (_timer != null) { _timer.Stop(); _timer = null; } + } + + public static string Status() + { + return String.Format("guilds(sweeps={0} emitted={1} removed={2} joins={3} tracked={4})", + _sweeps, _emitted, _removed, _joins, _last.Count); + } + + /// Runs one sweep now. Wired into `[bridge sweepnow`. + public static void SweepOnce() + { + GuildSweep(); + } + + private static void GuildSweep() + { + try + { + _sweeps++; + + if (!BridgeLink.Connected) + return; // nothing is listening; do not fill the queue with perishable snapshots + + var seen = new HashSet(); + + foreach (var bg in BaseGuild.List.Values) + { + var g = bg as Guild; + + // Skip disbanded guilds (leader gone): they linger in the list until cleaned up, + // and treating them as absent lets the "gone" pass below emit guild.remove. + if (g == null || g.Disbanded) + continue; + + seen.Add(g.Id); + + var sig = Signature(g); + + string prior; + if (_last.TryGetValue(g.Id, out prior) && prior == sig) + continue; // unchanged since last emit + + _last[g.Id] = sig; + BridgeLink.Emit(WriteGuild(g)); + _emitted++; + } + + // Anything tracked last sweep but not seen now has disbanded or been removed. + var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList(); + foreach (var id in gone) + { + _last.Remove(id); + BridgeLink.Emit(BridgeJson.Begin("guild.remove").Num("id", id).End()); + _removed++; + } + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] guild sweep threw: {0}", ex.Message); + } + } + + // The volatile fields that define a meaningful change: name, abbreviation, leader, member + // count, the member set (order-independent serial sum), and alliance. + private static string Signature(Guild g) + { + long memberSum = 0; + int count = 0; + + var members = g.Members; + if (members != null) + { + for (int i = 0; i < members.Count; i++) + { + var m = members[i]; + if (m == null) + continue; + count++; + unchecked { memberSum += (uint)m.Serial.Value; } + } + } + + var leaderSerial = g.Leader == null ? 0 : g.Leader.Serial.Value; + + return String.Concat( + g.Name ?? "", "|", + g.Abbreviation ?? "", "|", + leaderSerial.ToString(), "|", + count.ToString(), "|", + memberSum.ToString(), "|", + g.Alliance == null ? "" : (g.AllianceName ?? "")); + } + + private static string WriteGuild(Guild g) + { + int online = 0, count = 0; + var members = g.Members; + if (members != null) + { + for (int i = 0; i < members.Count; i++) + { + var m = members[i]; + if (m == null) + continue; + count++; + if (m.NetState != null) + online++; + } + } + + var sb = BridgeJson.Begin("guild.update") + .Num("id", g.Id) + .Str("name", g.Name) + .Str("abbr", g.Abbreviation) + .Num("members", count) + .Num("online", online) + .Str("alliance", g.Alliance == null ? null : g.AllianceName); + + sb.Actor("leader", g.Leader); + + return sb.End(); + } + + // ---- real-time join ---- + + private static void OnJoinGuild(JoinGuildEventArgs e) + { + try + { + if (e == null || e.Mobile == null) + return; + + var g = e.Guild as Guild; + + var sb = BridgeJson.Begin("guild.join"); + if (g != null) + sb.Num("id", g.Id).Str("name", g.Name).Str("abbr", g.Abbreviation); + sb.Actor("who", e.Mobile); + BridgeLink.Emit(sb.End()); + _joins++; + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] guild join handler threw: {0}", ex.Message); + } + } + } +} From 1244eb6c4fb24bb5eb9b194bca8bf57bd27544ef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 08:02:05 -0500 Subject: [PATCH 3/6] =?UTF-8?q?feat(protocol2):=20presence=20stream=20?= =?UTF-8?q?=E2=80=94=20online=20population=20+=20region=20transitions=20(P?= =?UTF-8?q?art=20B=20ph.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overlay BridgePresence (new): - presence.online sweep over online PlayerMobiles: total plus per-facet and per-region counts, emitted only when the population changes. - region.enter real-time from EventSink.OnEnterRegion (player-filtered), the cheap location signal PLAN.md prefers over Movement. - PresenceSweepSeconds (30s); wired into [bridge reload|sweepnow|status. Sidecar: - GET /online serves the latest presence.online snapshot from the event store (survives restart); population time series via /history?kind=presence.online. Docs: INTEGRATION.md presence events + /online endpoint; PROTOCOL_2 ph.2 built. Verified: sidecar cargo check clean; overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live run pending. Co-Authored-By: Claude Opus 4.8 --- overlay/Config/Bridge.cfg | 9 + overlay/Scripts/Custom/Bridge/BridgeBoot.cs | 4 + overlay/Scripts/Custom/Bridge/BridgeConfig.cs | 10 + .../Scripts/Custom/Bridge/BridgePresence.cs | 203 ++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 overlay/Scripts/Custom/Bridge/BridgePresence.cs diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg index 6cb896e..bf4fc04 100644 --- a/overlay/Config/Bridge.cfg +++ b/overlay/Config/Bridge.cfg @@ -39,6 +39,15 @@ GuildSweepSeconds=60 # Idle (emits nothing) unless the City Loyalty system is enabled (CityLoyalty.Enabled). CitySweepSeconds=300 +# Presence poll. Online population (total, per-facet, per-region) is snapshotted on this +# interval and emitted as presence.online only when it changes. Region transitions come +# through separately in real time as region.enter (EventSink.OnEnterRegion). +PresenceSweepSeconds=30 + +# Housing registry poll. Every house is diffed on this interval to emit house.update / +# house.remove (owner, region, location, decay). Houses change slowly; a few minutes is fine. +HousingSweepSeconds=300 + # Shown to a player when they run [link. The website page where they enter the code. LinkUrl=https://yoursite/link diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs index 6629494..88d3992 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs @@ -163,6 +163,7 @@ namespace Server.Custom.Bridge BridgeChamps.Rearm(); BridgeSocial.Rearm(); BridgeGovernance.Rearm(); + BridgePresence.Rearm(); e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe()); e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect."); break; @@ -177,11 +178,13 @@ namespace Server.Custom.Bridge BridgeChamps.SweepOnce(); BridgeSocial.SweepOnce(); BridgeGovernance.SweepOnce(); + BridgePresence.SweepOnce(); e.Mobile.SendMessage("Bridge: ran one sweep of each stream."); e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status()); + e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status()); break; default: @@ -194,6 +197,7 @@ namespace Server.Custom.Bridge e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status()); + e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status()); break; } diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs index 68ee2c3..e8aebcc 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs @@ -33,6 +33,8 @@ namespace Server.Custom.Bridge 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; } @@ -91,6 +93,14 @@ namespace Server.Custom.Bridge 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); diff --git a/overlay/Scripts/Custom/Bridge/BridgePresence.cs b/overlay/Scripts/Custom/Bridge/BridgePresence.cs new file mode 100644 index 0000000..d0a50f9 --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgePresence.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; + +using Server.Mobiles; + +namespace Server.Custom.Bridge +{ + /// + /// The presence stream (docs/PROTOCOL_2.md §11 #1/#2): who is online and where. Two parts: + /// + /// presence.online - a periodic population snapshot (total, per-facet, per-region), emitted + /// on a sweep but only when it changes, so the site has a live "N online" + /// plus a change history without a firehose of identical frames. + /// region.enter - a real-time location transition from EventSink.OnEnterRegion, the cheap + /// per-player movement signal PLAN.md §5.6 recommends over Movement. + /// + /// The snapshot is derived each sweep from the online PlayerMobiles (NetState != null), the + /// same population the vitals sweep already walks; counting them by map and region is a handful + /// of field reads. region.enter is filtered to players. + /// + public static class BridgePresence + { + private static Timer _timer; + + // Signature of the last-emitted snapshot, so an unchanged population emits nothing. + private static string _lastSig; + + private static long _sweeps, _emitted, _regionEnters; + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + EventSink.OnEnterRegion += OnEnterRegion; + EventSink.ServerStarted += OnServerStarted; + } + + private static void OnServerStarted() + { + // Force the next sweep to emit after a (re)connect, so a sidecar that restarted gets the + // current population within one sweep. + BridgeLink.Connected_Core += OnConnected; + Rearm(); + } + + private static void OnConnected() + { + _lastSig = null; + } + + /// Stops and recreates the timer from current config. Called by `[bridge reload`. + public static void Rearm() + { + Stop(); + + _timer = Timer.DelayCall( + TimeSpan.FromSeconds(BridgeConfig.PresenceSweepSeconds), + TimeSpan.FromSeconds(BridgeConfig.PresenceSweepSeconds), + PresenceSweep); + } + + public static void Stop() + { + if (_timer != null) { _timer.Stop(); _timer = null; } + } + + public static string Status() + { + return String.Format("presence(sweeps={0} emitted={1} regionEnters={2})", + _sweeps, _emitted, _regionEnters); + } + + /// Runs one sweep now. Wired into `[bridge sweepnow`. + public static void SweepOnce() + { + PresenceSweep(); + } + + private static void PresenceSweep() + { + try + { + _sweeps++; + + if (!BridgeLink.Connected) + return; // nothing is listening; do not fill the queue with perishable snapshots + + int total = 0; + var byFacet = new SortedDictionary(StringComparer.Ordinal); + var byRegion = new SortedDictionary(StringComparer.Ordinal); + + foreach (var m in World.Mobiles.Values) + { + var pm = m as PlayerMobile; + + if (pm == null || pm.NetState == null || pm.Deleted) + continue; + + total++; + + var facet = pm.Map == null ? "Internal" : pm.Map.Name; + Bump(byFacet, facet); + + var region = pm.Region; + var regionName = (region == null || String.IsNullOrEmpty(region.Name)) ? "Wilderness" : region.Name; + Bump(byRegion, regionName); + } + + var sig = Signature(total, byFacet, byRegion); + if (sig == _lastSig) + return; // population unchanged since last emit + + _lastSig = sig; + BridgeLink.Emit(WriteOnline(total, byFacet, byRegion)); + _emitted++; + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] presence sweep threw: {0}", ex.Message); + } + } + + private static void Bump(IDictionary map, string key) + { + int n; + map[key] = map.TryGetValue(key, out n) ? n + 1 : 1; + } + + private static string Signature(int total, SortedDictionary byFacet, SortedDictionary byRegion) + { + var sb = new System.Text.StringBuilder(); + sb.Append(total); + foreach (var kv in byFacet) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value); + sb.Append('#'); + foreach (var kv in byRegion) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value); + return sb.ToString(); + } + + private static string WriteOnline(int total, SortedDictionary byFacet, SortedDictionary byRegion) + { + var sb = BridgeJson.Begin("presence.online").Num("count", total); + + WriteCounts(sb, "byFacet", byFacet); + WriteCounts(sb, "byRegion", byRegion); + + return sb.End(); + } + + /// Writes a nested object of {name: count} pairs. + private static void WriteCounts(System.Text.StringBuilder sb, string field, SortedDictionary counts) + { + sb.Append(",\"").Append(field).Append("\":{"); + + bool first = true; + foreach (var kv in counts) + { + if (!first) + sb.Append(','); + first = false; + + BridgeJson.Escape(sb, kv.Key); + sb.Append(':').Append(kv.Value); + } + + sb.Append('}'); + } + + // ---- real-time region transitions ---- + + private static void OnEnterRegion(OnEnterRegionEventArgs e) + { + try + { + if (e == null || e.From == null || !e.From.Player) + return; + + var from = e.OldRegion; + var to = e.NewRegion; + + // Only meaningful when the named region actually changed. + var fromName = from == null ? null : from.Name; + var toName = to == null ? null : to.Name; + if (String.Equals(fromName, toName, StringComparison.Ordinal)) + return; + + var sb = BridgeJson.Begin("region.enter") + .Str("from", fromName) + .Str("to", toName) + .Str("map", e.From.Map == null ? null : e.From.Map.Name); + + sb.Actor("who", e.From); + + BridgeLink.Emit(sb.End()); + _regionEnters++; + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] region enter handler threw: {0}", ex.Message); + } + } + } +} From a837edd5eedea377a59f5d873198de784877f2f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 08:05:47 -0500 Subject: [PATCH 4/6] feat(protocol2): house registry board (Part B ph.3) Overlay BridgeHousing (new): a diff sweep over BaseHouse.AllHouses -> house.update / house.remove (owner, region, location, decay level, co-owners, friends, placement price), complementing the existing house.decay transition feed. HousingSweepSeconds (300s); wired into [bridge reload|sweepnow|status. Stock ServUO has no "for sale" flag, so this is an owner->houses registry; price is the placement value, not a listing. Sidecar: houses board table with upsert/delete/all; main routes house.update/ remove into it; GET /houses served from the store. Docs: INTEGRATION.md house.* events + /houses endpoint; PROTOCOL_2 ph.3 built. Verified: sidecar cargo check clean; overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live run pending. Co-Authored-By: Claude Opus 4.8 --- overlay/Scripts/Custom/Bridge/BridgeBoot.cs | 4 + .../Scripts/Custom/Bridge/BridgeHousing.cs | 165 ++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 overlay/Scripts/Custom/Bridge/BridgeHousing.cs diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs index 88d3992..4af7e27 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs @@ -164,6 +164,7 @@ namespace Server.Custom.Bridge BridgeSocial.Rearm(); BridgeGovernance.Rearm(); BridgePresence.Rearm(); + BridgeHousing.Rearm(); e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe()); e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect."); break; @@ -179,12 +180,14 @@ namespace Server.Custom.Bridge BridgeSocial.SweepOnce(); BridgeGovernance.SweepOnce(); BridgePresence.SweepOnce(); + BridgeHousing.SweepOnce(); e.Mobile.SendMessage("Bridge: ran one sweep of each stream."); e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status()); + e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status()); break; default: @@ -198,6 +201,7 @@ namespace Server.Custom.Bridge e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status()); + e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status()); break; } diff --git a/overlay/Scripts/Custom/Bridge/BridgeHousing.cs b/overlay/Scripts/Custom/Bridge/BridgeHousing.cs new file mode 100644 index 0000000..bca9d1c --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeHousing.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using Server.Multis; + +namespace Server.Custom.Bridge +{ + /// + /// The housing registry (docs/PROTOCOL_2.md §11 #9). BridgeSweeps already emits house.decay + /// *transitions*; this is the complementary *board*: one row per house with owner, location, + /// region, co-owners, value, and current decay level, so the website can render an owner→houses + /// map. Like the other Part B boards it is a diff sweep over BaseHouse.AllHouses — emit + /// house.update only when a house's signature changes, and house.remove when a house is gone. + /// + /// Note: stock ServUO has no "for sale" flag on a house (houses are traded, not listed), so the + /// registry is owner→houses; `price` is the house's placement value, not a sale listing. + /// + public static class BridgeHousing + { + private static Timer _timer; + + // house serial -> last-emitted signature. + private static readonly Dictionary _last = new Dictionary(); + + private static long _sweeps, _emitted, _removed; + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + EventSink.ServerStarted += OnServerStarted; + } + + private static void OnServerStarted() + { + BridgeLink.Connected_Core += OnConnected; + Rearm(); + } + + private static void OnConnected() + { + _last.Clear(); + } + + /// Stops and recreates the timer from current config. Called by `[bridge reload`. + public static void Rearm() + { + Stop(); + + _timer = Timer.DelayCall( + TimeSpan.FromSeconds(BridgeConfig.HousingSweepSeconds), + TimeSpan.FromSeconds(BridgeConfig.HousingSweepSeconds), + HouseSweep); + } + + public static void Stop() + { + if (_timer != null) { _timer.Stop(); _timer = null; } + } + + public static string Status() + { + return String.Format("housing(sweeps={0} emitted={1} removed={2} tracked={3})", + _sweeps, _emitted, _removed, _last.Count); + } + + /// Runs one sweep now. Wired into `[bridge sweepnow`. + public static void SweepOnce() + { + HouseSweep(); + } + + private static void HouseSweep() + { + try + { + _sweeps++; + + if (!BridgeLink.Connected) + return; // nothing is listening; do not fill the queue with perishable snapshots + + var seen = new HashSet(); + + foreach (var house in BaseHouse.AllHouses) + { + if (house == null || house.Deleted) + continue; + + seen.Add(house.Serial); + + var level = house.DecayLevel; // computed getter — read once + var sig = Signature(house, level); + + string prior; + if (_last.TryGetValue(house.Serial, out prior) && prior == sig) + continue; // unchanged since last emit + + _last[house.Serial] = sig; + BridgeLink.Emit(WriteHouse(house, level)); + _emitted++; + } + + var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList(); + foreach (var serial in gone) + { + _last.Remove(serial); + BridgeLink.Emit(BridgeJson.Begin("house.remove").Ser("serial", serial).End()); + _removed++; + } + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] housing sweep threw: {0}", ex.Message); + } + } + + private static string Signature(BaseHouse house, DecayLevel level) + { + var ownerSerial = house.Owner == null ? 0 : house.Owner.Serial.Value; + var region = house.Region; + var regionName = region == null ? "" : (region.Name ?? ""); + var sign = house.Sign; + var name = sign == null ? "" : (sign.GetName() ?? ""); + var coOwners = house.CoOwners == null ? 0 : house.CoOwners.Count; + + return String.Concat( + ownerSerial.ToString(), "|", + level.ToString(), "|", + regionName, "|", + name, "|", + coOwners.ToString(), "|", + house.Price.ToString()); + } + + private static string WriteHouse(BaseHouse house, DecayLevel level) + { + var sb = BridgeJson.Begin("house.update") + .Ser("serial", house.Serial) + .Str("decay", level.ToString()) + .Num("price", house.Price) + .Str("map", house.Map == null ? null : house.Map.Name) + .Num("x", house.X).Num("y", house.Y).Num("z", house.Z); + + var sign = house.Sign; + if (sign != null) + sb.Str("name", sign.GetName()); + + var region = house.Region; + if (region != null) + sb.Str("region", region.Name); + + sb.Actor("owner", house.Owner); + + sb.Num("coOwners", house.CoOwners == null ? 0 : house.CoOwners.Count); + sb.Num("friends", house.Friends == null ? 0 : house.Friends.Count); + + sb.Str("builtOn", house.BuiltOn.ToUniversalTime().ToString("o")); + sb.Str("lastRefreshed", house.LastRefreshed.ToUniversalTime().ToString("o")); + + return sb.End(); + } + } +} From be442d36a0414a61dcf22e606a7ee9e6f8ce7299 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 10:12:01 -0500 Subject: [PATCH 5/6] feat(protocol2): titles in char.profile (Part B ph.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overlay BridgeProfile: char.profile gains a titles block (selected index, fameKarma, skill, and the raw reward-title list) read from PlayerMobile's public title accessors. No new stream, no sidecar change — it rides the existing char.profile served by GET /char. Reward entries may be a cliloc number as a string or a literal; resolve numeric ones website-side like item names. Docs: INTEGRATION.md char.profile titles field; PROTOCOL_2 ph.4 built. Part B phase 5 (Factions/VvV) remains deferred by owner decision. Verified: overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live run pending. Co-Authored-By: Claude Opus 4.8 --- .../Scripts/Custom/Bridge/BridgeProfile.cs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/overlay/Scripts/Custom/Bridge/BridgeProfile.cs b/overlay/Scripts/Custom/Bridge/BridgeProfile.cs index 7e12197..4e08ede 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeProfile.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeProfile.cs @@ -98,9 +98,55 @@ namespace Server.Custom.Bridge } sb.Append(']'); + WriteTitles(sb, m); + return sb.End(); } + /// + /// The titles a character holds (docs/PROTOCOL_2.md §10.3). `selected` is the index into + /// `reward` currently displayed (-1 if none). `fameKarma` and `skill` are the computed + /// display titles (may be absent). `reward` is the raw reward-title list — an entry may be + /// a cliloc number (as a string) or a literal string; resolve clilocs website-side. + /// + private static void WriteTitles(StringBuilder sb, PlayerMobile m) + { + sb.Append(",\"titles\":{\"selected\":").Append(m.SelectedTitle); + + var fameKarma = m.FameKarmaTitle; + if (!String.IsNullOrEmpty(fameKarma)) + { + sb.Append(",\"fameKarma\":"); + BridgeJson.Escape(sb, fameKarma); + } + + var skill = m.PaperdollSkillTitle; + if (!String.IsNullOrEmpty(skill)) + { + sb.Append(",\"skill\":"); + BridgeJson.Escape(sb, skill); + } + + sb.Append(",\"reward\":["); + var rewards = m.RewardTitles; + if (rewards != null) + { + bool first = true; + for (int i = 0; i < rewards.Count; i++) + { + var r = rewards[i]; + if (r == null) + continue; + + if (!first) sb.Append(','); + first = false; + + BridgeJson.Escape(sb, Convert.ToString(r, System.Globalization.CultureInfo.InvariantCulture)); + } + } + sb.Append("]}"); + } + private static bool IsGearLayer(Layer layer) { switch (layer) From 5e0b42b94862d9491777eeedc4023f727328e02a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 11:27:13 -0500 Subject: [PATCH 6/6] =?UTF-8?q?feat(protocol2):=20Town=20Cryer=20news-gump?= =?UTF-8?q?=20integration=20(=C2=A716,=20Protocol=202.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- overlay/Config/Bridge.cfg | 9 + overlay/Scripts/Custom/Bridge/BridgeConfig.cs | 13 ++ overlay/Scripts/Custom/Bridge/BridgeNews.cs | 176 ++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 overlay/Scripts/Custom/Bridge/BridgeNews.cs diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg index bf4fc04..7a5ef92 100644 --- a/overlay/Config/Bridge.cfg +++ b/overlay/Config/Bridge.cfg @@ -58,6 +58,15 @@ TownCrierMaxLineLength=200 TownCrierMaxActive=20 TownCrierMaxDurationSec=86400 +# Town Cryer news gump. Website articles (news.add) become entries in the modern Town +# Cryer News gump (TownCryerSystem.NewsEntries), separate from the scrolling-crier lines +# above. The article title is also proclaimed by the criers (announce defaults on). Caps +# are defense in depth on top of the loopback trust boundary. +NewsMaxTitleLength=100 +NewsMaxBodyLength=2000 +NewsMaxExternal=20 +NewsAnnounceDurationSec=300 + # Admin write plane (staff moderation from the website). OFF by default: the whole # feature is opt-in per shard. When enabled, inbound admin.* commands (kick/ban/unban/ # broadcast) are honored. Authorization is enforced on the website; the shard trusts the diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs index e8aebcc..4ddd13e 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs @@ -43,6 +43,12 @@ namespace Server.Custom.Bridge 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; } @@ -108,6 +114,13 @@ namespace Server.Custom.Bridge 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); diff --git a/overlay/Scripts/Custom/Bridge/BridgeNews.cs b/overlay/Scripts/Custom/Bridge/BridgeNews.cs new file mode 100644 index 0000000..c766406 --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeNews.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; + +using Server.Mobiles; +using Server.Services.TownCryer; + +namespace Server.Custom.Bridge +{ + /// + /// Website news articles pushed into the modern Town Cryer News gump + /// (docs/PROTOCOL_2.md §16). Distinct from BridgeTownCrier, which drives the scrolling-crier + /// announcement lines (GlobalTownCrierEntryList). Here the full article — title, body (HTML), + /// image, and a "more info" URL — becomes a TownCryerNewsEntry in TownCryerSystem.NewsEntries, + /// which the stock news gumps already render (they branch on TextDefinition.Number, so string + /// content needs no gump change). + /// + /// No stock edit: NewsEntries is a public mutable list, so we insert/remove directly and keep + /// our own id -> entry map, leaving the stock entries untouched. On add we also proclaim just + /// the title through the existing crier say path (default on), so players hear it in-world. + /// + /// Everything runs on the Core thread (inbound lines are marshaled through Timer.DelayCall), + /// which is required to touch the shared news list and to send crier packets. + /// + public static class BridgeNews + { + // A neutral scroll gump when the website supplies no image. + private const int DefaultImage = 0x64E; + + // Website id -> the news entry we created for it, so a later remove/replace can find it. + private static readonly Dictionary _ours = + new Dictionary(StringComparer.Ordinal); + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + BridgeBoot.RegisterHandler("news.add", OnAdd); + BridgeBoot.RegisterHandler("news.remove", OnRemove); + } + + private static void OnAdd(Dictionary o) + { + var id = BridgeJson.GetString(o, "id"); + + if (id == null) + { + Reply("news.error", null, "missing id"); + return; + } + + var list = TownCryerSystem.NewsEntries; + if (list == null) + { + Reply("news.error", id, "town cryer unavailable"); + return; + } + + var title = BridgeJson.GetString(o, "title"); + if (String.IsNullOrEmpty(title)) + { + Reply("news.error", id, "missing title"); + return; + } + + var body = BridgeJson.GetString(o, "body") ?? ""; + var url = BridgeJson.GetString(o, "url"); + int image = BridgeJson.GetInt(o, "image", DefaultImage); + + // announce defaults to true (proclaim the title in-world); "announce":false suppresses it. + bool announce = true; + object rawAnnounce; + if (o.TryGetValue("announce", out rawAnnounce) && rawAnnounce is bool) + announce = (bool)rawAnnounce; + + if (title.Length > BridgeConfig.NewsMaxTitleLength) + title = title.Substring(0, BridgeConfig.NewsMaxTitleLength); + if (body.Length > BridgeConfig.NewsMaxBodyLength) + body = body.Substring(0, BridgeConfig.NewsMaxBodyLength); + + try + { + // Replace an existing id in place: drop the old entry first. + TownCryerNewsEntry old; + if (_ours.TryGetValue(id, out old) && old != null) + { + list.Remove(old); + _ours.Remove(id); + } + else if (_ours.Count >= BridgeConfig.NewsMaxExternal) + { + Reply("news.error", id, "too many news entries"); + return; + } + + var entry = new TownCryerNewsEntry( + new TextDefinition(title), + new TextDefinition(body), + image, + null, + url); + + list.Insert(0, entry); // newest first, as the gump reads top-down + _ours[id] = entry; + + if (announce) + Announce(title); + + Reply("news.ok", id, null); + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] news.add threw: {0}", ex.Message); + Reply("news.error", id, "internal error"); + } + } + + private static void OnRemove(Dictionary o) + { + var id = BridgeJson.GetString(o, "id"); + + if (id == null) + { + Reply("news.error", null, "missing id"); + return; + } + + TownCryerNewsEntry entry; + if (!_ours.TryGetValue(id, out entry)) + { + Reply("news.error", id, "unknown id"); + return; + } + + _ours.Remove(id); + + try + { + var list = TownCryerSystem.NewsEntries; + if (list != null && entry != null) + list.Remove(entry); + + Reply("news.ok", id, null); + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] news.remove threw: {0}", ex.Message); + Reply("news.error", id, "internal error"); + } + } + + /// Proclaims a single line — the article title — through the town criers. + private static void Announce(string title) + { + try + { + GlobalTownCrierEntryList.Instance.AddEntry( + new[] { title }, + TimeSpan.FromSeconds(BridgeConfig.NewsAnnounceDurationSec)); + } + catch (Exception ex) + { + // A failed proclamation must not fail the news add — the article is already posted. + Console.WriteLine("[Bridge] news announce threw: {0}", ex.Message); + } + } + + private static void Reply(string kind, string id, string reason) + { + var sb = BridgeJson.Begin(kind); + if (id != null) sb.Str("id", id); + if (reason != null) sb.Str("reason", reason); + BridgeLink.Emit(sb.End()); + } + } +}