From 21a1462e620724ff64c4527505d73e524d0d4a54 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 28 Jul 2026 21:04:23 -0500 Subject: [PATCH] feat(store): persist points.board and serve it from GET /points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocol 3.0 §7 (docs/link/v3.md). The shard publishes ~25 points/loyalty leaderboards as one points.board frame per system; the sidecar folds each into a projection table and serves them back, so the site's leaderboards page renders during a shard outage. - points_boards(system PK, name, json, updated_t), keyed by the shard's own PointsType name. `name` is hoisted only for the ORDER BY. - main.rs gains a points.board arm keyed on `system`, alongside the existing champ/guild/governor/house/ruleset projections. There is deliberately no delete counterpart: the shard's set of point systems is fixed at startup, so it emits no points.remove — the same shape the governor board already has. - GET /points returns every board ordered by display name; GET /points/:system returns one, or 404 when the shard has never published that system. 404 and "a published board nobody has scored in yet" (200, empty top) are different answers, and the website renders them differently. Store-backed rather than an RPC for the same reason as the other boards, and it matters more here: these are standings accumulated over months, so blanking them during a shard restart reads as data loss rather than as staleness. PROTOCOL_VERSION stays at 2 — the bump to 3 is the one-time edge → main cutover in v3.md §4, not a per-phase change. cargo build and cargo clippy --all-targets are clean. Smoke-tested against a driver on the loopback link: two boards stored and served, a re-emitted system overwriting rather than accumulating, 404 for an unknown system, 401 unauthenticated. Also verified against the real ServUO shard, which fed five live boards through this path. Co-Authored-By: Claude --- sidecar/src/main.rs | 20 +++++++++++++++ sidecar/src/store.rs | 58 ++++++++++++++++++++++++++++++++++++++++++++ sidecar/src/web.rs | 41 +++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 2bf1be5..6949b6a 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -194,6 +194,26 @@ async fn main() -> anyhow::Result<()> { } } } + // Points/loyalty boards (Protocol 3.0): one row per point system, keyed by + // the shard's own PointsType name. The plugin only emits a system whose top N + // actually moved, so this is a sparse stream of overwrites — and there is no + // `points.remove` to handle, because the shard's set of systems is fixed at + // startup and cannot shrink. + "points.board" => { + if let Some(system) = ev.value.get("system").and_then(|s| s.as_str()) { + if let Err(e) = event_store + .upsert_points_board( + system, + ev.value.get("nameString").and_then(|n| n.as_str()), + &text, + t, + ) + .await + { + tracing::warn!(error = %e, "failed to upsert points board"); + } + } + } // 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. diff --git a/sidecar/src/store.rs b/sidecar/src/store.rs index dcdb9d9..cba9f4a 100644 --- a/sidecar/src/store.rs +++ b/sidecar/src/store.rs @@ -322,6 +322,55 @@ impl Store { Ok(row.and_then(|r| serde_json::from_str(&r.get::("json")).ok())) } + // ---- points / loyalty boards (Protocol 3.0) ---- + + /// Upserts one system's leaderboard, keyed by its `PointsType` name (`QueensLoyalty`, + /// `CleanUpBritannia`, …). Fed from `points.board`; one row per system, always the most recent + /// top-N snapshot. + /// + /// There is no matching delete, and that is deliberate rather than an omission: the shard's set + /// of point systems is fixed at startup by `PointsSystem.Configure`, so a system cannot vanish + /// at runtime and the plugin emits no `points.remove`. Same argument the governor board makes. + pub async fn upsert_points_board( + &self, + system: &str, + name: Option<&str>, + json: &str, + t: i64, + ) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO points_boards (system, name, json, updated_t) VALUES (?, ?, ?, ?) + ON CONFLICT(system) DO UPDATE SET name = excluded.name, json = excluded.json, updated_t = excluded.updated_t", + ) + .bind(system) + .bind(name) + .bind(json) + .bind(t) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Every system's latest board, ordered by display name then system key. Systems the shard has + /// never published are simply absent — the website renders the set it is given. + pub async fn points_boards_all(&self) -> anyhow::Result> { + let rows = sqlx::query("SELECT json FROM points_boards ORDER BY name, system") + .fetch_all(&self.pool) + .await?; + Ok(parse_json_column(rows)) + } + + /// One system's board, or `None` when that system has never published one. `None` is a real + /// answer (an unknown system name, or one the operator excluded via `Bridge.cfg PointsSystems`), + /// which the website turns into a 404 rather than an empty board. + pub async fn points_board(&self, system: &str) -> anyhow::Result> { + let row = sqlx::query("SELECT json FROM points_boards WHERE system = ?") + .bind(system) + .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 @@ -422,6 +471,15 @@ CREATE TABLE IF NOT EXISTS news ( updated_t INTEGER NOT NULL ); +-- Points/loyalty leaderboards (Protocol 3.0). One row per point system, keyed by the shard's +-- own PointsType name; `name` is the resolved display name, hoisted only for the ORDER BY. +CREATE TABLE IF NOT EXISTS points_boards ( + system TEXT PRIMARY KEY, + name TEXT, + 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 ( diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 5f96b96..3912ef1 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -86,6 +86,10 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // 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)) + // Points/loyalty leaderboards (Protocol 3.0), store-backed like the other boards: the + // whole set, or one system by its PointsType name. + .route("/points", get(points)) + .route("/points/:system", get(points_system)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -811,6 +815,43 @@ async fn ruleset(State(st): State) -> impl IntoResponse { } } +/// Every points/loyalty leaderboard the shard publishes: one entry per point system, each with its +/// display name (literal and/or cliloc), max points, participant count and top N. Store-backed like +/// the other boards, so the site's leaderboards page renders during a shard outage — which matters +/// more here than elsewhere, since these are month-scale standings that a restart must not blank. +async fn points(State(st): State) -> impl IntoResponse { + match st.store.points_boards_all().await { + Ok(boards) => (StatusCode::OK, Json(json!({"boards": boards}))), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ), + } +} + +/// One system's board by its `PointsType` name (`QueensLoyalty`, `CleanUpBritannia`, …). +/// +/// 404 rather than an empty board when the system is unknown: the shard publishes only the systems +/// it shows on the loyalty gump (or the explicit `Bridge.cfg PointsSystems` list), so "no such +/// board" and "a board with nobody on it" are different answers and the website renders them +/// differently. +async fn points_system( + State(st): State, + Path(system): Path, +) -> impl IntoResponse { + match st.store.points_board(&system).await { + Ok(Some(board)) => (StatusCode::OK, Json(board)), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "unknown points system", "system": system})), + ), + 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