feat(shard): admin write plane, help-page queue, and public champion board
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m20s
PR Checks / client-build (pull_request) Successful in 9m49s
PR Checks / bot-install (pull_request) Successful in 9m33s

Wire up the three uo-link sidecar surfaces that weren't integrated yet.

Champion spawns
- Ingest champ.update/champ.remove into a new shard_champs table (served from
  our own store, like online/houses); public /site/champs board with a nav link,
  live via the existing SSE feed (champ.* added to the public allowlist).

Staff write plane (admin + moderator)
- kick / ban / unban / broadcast via /admin/shard/*; actor is stamped server-side
  from the session, never the browser. Sidecar status codes mapped (403 disabled/
  protected, 404 unknown, 503/504 transient). admin.audit events are logged and
  surfaced at /admin/shard/audit.
- New admin "In-Game Ops" view (/admin/shard-ops), plus per-account Kick/Ban/Unban
  on the user-detail and character views (ShardAccountActions, self-gated to staff).

Help-page (support) queue
- Ingest page.new/updated/closed into a new shard_pages table; respond/close via
  /admin/shard/pages/*. Champ board and page queue are snapshotted from the
  sidecar's /champs and /pages on every WS (re)connect (guarded so a failed call
  never wipes local state).

Verified live end-to-end against MariaDB + the Rust sidecar + ServUO; unit tests
cover ingest routing (shardIngest.champsPages.test.js). Swagger regenerated.

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-14 13:16:10 -05:00
parent 2dc360ca48
commit c31553aeb6
25 changed files with 2188 additions and 8 deletions

View File

@@ -172,6 +172,129 @@ async function listOnlineForAccounts(accounts) {
return rows.map(shapeOnline)
}
// ── Champion spawns ────────────────────────────────────────────────────────
// Upsert a champ spawn's state (champ.update). The full event is stored in
// `payload` for the category-specific fields; a few columns are hoisted out for
// querying/ordering. is-boss-up is derived from bossUp (sea bosses are always up).
async function upsertChamp(ev) {
if (!ev || !ev.serial) return
await db.upsertChamp(ev.serial, {
category: ev.category ?? null,
type: ev.type ?? null,
name: ev.name ?? null,
status: ev.status ?? null,
active: ev.active ? 1 : 0,
map: ev.map ?? null,
x: ev.x ?? null,
y: ev.y ?? null,
z: ev.z ?? null,
boss_up: ev.bossUp ? 1 : 0,
payload: JSON.stringify(ev),
t: Number.isFinite(ev.t) ? ev.t : null,
})
}
const removeChamp = (serial) => (serial ? db.removeChamp(serial) : Promise.resolve())
const clearChamps = () => db.clearChamps()
// Return the stored champ.update payload (the shape the sidecar/UI expect),
// falling back to the hoisted columns if an older row lacks a payload.
function shapeChamp(r) {
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
return payload || {
kind: 'champ.update',
serial: r.serial,
category: r.category,
type: r.type,
name: r.name,
status: r.status,
active: Boolean(r.active),
map: r.map,
x: r.x,
y: r.y,
z: r.z,
bossUp: Boolean(r.boss_up),
t: r.t,
}
}
async function listChamps() {
const rows = await db.listChamps()
return rows.map(shapeChamp)
}
// Replace the whole board with a fresh snapshot (sidecar GET /champs on connect).
async function replaceChamps(spawns) {
await db.clearChamps()
for (const ev of spawns || []) await upsertChamp(ev)
}
// ── Help-page (support) queue ──────────────────────────────────────────────
// Upsert a page (page.new / page.updated). The `sender` actor object carries the
// name/acct/webId; the rest are top-level fields.
async function upsertPage(ev) {
const pageId = ev && (ev.pageId || (ev.sender && ev.sender.serial))
if (!pageId) return
const sender = ev.sender || {}
await db.upsertPage(pageId, {
type: ev.type ?? null,
sender_name: sender.name ?? null,
sender_acct: sender.acct ?? null,
web_id: sender.webId ?? null,
message: ev.message ?? null,
map: ev.map ?? null,
x: ev.x ?? null,
y: ev.y ?? null,
z: ev.z ?? null,
sent_ms: Number.isFinite(ev.sentMs) ? ev.sentMs : null,
handled: ev.handled ? 1 : 0,
handler: ev.handler ?? null,
payload: JSON.stringify(ev),
})
}
const removePage = (pageId) => (pageId ? db.removePage(pageId) : Promise.resolve())
const clearPages = () => db.clearPages()
function shapePage(r) {
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
return {
pageId: r.page_id,
type: r.type,
sender: { serial: r.page_id, name: r.sender_name, acct: r.sender_acct, webId: r.web_id },
message: r.message,
map: r.map,
x: r.x,
y: r.y,
z: r.z,
sentMs: r.sent_ms == null ? null : Number(r.sent_ms),
handled: Boolean(r.handled),
handler: r.handler,
updatedAt: r.updated_at,
// Keep the raw payload available for any field not hoisted above.
payload: payload || undefined,
}
}
async function listPages() {
const rows = await db.listPages()
return rows.map(shapePage)
}
// Replace the whole queue with a fresh snapshot (sidecar GET /pages on connect).
async function replacePages(pages) {
await db.clearPages()
for (const ev of pages || []) await upsertPage(ev)
}
function safeJson(s) {
try {
return JSON.parse(s)
} catch {
return null
}
}
module.exports = {
upsertOnline,
setOffline,
@@ -186,4 +309,14 @@ module.exports = {
upsertHouse,
listIdoc,
listHousesForAccounts,
upsertChamp,
removeChamp,
clearChamps,
listChamps,
replaceChamps,
upsertPage,
removePage,
clearPages,
listPages,
replacePages,
}