feat(store): persist world.ruleset and serve it from GET /ruleset

Protocol 3.0 §5 (docs/link/v3.md). The shard publishes one world.ruleset frame
per connect describing how it is configured; the sidecar folds it into a
singleton row and serves it back.

Store-backed rather than an RPC, for the same reason /guilds and /houses are
(PROTOCOL_2.md §12.2): a rules page that goes blank while the shard restarts is
worse than one that is briefly stale. `{"ruleset": null}` distinguishes "the
shard has never published one" — an old plugin, or Bridge.RulesetEnabled=false —
from a published ruleset, which the website renders differently.

`rev` (the shard's FNV-1a of the body) is kept alongside the JSON so a reader can
tell "same ruleset, re-sent on reconnect" from "the operator changed something"
without diffing.

PROTOCOL_VERSION stays 2. The 2→3 bump is a hard operator-visible cutover and
happens exactly once, at the end of v3 (§4), not per phase.

Smoke-tested against a fake shard on loopback: frame ingested, GET /ruleset
returns it with plugin_connected=false (outage path), and the route sits behind
the gate (409 on a version mismatch, 401 unauthenticated). cargo build + clippy
clean.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-28 11:19:10 -05:00
parent 7e4177c6c0
commit 8e018a01e9
3 changed files with 74 additions and 0 deletions

View File

@@ -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<AppState>) -> 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<AppState>) -> 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