feat(store): persist points.board and serve it from GET /points #18

Merged
whitlocktech merged 1 commits from feat/points-board into edge 2026-07-29 07:53:20 +00:00
3 changed files with 119 additions and 0 deletions

View File

@@ -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 // Shard ruleset (Protocol 3.0): a singleton projection. The shard re-emits
// world.ruleset on every connect, so this row is simply overwritten; `rev` // world.ruleset on every connect, so this row is simply overwritten; `rev`
// lets a reader tell a re-send from an actual config change. // lets a reader tell a re-send from an actual config change.

View File

@@ -322,6 +322,55 @@ impl Store {
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok())) Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("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<Vec<Value>> {
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<Option<Value>> {
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::<String, _>("json")).ok()))
}
// ---- Town Cryer news (Protocol 2.1) ---- // ---- Town Cryer news (Protocol 2.1) ----
/// Stores/replaces one external news article (the `news.add` command json), keyed by id. The /// 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 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, -- 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. -- so an upsert can target id = 1 unconditionally and no second row can ever appear.
CREATE TABLE IF NOT EXISTS ruleset ( CREATE TABLE IF NOT EXISTS ruleset (

View File

@@ -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 // connect, so serving it from the store is what lets the site's rules page render while the
// shard is down. // shard is down.
.route("/ruleset", get(ruleset)) .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)); .route_layer(middleware::from_fn_with_state(state.clone(), gate));
let app = Router::new() let app = Router::new()
@@ -811,6 +815,43 @@ async fn ruleset(State(st): State<AppState>) -> 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<AppState>) -> 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<AppState>,
Path(system): Path<String>,
) -> 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 /// 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 /// 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 /// live `presence.online` stream keeps it current, and `GET /history?kind=presence.online` gives the