diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 27e048f..3960840 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -239,6 +239,47 @@ Category-specific fields on `champ.update`: The events are live deltas; for the current board of all spawns at once, use `GET /champs` (§6) — that's what you render on connect, then keep live with these events. +#### Guilds (Protocol 2.0) + +Guilds expose only one in-game event (a member joining), so the roster is polled (`GuildSweepSeconds`, default 60s) and diffed. Like champion spawns, `guild.update` is a **full-state upsert** emitted only on change — treat a guild id you've never seen as "newly created", and drop one on `guild.remove`. `guild.join` is the one real-time event, on top of the board. + +| kind | fields | notes | +|------|--------|-------| +| `guild.update` | `id`, `name`, `abbr`, `members`, `online`, `alliance` (or null), `leader` (actor object or null) | A guild's roster/leader/alliance changed, or its first sight this connection. A **leave** shows up here as `members` dropping. | +| `guild.remove` | `id` | The guild disbanded (leader gone) or was removed. Drop the row. | +| `guild.join` | `id`, `name`, `abbr`, `who` (actor object) | Real-time: a player joined a guild (`EventSink.JoinGuild`). | + +The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user. + +```json +{"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH","members":14, + "online":3,"alliance":"Britannian Pact", + "leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true}, + "t":1752489280000} +{"kind":"guild.join","id":1042,"name":"The Silver Hand","abbr":"TSH", + "who":{"serial":"0x77","name":"Bran","acct":"bran","player":true},"t":1752489281000} +``` + +Render the current board from `GET /guilds` (§6) on connect, then keep it live with these events. + +#### Town governors (Protocol 2.0) + +In modern ServUO the "mayor" of a town is the **City Loyalty Governor**. The set of cities is polled (`CitySweepSeconds`, default 300s); each city emits `city.update` (full-state upsert) only when its governor, governor-elect, or election phase changes. **No events at all unless the shard runs the City Loyalty system.** + +| kind | fields | notes | +|------|--------|-------| +| `city.update` | `city`, `governor` (actor or null), `governorElect` (actor or null), `electionPhase`, `candidates`, `autoPickAt` (ISO-8601 UTC, when an election is ongoing) | A city's governance changed. Derive "the governor changed" by comparing to your stored board. | + +`electionPhase` is one of `none` / `nominate` / `vote` / `pending`. Cities: Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia. + +```json +{"kind":"city.update","city":"Britain","electionPhase":"none","candidates":0, + "governor":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true}, + "governorElect":null,"t":1752489280000} +``` + +Render the current board from `GET /governors` (§6) on connect, then keep it live with these events. + --- ## 5. REST — read queries @@ -512,6 +553,29 @@ GET /champs A row survives a sidecar restart (it's in SQLite), so the board reflects the last-known state even during a shard outage. A `sea` boss appears when summoned and is removed when slain. +### Guild board (Protocol 2.0) + +``` +GET /guilds +→ { "guilds": [ {"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH", + "members":14,"online":3,"alliance":"Britannian Pact", + "leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true}, + "t":1752489280000}, ... ] } +``` + +Every guild's latest roster snapshot at once — the live board. Served from the sidecar's projection (no shard round-trip), kept current by the `guild.*` stream (§4). Render on load, then subscribe. Each entry is exactly a `guild.update` payload; ordered by name. Survives a sidecar restart. + +### Governor board (Protocol 2.0) + +``` +GET /governors +→ { "cities": [ {"kind":"city.update","city":"Britain","electionPhase":"none","candidates":0, + "governor":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true}, + "governorElect":null,"t":1752489280000}, ... ] } +``` + +Every city's latest governance snapshot — the live board, kept current by the `city.update` stream (§4). Empty if the shard does not run the City Loyalty system. Ordered by city. + --- ## 7. Status codes diff --git a/docs/PROTOCOL_2.md b/docs/PROTOCOL_2.md index fef8646..5455b73 100644 --- a/docs/PROTOCOL_2.md +++ b/docs/PROTOCOL_2.md @@ -380,7 +380,7 @@ If the website mirrors rosters/links (it does — `store.record_link`), it must ## 13. Part B phasing -1. **Guilds + governors.** `BridgeSocial.cs` (guild sweep + `JoinGuild`) and `BridgeGovernance.cs` (city sweep), their `GuildSweepSeconds`/`CitySweepSeconds` config, and the `world.systems` frame. Ship with their REST snapshots (`GET /guilds`, `/governors`, §12.2) from day one — a diff stream without its snapshot is half-built. +1. ~~**Guilds + governors.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeSocial.cs` (guild sweep + `JoinGuild` → `guild.update`/`guild.remove`/`guild.join`) and `BridgeGovernance.cs` (city sweep → `city.update`, gated on `CityLoyaltySystem.Enabled`), `GuildSweepSeconds` (60s) / `CitySweepSeconds` (300s) config, both wired into `[bridge reload|sweepnow|status`. Sidecar `guilds`/`governors` board tables + `GET /guilds`, `/governors` served from the store (the §12.2 snapshot rule). Shared `BridgeJson.Actor` writer (serial/name/acct/webId/player). **Deviation from the §10 sketch:** the wire uses full-state `guild.update`/`city.update` upserts (website derives "created"/"governor changed" from the board) rather than discrete `guild.created`/`city.governor` events — this avoids a reconnect re-emit looking like a storm of creations, matching the proven `champ.update` model. *Live end-to-end run still pending.* 2. **Presence.** Who's-online/population sweep + region presence (`OnEnterRegion`) → `GET /online`, population history in the store. 3. **Housing registry.** Extend the decay sweep to a full owner→houses list + houses-for-sale → `GET /houses`. (Selected from the §11 menu.) 4. **Titles.** `char.profile` `titles` block (§10.3) — no new stream, folds into `BridgeProfile`. 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); + } + } + } +} diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index c2fcd29..ea9a228 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -136,6 +136,34 @@ async fn main() -> anyhow::Result<()> { } } } + // Guild board (Protocol 2.0): guild.update folds in the latest roster (one row + // per guild id); guild.remove drops a disbanded guild. + "guild.update" => { + if let Some(id) = ev.value.get("id").and_then(|v| v.as_i64()) { + if let Err(e) = event_store + .upsert_guild(id, ev.value.get("name").and_then(|n| n.as_str()), &text, t) + .await + { + tracing::warn!(error = %e, "failed to upsert guild board"); + } + } + } + "guild.remove" => { + if let Some(id) = ev.value.get("id").and_then(|v| v.as_i64()) { + if let Err(e) = event_store.delete_guild(id).await { + tracing::warn!(error = %e, "failed to remove guild board row"); + } + } + } + // Governor board (Protocol 2.0): city.update folds in each city's latest + // governance state (one row per city). + "city.update" => { + if let Some(city) = ev.value.get("city").and_then(|c| c.as_str()) { + if let Err(e) = event_store.upsert_governor(city, &text, t).await { + tracing::warn!(error = %e, "failed to upsert governor board"); + } + } + } _ => {} } } diff --git a/sidecar/src/store.rs b/sidecar/src/store.rs index ef1d602..789884e 100644 --- a/sidecar/src/store.rs +++ b/sidecar/src/store.rs @@ -187,6 +187,65 @@ impl Store { .await?; Ok(parse_json_column(rows)) } + + // ---- guild board (Protocol 2.0) ---- + + /// Upserts one guild's latest state, keyed by guild id. Fed from `guild.update`; one row per + /// guild, always the most recent snapshot. This is the board the website reads on load. + pub async fn upsert_guild(&self, id: i64, name: Option<&str>, json: &str, t: i64) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO guilds (id, name, json, updated_t) VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET name = excluded.name, json = excluded.json, updated_t = excluded.updated_t", + ) + .bind(id) + .bind(name) + .bind(json) + .bind(t) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Drops one guild from the board. Fed from `guild.remove` (a disband or a removed guild). + pub async fn delete_guild(&self, id: i64) -> anyhow::Result<()> { + sqlx::query("DELETE FROM guilds WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// The full guild board: every guild's latest snapshot, ordered by name. + pub async fn guilds_all(&self) -> anyhow::Result> { + let rows = sqlx::query("SELECT json FROM guilds ORDER BY name, id") + .fetch_all(&self.pool) + .await?; + Ok(parse_json_column(rows)) + } + + // ---- governor board (Protocol 2.0) ---- + + /// Upserts one city's latest governance state, keyed by city name. Fed from `city.update`. + pub async fn upsert_governor(&self, city: &str, json: &str, t: i64) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO governors (city, json, updated_t) VALUES (?, ?, ?) + ON CONFLICT(city) DO UPDATE SET json = excluded.json, updated_t = excluded.updated_t", + ) + .bind(city) + .bind(json) + .bind(t) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// The full governor board: every city's latest governance snapshot, ordered by city. + pub async fn governors_all(&self) -> anyhow::Result> { + let rows = sqlx::query("SELECT json FROM governors ORDER BY city") + .fetch_all(&self.pool) + .await?; + Ok(parse_json_column(rows)) + } } fn parse_json_column(rows: Vec) -> Vec { @@ -225,4 +284,17 @@ CREATE TABLE IF NOT EXISTS champs ( json TEXT NOT NULL, updated_t INTEGER NOT NULL ); + +CREATE TABLE IF NOT EXISTS guilds ( + id INTEGER PRIMARY KEY, + name TEXT, + json TEXT NOT NULL, + updated_t INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS governors ( + city TEXT PRIMARY KEY, + json TEXT NOT NULL, + updated_t INTEGER NOT NULL +); "#; diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index a94dff6..a8f8c73 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -73,6 +73,10 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/history", get(history)) .route("/economy", get(economy)) .route("/champs", get(champs)) + // World-state boards (Protocol 2.0), served from the store so they answer without the shard + // and survive an outage with the last-known snapshot (docs/PROTOCOL_2.md §12.2). + .route("/guilds", get(guilds)) + .route("/governors", get(governors)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -700,6 +704,31 @@ async fn champs(State(st): State) -> impl IntoResponse { } } +/// The guild board: every guild's latest roster snapshot (id/name/abbr/leader/members/alliance). +/// Served from the local board table, so it hydrates a fresh page or a restarted sidecar without a +/// shard round-trip. The live `guild.*` feed then keeps it current. +async fn guilds(State(st): State) -> impl IntoResponse { + match st.store.guilds_all().await { + Ok(guilds) => (StatusCode::OK, Json(json!({"guilds": guilds}))), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ), + } +} + +/// The governor board: each city's latest governance snapshot (governor/elect/election phase). +/// Served from the local board table for the same reason as `/guilds`. +async fn governors(State(st): State) -> impl IntoResponse { + match st.store.governors_all().await { + Ok(cities) => (StatusCode::OK, Json(json!({"cities": cities}))), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ), + } +} + // ---- websocket ---- async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State) -> impl IntoResponse {