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:
2026-07-17 07:54:11 -05:00
parent 5816c29c67
commit dd39d524c6
6 changed files with 463 additions and 0 deletions

View 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";
}
}
}