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>
176 lines
5.9 KiB
C#
176 lines
5.9 KiB
C#
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";
|
|
}
|
|
}
|
|
}
|