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:
@@ -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");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Option<Value>> {
|
||||
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::<String, _>("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
|
||||
);
|
||||
"#;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user