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:
@@ -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?;
|
||||
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> {
|
||||
@@ -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
|
||||
);
|
||||
"#;
|
||||
|
||||
@@ -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<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 ----
|
||||
|
||||
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
||||
|
||||
Reference in New Issue
Block a user