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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
175
overlay/Scripts/Custom/Bridge/BridgeGovernance.cs
Normal file
175
overlay/Scripts/Custom/Bridge/BridgeGovernance.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Server.Engines.CityLoyalty;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="BridgeChamps"/> and <see cref="BridgeSocial"/> — 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.
|
||||
/// </summary>
|
||||
public static class BridgeGovernance
|
||||
{
|
||||
private static Timer _timer;
|
||||
|
||||
// City enum value -> last-emitted signature.
|
||||
private static readonly Dictionary<int, string> _last = new Dictionary<int, string>();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Folds the election state into one of: none / nominate / vote / pending.</summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,46 @@ namespace Server.Custom.Bridge
|
||||
return sb;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>
|
||||
public static string End(this StringBuilder sb)
|
||||
{
|
||||
|
||||
218
overlay/Scripts/Custom/Bridge/BridgeSocial.cs
Normal file
218
overlay/Scripts/Custom/Bridge/BridgeSocial.cs
Normal file
@@ -0,0 +1,218 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
using Server.Guilds;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="BridgeChamps"/>, 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.
|
||||
/// </summary>
|
||||
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<int, string> _last = new Dictionary<int, string>();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||
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<int>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user