Compare commits
2 Commits
41811d40af
...
2e386a9d5c
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e386a9d5c | |||
| 21a1462e62 |
@@ -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.
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user