Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
Claude dd39d524c6 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>
2026-07-17 07:54:11 -05:00

203 lines
7.6 KiB
C#

using System;
using System.Collections.Generic;
using Server.Commands;
namespace Server.Custom.Bridge
{
/// <summary>
/// Lifecycle wiring. Boot order (Server/Main.cs:544-562, all on the Core thread):
///
/// Configure() -> World.Load() -> Initialize() -> EventSink.ServerStarted
///
/// Config is read in Configure. Handlers are attached in Initialize. The socket opens on
/// ServerStarted, once the world is actually there to describe.
///
/// EventSink.Shutdown does NOT fire on a crash (Server/Main.cs:198,313), so the sidecar
/// must treat socket EOF as normal and re-handshake rather than waiting for a goodbye.
/// </summary>
public static class BridgeBoot
{
private static readonly Dictionary<string, Action<Dictionary<string, object>>> _handlers =
new Dictionary<string, Action<Dictionary<string, object>>>(StringComparer.Ordinal);
/// <summary>
/// Identifies this run of the shard. It is stable across sidecar reconnects and changes
/// on every shard restart, which is how the sidecar tells "I reconnected" (keep my
/// cached state) from "the shard restarted" (discard it).
/// </summary>
private static string _bootId;
public static void Configure()
{
BridgeConfig.Configure();
}
public static void Initialize()
{
if (!BridgeConfig.Enabled)
{
Console.WriteLine("[Bridge] disabled by config");
return;
}
CommandSystem.Register("bridge", AccessLevel.Administrator, Bridge_OnCommand);
RegisterHandler("ping", OnPing);
BridgeLink.InboundLine += OnInboundLine;
BridgeLink.Connected_Core += EmitHello;
EventSink.ServerStarted += OnServerStarted;
EventSink.Shutdown += OnShutdown;
EventSink.Crashed += OnCrashed;
Console.WriteLine("[Bridge] {0}", BridgeConfig.Describe());
}
/// <summary>Handlers run on the Core thread. They may touch the world freely.</summary>
public static void RegisterHandler(string kind, Action<Dictionary<string, object>> handler)
{
_handlers[kind] = handler;
}
private static void OnServerStarted()
{
_bootId = Guid.NewGuid().ToString("N");
BridgeLink.Start();
}
/// <summary>
/// Core thread, once per connection. The sidecar restarts independently of the shard,
/// so this is sent on every connect rather than once at boot — otherwise a sidecar that
/// came up second would never learn which shard it is talking to.
/// </summary>
private static void EmitHello()
{
BridgeLink.Emit(BridgeJson.Begin("server.hello")
.Str("shard", Server.Misc.ServerList.ServerName)
.Str("bootId", _bootId)
.Num("connects", BridgeLink.Connects)
.Num("items", World.Items.Count)
.Num("mobiles", World.Mobiles.Count)
.Num("accounts", Accounting.Accounts.Count)
.End());
}
private static void OnShutdown(ShutdownEventArgs e)
{
BridgeLink.Emit(BridgeJson.Begin("server.shutdown").End());
// Stop() joins the link thread for up to 2s, which gives the writer a chance to drain
// the goodbye. Best effort: the sidecar must not depend on receiving it.
BridgeLink.Stop();
}
private static void OnCrashed(CrashedEventArgs e)
{
try
{
BridgeLink.Emit(BridgeJson.Begin("server.crashed")
.Str("error", e.Exception == null ? null : e.Exception.Message)
.End());
BridgeLink.Stop();
}
catch
{
// The process is already going down. Never make a crash worse.
}
}
/// <summary>Core thread, one call per inbound line.</summary>
private static void OnInboundLine(string line)
{
var obj = BridgeJson.Parse(line);
if (obj == null)
{
Console.WriteLine("[Bridge] malformed inbound line, ignoring");
return;
}
var kind = BridgeJson.GetString(obj, "kind");
if (kind == null)
return;
Action<Dictionary<string, object>> handler;
if (!_handlers.TryGetValue(kind, out handler))
{
Console.WriteLine("[Bridge] no handler for inbound kind '{0}'", kind);
return;
}
handler(obj);
}
private static void OnPing(Dictionary<string, object> o)
{
var sb = BridgeJson.Begin("pong");
var id = BridgeJson.GetString(o, "id");
if (id != null)
sb.Str("id", id);
BridgeLink.Emit(sb.End());
}
[Usage("bridge [status | reload | ping | sweepnow]")]
[Description("Inspects and controls the sidecar link.")]
private static void Bridge_OnCommand(CommandEventArgs e)
{
var arg = e.Length > 0 ? e.GetString(0).ToLowerInvariant() : "status";
switch (arg)
{
case "reload":
BridgeConfig.Load();
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;
case "ping":
BridgeLink.Emit(BridgeJson.Begin("ping").End());
e.Mobile.SendMessage("Bridge: ping queued.");
break;
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:
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
e.Mobile.SendMessage(
"Bridge: connected={0} depth={1} sent={2} dropped={3} received={4} connects={5} writeErrors={6}",
BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
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;
}
}
}
}