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