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);
+ }
+ }
+ }
+}