feat(pages): help-page (support) queue — stream, snapshot, respond, close

Phase 2 of docs/ADMIN_CONTROLS.md: surface the in-game help-page queue to the
website.

- BridgePages.cs: the queue has no EventSink, so it is polled (PageSweepSeconds,
  default 5s) and diffed, keyed by sender serial (one page per player) ->
  page.new / page.updated / page.closed. Inbound pages.snapshot -> pages.list;
  page.respond delivers a staff reply to the player (online: a gump now; offline:
  queued for next login; shows as "Staff") and can close; page.close removes it.
- BridgeConfig/Bridge.cfg: PageSweepSeconds. BridgeBoot: reload re-arms the poll,
  status reports it.
- sidecar/src/web.rs: GET /pages, POST /pages/{id}/respond, POST /pages/{id}/close.
- INTEGRATION.md: page events (§4) and endpoints (§6).
- tools/scaffolding/BridgePageProbe.cs: gated headless verification.

Verified live (probe-seeded tickets): snapshot returns the queue, the poll emits
page.new for both and page.closed on removal, respond -> 200, close removes the
page, unknown page -> 404.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
This commit is contained in:
2026-07-13 02:54:19 -05:00
parent 11169c52a6
commit 17e1c91fb3
8 changed files with 572 additions and 1 deletions

View File

@@ -63,6 +63,10 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
.route("/admin/ban", post(admin_ban))
.route("/admin/unban", post(admin_unban))
.route("/admin/broadcast", post(admin_broadcast))
// Help-page (support) queue: snapshot the open queue, respond to / close a page.
.route("/pages", get(pages_list))
.route("/pages/:id/respond", post(page_respond))
.route("/pages/:id/close", post(page_close))
// History, read from SQLite rather than the shard.
.route("/history", get(history))
.route("/economy", get(economy))
@@ -330,6 +334,45 @@ async fn admin_broadcast(State(st): State<AppState>, Json(body): Json<Value>) ->
admin_call(&st, "admin.broadcast", body).await
}
// ---- help-page queue handlers ----
/// The open help-page queue, correlated on reqId. Returns a pages.list.
async fn pages_list(State(st): State<AppState>) -> impl IntoResponse {
let req_id = st.rpc.next_req_id();
let cmd = json!({"kind": "pages.snapshot", "reqId": req_id});
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
}
/// Body: {"message":"...","close":<bool, optional>}. Delivers a staff response to the player.
async fn page_respond(
State(st): State<AppState>,
Path(id): Path<String>,
Json(body): Json<Value>,
) -> impl IntoResponse {
let message = body.get("message").and_then(|m| m.as_str()).unwrap_or_default();
if message.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "message is required"})),
);
}
let close = body.get("close").and_then(|c| c.as_bool()).unwrap_or(false);
let req_id = st.rpc.next_req_id();
let cmd = json!({
"kind": "page.respond", "reqId": req_id,
"pageId": id, "message": message, "close": close
});
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
}
/// Removes a page from the queue.
async fn page_close(State(st): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
let req_id = st.rpc.next_req_id();
let cmd = json!({"kind": "page.close", "reqId": req_id, "pageId": id});
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
}
// ---- query handlers ----
async fn char_by_slot(