diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index c6ce727..2bf1be5 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -194,6 +194,21 @@ async fn main() -> anyhow::Result<()> { } } } + // Shard ruleset (Protocol 3.0): a singleton projection. The shard re-emits + // world.ruleset on every connect, so this row is simply overwritten; `rev` + // lets a reader tell a re-send from an actual config change. + "world.ruleset" => { + if let Err(e) = event_store + .upsert_ruleset( + ev.value.get("rev").and_then(|r| r.as_str()), + &text, + t, + ) + .await + { + tracing::warn!(error = %e, "failed to upsert ruleset"); + } + } _ => {} } } diff --git a/sidecar/src/store.rs b/sidecar/src/store.rs index 58f42cb..dcdb9d9 100644 --- a/sidecar/src/store.rs +++ b/sidecar/src/store.rs @@ -293,6 +293,35 @@ impl Store { Ok(parse_json_column(rows)) } + // ---- shard ruleset (Protocol 3.0) ---- + + /// Stores the shard's published ruleset. A singleton (`id = 1`): the shard emits one + /// `world.ruleset` frame per connect describing how it is configured, and only the latest one + /// matters. `rev` is the shard's FNV-1a of the body, kept so a reader can tell "same ruleset, + /// re-sent on reconnect" from "the operator changed something" without diffing the JSON. + pub async fn upsert_ruleset(&self, rev: Option<&str>, json: &str, t: i64) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO ruleset (id, rev, json, updated_t) VALUES (1, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET rev = excluded.rev, json = excluded.json, updated_t = excluded.updated_t", + ) + .bind(rev) + .bind(json) + .bind(t) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// The stored ruleset, or `None` if the shard has never published one. Returning `None` rather + /// than an empty object is deliberate: "not published yet" and "published, everything off" are + /// different answers and the website renders them differently. + pub async fn ruleset(&self) -> anyhow::Result> { + let row = sqlx::query("SELECT json FROM ruleset WHERE id = 1") + .fetch_optional(&self.pool) + .await?; + Ok(row.and_then(|r| serde_json::from_str(&r.get::("json")).ok())) + } + // ---- Town Cryer news (Protocol 2.1) ---- /// Stores/replaces one external news article (the `news.add` command json), keyed by id. The @@ -392,4 +421,13 @@ CREATE TABLE IF NOT EXISTS news ( json TEXT NOT NULL, updated_t INTEGER NOT NULL ); + +-- The shard's published ruleset (Protocol 3.0). Singleton: the CHECK is what makes it one, +-- so an upsert can target id = 1 unconditionally and no second row can ever appear. +CREATE TABLE IF NOT EXISTS ruleset ( + id INTEGER PRIMARY KEY CHECK (id = 1), + rev TEXT, + json TEXT NOT NULL, + updated_t INTEGER NOT NULL +); "#; diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index b37b262..5f96b96 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -82,6 +82,10 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/governors", get(governors)) .route("/online", get(online)) .route("/houses", get(houses)) + // The shard ruleset (Protocol 3.0), likewise store-backed: the shard publishes it once per + // connect, so serving it from the store is what lets the site's rules page render while the + // shard is down. + .route("/ruleset", get(ruleset)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -790,6 +794,23 @@ async fn houses(State(st): State) -> impl IntoResponse { } } +/// The shard's published ruleset: expansion, which optional systems are on, skill/stat caps, +/// account and house limits, champion scroll rules, the save/restart schedule. Served from the +/// store, so it answers during a shard outage with the last-known ruleset — which is the whole +/// point, since a rules page that goes blank when the shard restarts is worse than a stale one. +/// +/// `{"ruleset": null}` means the shard has never published one (an old plugin, or +/// `Bridge.RulesetEnabled=false`), which the website renders differently from a published ruleset. +async fn ruleset(State(st): State) -> impl IntoResponse { + match st.store.ruleset().await { + Ok(r) => (StatusCode::OK, Json(json!({ "ruleset": r }))), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ), + } +} + /// The current online population: total plus per-facet and per-region counts. This is the most /// recent `presence.online` snapshot from the event store (so it survives a sidecar restart); the /// live `presence.online` stream keeps it current, and `GET /history?kind=presence.online` gives the