feat(store): persist points.board and serve it from GET /points
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<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
|
||||
/// 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