diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg
index c1beefe..7a5ef92 100644
--- a/overlay/Config/Bridge.cfg
+++ b/overlay/Config/Bridge.cfg
@@ -29,6 +29,25 @@ 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
+
+# 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
@@ -39,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
@@ -57,6 +85,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/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
index 80e6082..4af7e27 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
@@ -161,6 +161,10 @@ namespace Server.Custom.Bridge
BridgeSweeps.Rearm();
BridgePages.Rearm();
BridgeChamps.Rearm();
+ 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;
@@ -173,9 +177,17 @@ namespace Server.Custom.Bridge
case "sweepnow":
BridgeSweeps.SweepOnce();
BridgeChamps.SweepOnce();
+ 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:
@@ -186,6 +198,10 @@ 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}", BridgePresence.Status());
+ e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.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 627e5e4..4ddd13e 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.
@@ -19,6 +31,10 @@ 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 int PresenceSweepSeconds { get; private set; }
+ public static int HousingSweepSeconds { get; private set; }
public static string LinkUrl { get; private set; }
@@ -27,12 +43,25 @@ 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; }
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()
@@ -60,6 +89,24 @@ 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;
+
+ 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);
@@ -67,14 +114,77 @@ 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);
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 +205,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);
}
}
}
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/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();
+ }
+ }
+}
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/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());
+ }
+ }
+}
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);
+ }
+ }
+ }
+}
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)
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);
+ }
+ }
+ }
+}