Protocol 2.0 + 2.1 — account provisioning, world-state streams, Town Cryer news #7
@@ -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.
|
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
|
## 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.
|
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
|
## 7. Status codes
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ If the website mirrors rosters/links (it does — `store.record_link`), it must
|
|||||||
|
|
||||||
## 13. Part B phasing
|
## 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.
|
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.)
|
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`.
|
4. **Titles.** `char.profile` `titles` block (§10.3) — no new stream, folds into `BridgeProfile`.
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ ChampSweepSeconds=10
|
|||||||
# support queue; the full open queue is also available on demand via pages.snapshot.
|
# support queue; the full open queue is also available on demand via pages.snapshot.
|
||||||
PageSweepSeconds=5
|
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.
|
# Shown to a player when they run [link. The website page where they enter the code.
|
||||||
LinkUrl=https://yoursite/link
|
LinkUrl=https://yoursite/link
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ namespace Server.Custom.Bridge
|
|||||||
BridgeSweeps.Rearm();
|
BridgeSweeps.Rearm();
|
||||||
BridgePages.Rearm();
|
BridgePages.Rearm();
|
||||||
BridgeChamps.Rearm();
|
BridgeChamps.Rearm();
|
||||||
|
BridgeSocial.Rearm();
|
||||||
|
BridgeGovernance.Rearm();
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
||||||
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
|
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
|
||||||
break;
|
break;
|
||||||
@@ -173,9 +175,13 @@ namespace Server.Custom.Bridge
|
|||||||
case "sweepnow":
|
case "sweepnow":
|
||||||
BridgeSweeps.SweepOnce();
|
BridgeSweeps.SweepOnce();
|
||||||
BridgeChamps.SweepOnce();
|
BridgeChamps.SweepOnce();
|
||||||
|
BridgeSocial.SweepOnce();
|
||||||
|
BridgeGovernance.SweepOnce();
|
||||||
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -186,6 +192,8 @@ namespace Server.Custom.Bridge
|
|||||||
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
|
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.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());
|
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ namespace Server.Custom.Bridge
|
|||||||
public static int EconomySweepSeconds { get; private set; }
|
public static int EconomySweepSeconds { get; private set; }
|
||||||
public static int PageSweepSeconds { get; private set; }
|
public static int PageSweepSeconds { get; private set; }
|
||||||
public static int ChampSweepSeconds { 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; }
|
public static string LinkUrl { get; private set; }
|
||||||
|
|
||||||
@@ -79,6 +81,16 @@ namespace Server.Custom.Bridge
|
|||||||
if (ChampSweepSeconds < 1)
|
if (ChampSweepSeconds < 1)
|
||||||
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");
|
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
|
||||||
|
|
||||||
TownCrierMaxLines = Config.Get("Bridge.TownCrierMaxLines", 6);
|
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;
|
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>
|
/// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>
|
||||||
public static string End(this StringBuilder sb)
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,6 +187,65 @@ impl Store {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(parse_json_column(rows))
|
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<Vec<Value>> {
|
||||||
|
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<Vec<Value>> {
|
||||||
|
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<sqlx::sqlite::SqliteRow>) -> Vec<Value> {
|
fn parse_json_column(rows: Vec<sqlx::sqlite::SqliteRow>) -> Vec<Value> {
|
||||||
@@ -225,4 +284,17 @@ CREATE TABLE IF NOT EXISTS champs (
|
|||||||
json TEXT NOT NULL,
|
json TEXT NOT NULL,
|
||||||
updated_t INTEGER 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
|
||||||
|
);
|
||||||
"#;
|
"#;
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
|||||||
.route("/history", get(history))
|
.route("/history", get(history))
|
||||||
.route("/economy", get(economy))
|
.route("/economy", get(economy))
|
||||||
.route("/champs", get(champs))
|
.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));
|
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
@@ -700,6 +704,31 @@ async fn champs(State(st): State<AppState>) -> 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<AppState>) -> 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<AppState>) -> 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 ----
|
// ---- websocket ----
|
||||||
|
|
||||||
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user