+
+
+ }
+ />
} />
} />
} />
diff --git a/client/src/api/client.js b/client/src/api/client.js
index 0ca383e..a5c36a3 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -94,6 +94,7 @@ export const api = {
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
online: () => req('/public/shard/online'),
idoc: () => req('/public/shard/idoc'),
+ champs: () => req('/public/shard/champs'),
},
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
// fetch-only, so SSE subscribers build the URL from here. The admin stream
@@ -259,6 +260,20 @@ export const api = {
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
+ // ----- in-game staff operations: write plane + support queue (admin/moderator) -----
+ // `actor` is stamped server-side from the session — never sent from here.
+ shardOps: {
+ kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
+ ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
+ unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
+ broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
+ pages: () => req('/admin/shard/pages'),
+ respondPage: (id, data) =>
+ req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
+ closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
+ audit: (limit) => req(`/admin/shard/audit${limit ? `?limit=${limit}` : ''}`),
+ },
+
// ----- Email delivery / Gmail OAuth2 (admin only) -----
getEmailConfig: () => req('/admin/email/config'),
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
diff --git a/client/src/components/CharacterSheet.jsx b/client/src/components/CharacterSheet.jsx
index 34bdb9e..72f247d 100644
--- a/client/src/components/CharacterSheet.jsx
+++ b/client/src/components/CharacterSheet.jsx
@@ -1,6 +1,12 @@
// Reusable character-sheet renderer for the char.profile shape returned by
// /public/shard/char/:serial. Presentational only — the parent handles loading
// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles).
+//
+// `moderation` opts in the in-game kick/ban controls for the character's account;
+// they self-gate to staff (ShardAccountActions), so passing it from a page a
+// player can reach is safe.
+
+import ShardAccountActions from './ShardAccountActions.jsx'
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
@@ -28,7 +34,7 @@ function Vital({ label, cur, max }) {
)
}
-export default function CharacterSheet({ char }) {
+export default function CharacterSheet({ char, moderation = false }) {
if (!char) return null
const stats = char.stats || {}
const resist = stats.resist || {}
@@ -58,6 +64,14 @@ export default function CharacterSheet({ char }) {
{char.serial}
+ {/* Staff moderation for this character's account (self-gates to staff). */}
+ {moderation && char.acct && (
+
+ Account {char.acct}
+
+
+ )}
+
{/* Core stats */}
Attributes
diff --git a/client/src/components/GameAccounts.jsx b/client/src/components/GameAccounts.jsx
index c4180f8..fccc8b8 100644
--- a/client/src/components/GameAccounts.jsx
+++ b/client/src/components/GameAccounts.jsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loading, ErrorState } from './PageState.jsx'
+import ShardAccountActions from './ShardAccountActions.jsx'
// Shared game-account linking + character roster, used by both the player portal
// (/player) and the staff account page (/admin/account). `scope` is the api
@@ -107,7 +108,7 @@ function AccountRoster({ scope, account, charTo }) {
)
}
-export default function GameAccounts({ scope, charTo, readOnly = false }) {
+export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false }) {
const [accounts, setAccounts] = useState(null)
const [error, setError] = useState('')
@@ -156,6 +157,7 @@ export default function GameAccounts({ scope, charTo, readOnly = false }) {
{a.account}
+ {moderation && }
))}
diff --git a/client/src/components/ShardAccountActions.jsx b/client/src/components/ShardAccountActions.jsx
new file mode 100644
index 0000000..10510a5
--- /dev/null
+++ b/client/src/components/ShardAccountActions.jsx
@@ -0,0 +1,84 @@
+import { useState } from 'react'
+import { useAuth } from '../contexts/AuthContext.jsx'
+import { api } from '../api/client.js'
+
+// Compact in-game moderation controls (kick / ban / unban) scoped to a single
+// game account. Reused wherever a linked account or character is shown to staff:
+// the admin user-detail account list and the character sheet. Self-gates on role
+// (admin/moderator) so it is safe to render inside components that players also
+// see — a player never gets the controls, and the API enforces the same gate.
+//
+// `actor` is stamped server-side from the session; nothing here sends it. Kick is
+// reversible (they reconnect) so it acts immediately; Ban reveals an inline
+// confirm with an optional duration + reason before it fires.
+export default function ShardAccountActions({ account, style }) {
+ const { user } = useAuth()
+ const [busy, setBusy] = useState('')
+ const [ok, setOk] = useState('')
+ const [err, setErr] = useState('')
+ const [banOpen, setBanOpen] = useState(false)
+ const [durationSec, setDurationSec] = useState('')
+ const [reason, setReason] = useState('')
+
+ // Only staff who can actually use the write plane see the controls.
+ if (!user || !['admin', 'moderator'].includes(user.role) || !account) return null
+
+ async function run(label, fn, done) {
+ setBusy(label); setOk(''); setErr('')
+ try {
+ const r = await fn()
+ setOk(done(r))
+ } catch (e) {
+ setErr(e.message || 'Action failed.')
+ } finally {
+ setBusy('')
+ }
+ }
+
+ const kick = () =>
+ run('kick', () => api.admin.shardOps.kick({ account }), (r) =>
+ `Kicked${r && r.sessions != null ? ` (${r.sessions} session${r.sessions === 1 ? '' : 's'})` : ''}.`,
+ )
+ const unban = () => run('unban', () => api.admin.shardOps.unban(account), () => 'Unbanned.')
+ const ban = () =>
+ run('ban', () =>
+ api.admin.shardOps.ban({
+ account,
+ durationSec: durationSec === '' ? undefined : Number(durationSec),
+ reason: reason.trim() || undefined,
+ }),
+ () => {
+ setBanOpen(false)
+ return `Banned${durationSec ? ` for ${durationSec}s` : ' indefinitely'}.`
+ })
+
+ const btn = { fontSize: '0.72rem', padding: '4px 10px' }
+
+ return (
+
+ )
+}
diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx
index e8df12f..65b59aa 100644
--- a/client/src/components/SiteHeader.jsx
+++ b/client/src/components/SiteHeader.jsx
@@ -12,6 +12,7 @@ const NAV = [
{ label: 'Newsletter', to: '/site/newsletter' },
{ label: 'Wiki', to: '/wiki' },
{ label: 'Shard', to: '/site/shard' },
+ { label: 'Champions', to: '/site/champs' },
{ label: 'About', to: '/site/about' },
]
diff --git a/client/src/lib/shardEvents.js b/client/src/lib/shardEvents.js
index 963cd71..00c70ec 100644
--- a/client/src/lib/shardEvents.js
+++ b/client/src/lib/shardEvents.js
@@ -45,6 +45,24 @@ export function describe(ev) {
return 'Shard shut down'
case 'server.crashed':
return `Shard crashed${p.error ? `: ${p.error}` : ''}`
+ case 'champ.update': {
+ const where = p.name || p.type || 'A champion spawn'
+ if (p.status === 'active' && p.bossUp) return `${where}: boss is up${p.boss ? ` (${p.boss})` : ''}`
+ if (p.status === 'active') return `${where} is active${p.level != null ? ` — level ${p.level}` : ''}`
+ if (p.status === 'cooldown') return `${where} is on cooldown`
+ return `${where} is ${p.status || 'idle'}`
+ }
+ case 'champ.remove':
+ return `A champion spawn ended`
+ // Support (help-page) queue + in-game moderation (admin channel only)
+ case 'page.new':
+ return `New ${p.type || 'help'} page from ${nameOf(p.sender)}`
+ case 'page.updated':
+ return `Help page from ${nameOf(p.sender)} updated${p.handled ? ' (claimed)' : ''}`
+ case 'page.closed':
+ return `Help page ${p.pageId || ''} closed`
+ case 'admin.audit':
+ return `${p.actor || 'Staff'} ${p.action || 'acted'}${p.target ? ` on ${p.target}` : ''}${p.origin ? ` [${p.origin}]` : ''}`
// Staff / sensitive (admin channel only)
case 'audit.set':
return `${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})`
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index 9cce70b..3073b89 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -63,6 +63,7 @@ const NAV = [
title: 'Moderation',
items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
+ { to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] },
],
},
{
@@ -94,6 +95,7 @@ const TITLES = {
'/admin/wiki': 'Wiki Pages',
'/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation',
+ '/admin/shard-ops': 'In-Game Ops',
'/admin/settings': 'Site Settings',
'/admin/activity': 'Activity Log',
'/admin/bot-activity': 'Web Bot Activity',
@@ -136,11 +138,13 @@ export default function AdminLayout() {
const wide = location.pathname === '/admin/hero'
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
- // Moderators only get the moderation section + their own account security.
+ // Moderators only get the moderation section (Discord + in-game ops) + their
+ // own account security.
const isModerator = user?.role === 'moderator'
+ const MOD_PATHS = ['/admin/moderation', '/admin/shard-ops', '/admin/account']
const visible = (item) => {
if (item.roles && !item.roles.includes(user?.role)) return false
- if (isModerator) return item.to === '/admin/moderation' || item.to === '/admin/account'
+ if (isModerator) return MOD_PATHS.includes(item.to)
return true
}
// Drop items the current role can't see, then drop any now-empty group so an
@@ -178,7 +182,9 @@ export default function AdminLayout() {
useEffect(() => {
if (!isModerator) return
const p = location.pathname
- if (!p.startsWith('/admin/moderation') && p !== '/admin/account') {
+ const allowed =
+ p.startsWith('/admin/moderation') || p.startsWith('/admin/shard-ops') || p === '/admin/account'
+ if (!allowed) {
navigate('/admin/moderation', { replace: true })
}
}, [isModerator, location.pathname, navigate])
diff --git a/client/src/routes/admin/views/AdminCharacter.jsx b/client/src/routes/admin/views/AdminCharacter.jsx
index db54934..f320f6a 100644
--- a/client/src/routes/admin/views/AdminCharacter.jsx
+++ b/client/src/routes/admin/views/AdminCharacter.jsx
@@ -23,7 +23,7 @@ export default function AdminCharacter() {
{restarting && }
{forbidden && }
{error && !restarting && !forbidden && }
- {!loading && !error && data && }
+ {!loading && !error && data && }
)
}
diff --git a/client/src/routes/admin/views/ShardOps.jsx b/client/src/routes/admin/views/ShardOps.jsx
new file mode 100644
index 0000000..9e33dd5
--- /dev/null
+++ b/client/src/routes/admin/views/ShardOps.jsx
@@ -0,0 +1,269 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useShardFeed } from '../../../lib/useShardFeed.js'
+import { describe } from '../../../lib/shardEvents.js'
+import { ago } from '../../../lib/format.js'
+import { api } from '../../../api/client.js'
+
+// In-game staff operations: the uo-link write plane (broadcast / kick / ban /
+// unban) and the help-page support queue, plus a live audit log. Open to admins
+// and moderators. The acting staff member (`actor`) is attached server-side from
+// the session — nothing here sends it — so every action is attributable.
+
+function Flash({ ok, err }) {
+ if (ok) return {ok}
+ if (err) return {err}
+ return null
+}
+
+// ── Broadcast ────────────────────────────────────────────────────────────────
+function Broadcast() {
+ const [text, setText] = useState('')
+ const [hue, setHue] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [ok, setOk] = useState('')
+ const [err, setErr] = useState('')
+
+ async function send() {
+ if (!text.trim()) return setErr('Enter a message.')
+ setBusy(true); setOk(''); setErr('')
+ try {
+ await api.admin.shardOps.broadcast({ text: text.trim(), hue: hue === '' ? undefined : Number(hue) })
+ setOk('Broadcast sent.')
+ setText('')
+ } catch (e) {
+ setErr(e.message || 'Could not broadcast.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+// ── Account actions (kick / ban / unban) ─────────────────────────────────────
+function AccountActions() {
+ const [account, setAccount] = useState('')
+ const [durationSec, setDurationSec] = useState('')
+ const [reason, setReason] = useState('')
+ const [busy, setBusy] = useState('')
+ const [ok, setOk] = useState('')
+ const [err, setErr] = useState('')
+
+ const acct = account.trim()
+ function guard() {
+ if (!acct) {
+ setErr('Enter an account name.')
+ return false
+ }
+ return true
+ }
+
+ async function run(label, fn, done) {
+ if (!guard()) return
+ setBusy(label); setOk(''); setErr('')
+ try {
+ const r = await fn()
+ setOk(done(r))
+ } catch (e) {
+ setErr(e.message || 'Action failed.')
+ } finally {
+ setBusy('')
+ }
+ }
+
+ const kick = () =>
+ run('kick', () => api.admin.shardOps.kick({ account: acct }), (r) => `Kicked ${acct}${r?.sessions != null ? ` (${r.sessions} session${r.sessions === 1 ? '' : 's'})` : ''}.`)
+ const ban = () =>
+ run('ban', () => api.admin.shardOps.ban({ account: acct, durationSec: durationSec === '' ? undefined : Number(durationSec), reason: reason.trim() || undefined }), () => `Banned ${acct}${durationSec ? ` for ${durationSec}s` : ' indefinitely'}.`)
+ const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`)
+
+ return (
+
+ )
+}
+
+// ── Support (help-page) queue ────────────────────────────────────────────────
+function PageRow({ page, onDone }) {
+ const [message, setMessage] = useState('')
+ const [busy, setBusy] = useState('')
+ const [err, setErr] = useState('')
+
+ async function respond(close) {
+ if (!message.trim()) return setErr('Enter a reply first.')
+ setBusy(close ? 'respond-close' : 'respond'); setErr('')
+ try {
+ await api.admin.shardOps.respondPage(page.pageId, { message: message.trim(), close })
+ onDone()
+ } catch (e) {
+ setErr(e.message || 'Could not send.')
+ setBusy('')
+ }
+ }
+ async function close() {
+ setBusy('close'); setErr('')
+ try {
+ await api.admin.shardOps.closePage(page.pageId)
+ onDone()
+ } catch (e) {
+ setErr(e.message || 'Could not close.')
+ setBusy('')
+ }
+ }
+
+ return (
+
+
+
+
{page.type || 'Page'}
+
+ {page.sender?.name || page.pageId}
+ {page.handled && · claimed{page.handler ? ` by ${page.handler}` : ''}}
+
+
+
{page.sentMs ? ago(page.sentMs) : ''}
+
+ {page.message &&
{page.message}
}
+
+ {page.map || '—'}{page.x != null ? ` (${page.x}, ${page.y})` : ''}
+
+
+ )
+}
+
+function SupportQueue() {
+ const [pages, setPages] = useState(null)
+ const [err, setErr] = useState('')
+ const pollRef = useRef(null)
+
+ const load = useCallback(async () => {
+ try {
+ setPages(await api.admin.shardOps.pages())
+ } catch {
+ setErr('Could not load the support queue.')
+ }
+ }, [])
+
+ useEffect(() => {
+ load()
+ pollRef.current = setInterval(load, 7000)
+ return () => clearInterval(pollRef.current)
+ }, [load])
+
+ return (
+
+ Support queue
+
+ Open help pages from players. A reply reaches them in game (or on their next login).
+
+ {err && {err}}
+ {pages == null ? (
+ Loading…
+ ) : pages.length === 0 ? (
+ The queue is empty.
+ ) : (
+
+ )}
+
+ )
+}
+
+// ── Audit log ────────────────────────────────────────────────────────────────
+// Seeded from the stored admin.audit history, then kept live from the admin SSE
+// channel (which carries every kind — we filter to admin.audit here).
+function AuditLog() {
+ const [seed, setSeed] = useState([])
+ const { events } = useShardFeed({ url: api.adminShardStreamUrl, filter: new Set(['admin.audit']), max: 50 })
+
+ useEffect(() => {
+ api.admin.shardOps
+ .audit(50)
+ .then((rows) => setSeed(rows.map((r) => ({ ...r, _id: `seed-${r.id}` }))))
+ .catch(() => setSeed([]))
+ }, [])
+
+ // Live events on top; fall back to the seed for anything older than the live tail.
+ const oldestLive = events.length ? Math.min(...events.map((e) => e.t || 0)) : Infinity
+ const rows = [...events, ...seed.filter((s) => (s.t || 0) < oldestLive)].slice(0, 60)
+
+ return (
+
+ Audit log
+ {rows.length === 0 ? (
+ No moderation actions recorded yet.
+ ) : (
+
+ {rows.map((e) => (
+ -
+ {describe(e)}
+ {ago(e.t)}
+
+ ))}
+
+ )}
+
+ )
+}
+
+export default function ShardOps() {
+ return (
+
+ )
+}
diff --git a/client/src/routes/admin/views/UserDetail.jsx b/client/src/routes/admin/views/UserDetail.jsx
index a2033e2..5ca2f0e 100644
--- a/client/src/routes/admin/views/UserDetail.jsx
+++ b/client/src/routes/admin/views/UserDetail.jsx
@@ -101,7 +101,7 @@ function ShardSections({ scope }) {
<>
Linked accounts & characters
- `/admin/characters/${serial}`} />
+ `/admin/characters/${serial}`} />
diff --git a/client/src/routes/public/ChampSpawns.jsx b/client/src/routes/public/ChampSpawns.jsx
new file mode 100644
index 0000000..71d8635
--- /dev/null
+++ b/client/src/routes/public/ChampSpawns.jsx
@@ -0,0 +1,203 @@
+import { useMemo } from 'react'
+import PublicLayout from '../../components/PublicLayout.jsx'
+import PageHeader from '../../components/PageHeader.jsx'
+import { Loading, ErrorState } from '../../components/PageState.jsx'
+import { useAsync } from '../../lib/useAsync.js'
+import { useShardFeed } from '../../lib/useShardFeed.js'
+import { api } from '../../api/client.js'
+
+// The champion-spawn board. Loaded once from /public/shard/champs, then kept live
+// by merging champ.update / champ.remove deltas from the public SSE feed. Three
+// families share the board, split by category into their own sections.
+const CHAMP_KINDS = new Set(['champ.update', 'champ.remove'])
+
+const SECTIONS = [
+ { id: 'champion', title: 'Champion altars', blurb: 'Felucca-style altar spawns.' },
+ { id: 'mini', title: 'Mini champs', blurb: 'TerMur controllers — they re-arm on their own.' },
+ { id: 'sea', title: 'Sea bosses', blurb: 'High Seas world bosses, alive only while summoned.' },
+]
+
+const STATUS_STYLE = {
+ active: { bg: 'rgba(95,185,138,0.16)', fg: '#8fdcae', border: 'rgba(95,185,138,0.45)', label: 'Active' },
+ cooldown: { bg: 'rgba(230,194,106,0.14)', fg: '#e6c26a', border: 'rgba(230,194,106,0.4)', label: 'Cooldown' },
+ dormant: { bg: 'rgba(140,150,165,0.14)', fg: '#aab3c0', border: 'rgba(140,150,165,0.35)', label: 'Dormant' },
+}
+
+// A short "in 4m" / "in 2h" for a future ISO timestamp (restartAt / expireAt).
+function until(iso) {
+ if (!iso) return ''
+ const ms = new Date(iso).getTime() - Date.now()
+ if (!Number.isFinite(ms)) return ''
+ if (ms <= 0) return 'due'
+ const mins = Math.round(ms / 60000)
+ if (mins < 60) return `in ${mins}m`
+ const hrs = Math.round(mins / 60)
+ return `in ${hrs}h`
+}
+
+function StatusBadge({ status }) {
+ const s = STATUS_STYLE[status] || STATUS_STYLE.dormant
+ return (
+
+ {s.label}
+
+ )
+}
+
+// A slim progress bar (kills toward the next level, or a sea boss's hit points).
+function Meter({ value, max, tone = 'var(--accent)' }) {
+ if (!max) return null
+ const pct = Math.max(0, Math.min(100, (Number(value) / Number(max)) * 100))
+ return (
+
+ )
+}
+
+// Category-specific middle line + meter for one spawn.
+function ChampDetail({ s }) {
+ const line = { display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.8rem', color: 'var(--muted)', marginTop: 8 }
+ if (s.category === 'sea') {
+ return (
+ <>
+
+ {s.boss || s.type}
+ {s.hitsMax != null && {Number(s.hits).toLocaleString()} / {Number(s.hitsMax).toLocaleString()} hp}
+
+
+ >
+ )
+ }
+ if (s.category === 'mini') {
+ return (
+
+ Level {s.level ?? 0}{s.maxLevel != null ? ` / ${s.maxLevel}` : ''}
+ {s.status === 'active' ? 'Running' : 'Re-arming'}
+
+ )
+ }
+ // champion
+ return (
+ <>
+
+
+ Level {s.level ?? 0}
+ {s.bossUp && s.boss ? ` — ${s.boss}` : ''}
+
+
+ {s.status === 'cooldown'
+ ? until(s.restartAt) || 'restarting'
+ : s.status === 'active'
+ ? `${Number(s.kills || 0).toLocaleString()} / ${Number(s.maxKills || 0).toLocaleString()} kills`
+ : ''}
+
+
+ {s.status === 'active' && (
+
+ )}
+ >
+ )
+}
+
+function ChampCard({ s }) {
+ return (
+
+
+
+ {s.name || s.type || 'Spawn'}
+
+
+
+
+
+ {s.map || '—'}{s.x != null ? ` (${s.x}, ${s.y})` : ''}
+
+
+ )
+}
+
+export default function ChampSpawns() {
+ const { loading, error, data } = useAsync(() => api.shard.champs())
+ const { events, connected } = useShardFeed({ filter: CHAMP_KINDS, max: 60 })
+
+ // Merge the initial snapshot with live deltas: seed a map by serial, then apply
+ // buffered events oldest → newest (the buffer is newest-first) so live wins.
+ const board = useMemo(() => {
+ const map = new Map()
+ for (const s of data || []) if (s && s.serial) map.set(s.serial, s)
+ for (let i = events.length - 1; i >= 0; i -= 1) {
+ const ev = events[i]
+ if (!ev || !ev.serial) continue
+ if (ev.kind === 'champ.update') map.set(ev.serial, ev)
+ else if (ev.kind === 'champ.remove') map.delete(ev.serial)
+ }
+ return [...map.values()]
+ }, [data, events])
+
+ const byCategory = (id) =>
+ board.filter((s) => (s.category || 'champion') === id).sort((a, b) => (a.name || '').localeCompare(b.name || ''))
+
+ const activeCount = board.filter((s) => s.status === 'active').length
+
+ return (
+
+
+
+
+
+
+ {connected ? 'Live' : 'Offline'}
+
+
+
+ {loading &&
}
+ {error &&
}
+
+ {!loading && !error && (
+ <>
+ {board.length === 0 ? (
+
+ No champion spawns are being tracked right now.
+
+ ) : (
+ <>
+
+ {activeCount} active · {board.length} tracked
+
+ {SECTIONS.map((sec) => {
+ const rows = byCategory(sec.id)
+ if (rows.length === 0) return null
+ return (
+
+
+
{sec.title}
+
{sec.blurb}
+
+
+ {rows.map((s) => )}
+
+
+ )
+ })}
+ >
+ )}
+ >
+ )}
+
+
+ )
+}
diff --git a/server/db/schema.sql b/server/db/schema.sql
index cc0797b..3559a9b 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -383,6 +383,54 @@ CREATE TABLE IF NOT EXISTS shard_account_links (
INDEX idx_shard_links_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+-- Current champion-spawn board, upserted on champ.update and removed on
+-- champ.remove. Mirrors the sidecar's /champs projection into our own store so
+-- the public Champions page (and its live deltas) survive a shard outage, the
+-- same way shard_online / shard_houses do. Three families share one table, told
+-- apart by `category` (champion | mini | sea); category-specific fields (level,
+-- kills, boss, restartAt, hits, …) live in the JSON `payload` so the schema does
+-- not have to model every variant.
+CREATE TABLE IF NOT EXISTS shard_champs (
+ serial VARCHAR(20) NOT NULL PRIMARY KEY, -- controller/mobile serial (opaque hex)
+ category VARCHAR(16) NULL, -- champion | mini | sea
+ type VARCHAR(80) NULL,
+ name VARCHAR(120) NULL,
+ status VARCHAR(16) NULL, -- active | cooldown | dormant
+ active TINYINT(1) NOT NULL DEFAULT 0,
+ map VARCHAR(40) NULL,
+ x INT NULL,
+ y INT NULL,
+ z INT NULL,
+ boss_up TINYINT(1) NOT NULL DEFAULT 0,
+ payload JSON NOT NULL, -- the full champ.update object
+ t BIGINT NULL, -- event time, epoch ms
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_shard_champs_category (category)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Current open help-page (support ticket) queue, upserted on page.new/page.updated
+-- and removed on page.closed. Snapshotted authoritatively from the sidecar's
+-- GET /pages on every (re)connect. page_id is the sender's serial (one page per
+-- player). Staff-only data — served on the admin channel, never public.
+CREATE TABLE IF NOT EXISTS shard_pages (
+ page_id VARCHAR(20) NOT NULL PRIMARY KEY, -- sender serial (one page per player)
+ type VARCHAR(40) NULL, -- Bug | Stuck | Account | Question | ...
+ sender_name VARCHAR(120) NULL,
+ sender_acct VARCHAR(120) NULL,
+ web_id INT NULL, -- linked website user id, if any
+ message TEXT NULL,
+ map VARCHAR(40) NULL,
+ x INT NULL,
+ y INT NULL,
+ z INT NULL,
+ sent_ms BIGINT NULL, -- when the page was opened, epoch ms
+ handled TINYINT(1) NOT NULL DEFAULT 0, -- a staffer claimed it in game
+ handler VARCHAR(120) NULL,
+ payload JSON NOT NULL, -- the full page.new/updated object
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_shard_pages_handled (handled)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
-- Discord bot moderation core (Phase 2). These tables are owned by the bot
-- process (its own DB pool, bot/src/db.js) — the main server never reads or
-- writes them. They live in the same physical database as everything else
diff --git a/server/src/model/shardState/shardState.db.js b/server/src/model/shardState/shardState.db.js
index 2aa9138..5710f64 100644
--- a/server/src/model/shardState/shardState.db.js
+++ b/server/src/model/shardState/shardState.db.js
@@ -113,6 +113,50 @@ const listHousesByAccounts = (accounts) =>
accounts,
)
+// ── Champion spawns ────────────────────────────────────────────────────────
+const CHAMP_COLS =
+ 'serial, category, type, name, status, active, map, x, y, z, boss_up, payload, t, updated_at'
+
+async function upsertChamp(serial, fields) {
+ const cols = Object.keys(fields)
+ const allCols = ['serial', ...cols]
+ const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
+ const placeholders = allCols.map(() => '?').join(', ')
+ const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
+ await query(
+ `INSERT INTO shard_champs (${insertCols}) VALUES (${placeholders})
+ ON DUPLICATE KEY UPDATE ${updates}`,
+ [serial, ...cols.map((c) => fields[c])],
+ )
+}
+
+const removeChamp = (serial) => query('DELETE FROM shard_champs WHERE serial = ?', [serial])
+const clearChamps = () => query('DELETE FROM shard_champs')
+// Ordered by name (matches the sidecar's /champs ordering).
+const listChamps = () => query(`SELECT ${CHAMP_COLS} FROM shard_champs ORDER BY name ASC`)
+
+// ── Help-page (support) queue ──────────────────────────────────────────────
+const PAGE_COLS =
+ 'page_id, type, sender_name, sender_acct, web_id, message, map, x, y, z, sent_ms, handled, handler, payload, updated_at'
+
+async function upsertPage(pageId, fields) {
+ const cols = Object.keys(fields)
+ const allCols = ['page_id', ...cols]
+ const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
+ const placeholders = allCols.map(() => '?').join(', ')
+ const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
+ await query(
+ `INSERT INTO shard_pages (${insertCols}) VALUES (${placeholders})
+ ON DUPLICATE KEY UPDATE ${updates}`,
+ [pageId, ...cols.map((c) => fields[c])],
+ )
+}
+
+const removePage = (pageId) => query('DELETE FROM shard_pages WHERE page_id = ?', [pageId])
+const clearPages = () => query('DELETE FROM shard_pages')
+// Oldest-open first so the queue reads like a work list.
+const listPages = () => query(`SELECT ${PAGE_COLS} FROM shard_pages ORDER BY sent_ms ASC`)
+
module.exports = {
upsertOnline,
removeOnline,
@@ -127,4 +171,12 @@ module.exports = {
upsertHouse,
listIdocHouses,
listHousesByAccounts,
+ upsertChamp,
+ removeChamp,
+ clearChamps,
+ listChamps,
+ upsertPage,
+ removePage,
+ clearPages,
+ listPages,
}
diff --git a/server/src/model/shardState/shardState.model.js b/server/src/model/shardState/shardState.model.js
index 4fd9e56..1773b81 100644
--- a/server/src/model/shardState/shardState.model.js
+++ b/server/src/model/shardState/shardState.model.js
@@ -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,
}
diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js
index 9965c50..a423d15 100644
--- a/server/src/router/v1/admin/admin.routes.js
+++ b/server/src/router/v1/admin/admin.routes.js
@@ -12,6 +12,7 @@ const authProviders = require('./authProviders.controller')
const discordBot = require('./discordBot.controller')
const emailConfig = require('./emailConfig.controller')
const uoLink = require('./uoLink.controller')
+const shardOps = require('./shardOps.controller')
const usersShard = require('./usersShard.controller')
const selfShard = require('../player/shard.controller')
const moderation = require('./moderation.controller')
@@ -181,6 +182,112 @@ adminRouter.get(
selfShard.getSales,
)
+// ── In-game staff operations (uo-link write plane + support queue) ─────
+// Privileged live-shard actions and the help-page queue, open to moderators as
+// well as admins (modAccess). `actor` is stamped server-side from the session in
+// the controller — the body never carries it. See shardOps.controller.js.
+adminRouter.post(
+ '/shard/kick',
+ // #swagger.tags = ['Admin · Shard']
+ // #swagger.summary = 'Kick every live session of an account (admin/moderator)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, serial: { type: "string" } } } } } } */
+ /* #swagger.responses[200] = { description: 'Kicked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
+ /* #swagger.responses[403] = { description: 'Protected target or write plane disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ modAccess,
+ body('account').optional({ values: 'falsy' }).matches(SHARD_ACCOUNT_RE),
+ body('serial').optional({ values: 'falsy' }).matches(/^0x[0-9a-fA-F]+$/),
+ validate,
+ shardOps.kick,
+)
+adminRouter.post(
+ '/shard/ban',
+ // #swagger.tags = ['Admin · Shard']
+ // #swagger.summary = 'Ban an account, timed or indefinite (admin/moderator)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, serial: { type: "string" }, durationSec: { type: "integer" }, reason: { type: "string" } } } } } } */
+ /* #swagger.responses[200] = { description: 'Banned', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
+ /* #swagger.responses[403] = { description: 'Protected target or write plane disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ modAccess,
+ body('account').optional({ values: 'falsy' }).matches(SHARD_ACCOUNT_RE),
+ body('serial').optional({ values: 'falsy' }).matches(/^0x[0-9a-fA-F]+$/),
+ body('durationSec').optional().isInt({ min: 0, max: 315360000 }),
+ body('reason').optional({ values: 'falsy' }).isString().trim().isLength({ max: 500 }),
+ validate,
+ shardOps.ban,
+)
+adminRouter.post(
+ '/shard/unban',
+ // #swagger.tags = ['Admin · Shard']
+ // #swagger.summary = 'Clear an account ban (admin/moderator)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" } }, required: ["account"] } } } } */
+ /* #swagger.responses[200] = { description: 'Unbanned', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
+ modAccess,
+ body('account').matches(SHARD_ACCOUNT_RE),
+ validate,
+ shardOps.unban,
+)
+adminRouter.post(
+ '/shard/broadcast',
+ // #swagger.tags = ['Admin · Shard']
+ // #swagger.summary = 'Broadcast a system message to everyone online (admin/moderator)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { text: { type: "string" }, hue: { type: "integer" } }, required: ["text"] } } } } */
+ /* #swagger.responses[200] = { description: 'Broadcast', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
+ modAccess,
+ body('text').isString().trim().isLength({ min: 1, max: 300 }),
+ body('hue').optional().isInt({ min: 0, max: 3000 }),
+ validate,
+ shardOps.broadcast,
+)
+adminRouter.get(
+ '/shard/pages',
+ // #swagger.tags = ['Admin · Shard']
+ // #swagger.summary = 'Open help-page (support) queue (admin/moderator)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'Open pages', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
+ modAccess,
+ shardOps.listPages,
+)
+adminRouter.post(
+ '/shard/pages/:id/respond',
+ // #swagger.tags = ['Admin · Shard']
+ // #swagger.summary = 'Reply to a help page, optionally closing it (admin/moderator)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page id (sender serial).' }
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { message: { type: "string" }, close: { type: "boolean" } }, required: ["message"] } } } } */
+ /* #swagger.responses[200] = { description: 'Responded', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
+ /* #swagger.responses[404] = { description: 'Unknown page', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ modAccess,
+ param('id').matches(/^0x[0-9a-fA-F]+$/),
+ body('message').isString().trim().isLength({ min: 1, max: 500 }),
+ body('close').optional().isBoolean(),
+ validate,
+ shardOps.respondPage,
+)
+adminRouter.post(
+ '/shard/pages/:id/close',
+ // #swagger.tags = ['Admin · Shard']
+ // #swagger.summary = 'Resolve a help page without a reply (admin/moderator)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page id (sender serial).' }
+ /* #swagger.responses[200] = { description: 'Closed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
+ modAccess,
+ param('id').matches(/^0x[0-9a-fA-F]+$/),
+ validate,
+ shardOps.closePage,
+)
+adminRouter.get(
+ '/shard/audit',
+ // #swagger.tags = ['Admin · Shard']
+ // #swagger.summary = 'Recent in-game moderation audit events (admin/moderator)'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'admin.audit events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */
+ modAccess,
+ shardOps.listAudit,
+)
+
// ── Image uploads (screenshots/gallery) ───────────────────────────────
const UPLOAD_DIR =
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')
diff --git a/server/src/router/v1/admin/shardOps.controller.js b/server/src/router/v1/admin/shardOps.controller.js
new file mode 100644
index 0000000..3e6d020
--- /dev/null
+++ b/server/src/router/v1/admin/shardOps.controller.js
@@ -0,0 +1,158 @@
+// ── Admin: in-game staff operations (uo-link write plane + support queue) ────
+//
+// The privileged "write plane" (§6 of the sidecar guide): kick / ban / unban /
+// broadcast against the live shard, plus the help-page (support ticket) queue.
+// Gated admin+moderator at the route (modAccess) — the sidecar trusts the
+// loopback socket, so authorization is entirely the site's responsibility.
+//
+// SECURITY: `actor` (who is taking the action) is ALWAYS set here from the
+// authenticated session (req.user.username), never from the request body, so an
+// action can't be attributed to someone else. The shard records it in its console
+// log, the ban's BanDealer tag, and the admin.audit event it echoes back.
+
+const uoLinkClient = require('../../../utils/uoLinkClient')
+const shardState = require('../../../model/shardState/shardState.model')
+const shardEvents = require('../../../model/shardEvents/shardEvents.model')
+const activity = require('../../../model/activity/activity.model')
+
+const log = require('../../../utils/logger')('admin-shard-ops')
+
+// Map a never-throw uoLinkClient result onto an HTTP response. `okData` shapes the
+// success body. Mirrors the sidecar's documented status codes so the UI can tell a
+// transient outage (503/504 — retry) from a real rejection (403/404).
+function relay(res, result, okData) {
+ if (result.ok) return res.json(okData(result.data))
+ switch (result.status) {
+ case 400:
+ return res.status(400).json({ message: (result.data && result.data.error) || 'The shard rejected that request.' })
+ case 403:
+ return res.status(403).json({
+ message:
+ (result.data && result.data.error) ||
+ 'That action was refused — the target is protected, or the write plane is disabled on the shard.',
+ })
+ case 404:
+ return res.status(404).json({ message: 'No such account or target on the shard.' })
+ case 503:
+ case 504:
+ case 0:
+ return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
+ default:
+ return res.status(502).json({ message: 'Could not reach the shard.' })
+ }
+}
+
+// POST /admin/shard/kick — disconnect every live session of an account (or serial).
+async function kick(req, res) {
+ const { account, serial } = req.body
+ const actor = req.user.username
+ try {
+ const result = await uoLinkClient.adminKick({ actor, account, serial })
+ if (result.ok) await activity.log({ req, action: 'shard.kick', detail: { account, serial } })
+ return relay(res, result, (d) => d || { ok: true })
+ } catch (err) {
+ log.error('shardOps.kick', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// POST /admin/shard/ban — ban an account (works offline); durationSec 0/absent = indefinite.
+async function ban(req, res) {
+ const { account, serial, durationSec, reason } = req.body
+ const actor = req.user.username
+ try {
+ const result = await uoLinkClient.adminBan({ actor, account, serial, durationSec, reason })
+ if (result.ok) await activity.log({ req, action: 'shard.ban', detail: { account, serial, durationSec, reason } })
+ return relay(res, result, (d) => d || { ok: true })
+ } catch (err) {
+ log.error('shardOps.ban', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// POST /admin/shard/unban — clear an account's ban.
+async function unban(req, res) {
+ const { account } = req.body
+ const actor = req.user.username
+ try {
+ const result = await uoLinkClient.adminUnban({ actor, account })
+ if (result.ok) await activity.log({ req, action: 'shard.unban', detail: { account } })
+ return relay(res, result, (d) => d || { ok: true })
+ } catch (err) {
+ log.error('shardOps.unban', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// POST /admin/shard/broadcast — a system message to everyone online.
+async function broadcast(req, res) {
+ const { text, hue } = req.body
+ const actor = req.user.username
+ try {
+ const result = await uoLinkClient.adminBroadcast({ actor, text, hue })
+ if (result.ok) await activity.log({ req, action: 'shard.broadcast', detail: { text } })
+ return relay(res, result, (d) => d || { ok: true })
+ } catch (err) {
+ log.error('shardOps.broadcast', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// GET /admin/shard/pages — the open help-page (support) queue, from our store.
+async function listPages(req, res) {
+ try {
+ return res.json(await shardState.listPages())
+ } catch (err) {
+ log.error('shardOps.listPages', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// POST /admin/shard/pages/:id/respond — reply to a player (optionally close).
+async function respondPage(req, res) {
+ const { id } = req.params
+ const { message, close } = req.body
+ try {
+ const result = await uoLinkClient.respondPage(id, { message, close: Boolean(close) })
+ if (result.ok) {
+ await activity.log({ req, action: 'shard.page.respond', detail: { pageId: id, close: Boolean(close) } })
+ // Close removes the page from the queue; reflect it locally at once (the
+ // page.closed event will confirm it, but the UI shouldn't wait a poll cycle).
+ if (close) await shardState.removePage(id).catch(() => {})
+ }
+ return relay(res, result, (d) => d || { ok: true })
+ } catch (err) {
+ log.error('shardOps.respondPage', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// POST /admin/shard/pages/:id/close — resolve a page without a reply.
+async function closePage(req, res) {
+ const { id } = req.params
+ try {
+ const result = await uoLinkClient.closePage(id)
+ if (result.ok) {
+ await activity.log({ req, action: 'shard.page.close', detail: { pageId: id } })
+ await shardState.removePage(id).catch(() => {})
+ }
+ return relay(res, result, (d) => d || { ok: true })
+ } catch (err) {
+ log.error('shardOps.closePage', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+// GET /admin/shard/audit — recent moderation audit events (admin.audit), from the
+// ingested event log. Seeds the live audit log the panel keeps current over SSE.
+async function listAudit(req, res) {
+ try {
+ const limit = req.query.limit
+ return res.json(await shardEvents.list({ kind: 'admin.audit', limit }))
+ } catch (err) {
+ log.error('shardOps.listAudit', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+module.exports = { kick, ban, unban, broadcast, listPages, respondPage, closePage, listAudit }
diff --git a/server/src/router/v1/public/public.routes.js b/server/src/router/v1/public/public.routes.js
index 992a7a1..846f44c 100644
--- a/server/src/router/v1/public/public.routes.js
+++ b/server/src/router/v1/public/public.routes.js
@@ -176,6 +176,14 @@ publicRouter.get(
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
shard.getIdoc,
)
+publicRouter.get(
+ '/shard/champs',
+ // #swagger.tags = ['Public · Shard']
+ // #swagger.summary = 'Current champion-spawn board (all categories)'
+ // #swagger.description = 'The live board of every champion / mini-champ / sea-boss spawn. Update in place via the champ.update / champ.remove frames on /shard/stream.'
+ /* #swagger.responses[200] = { description: 'Champion spawns, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
+ shard.getChamps,
+)
publicRouter.get(
'/shard/stream',
// #swagger.tags = ['Public · Shard']
diff --git a/server/src/router/v1/public/shard.controller.js b/server/src/router/v1/public/shard.controller.js
index a62b8f7..6967007 100644
--- a/server/src/router/v1/public/shard.controller.js
+++ b/server/src/router/v1/public/shard.controller.js
@@ -92,9 +92,21 @@ async function getIdoc(req, res) {
}
}
+// GET /public/shard/champs — the current champion-spawn board (all categories).
+// Served from our own store; live deltas (champ.update / champ.remove) arrive on
+// the public SSE stream so the page can update in place.
+async function getChamps(req, res) {
+ try {
+ return res.json(await shardState.listChamps())
+ } catch (err) {
+ log.error('shard.getChamps', err)
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
// GET /public/shard/stream — public live-event SSE channel (safe kinds only).
function stream(req, res) {
broadcast.subscribe(req, res, 'public')
}
-module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, stream }
+module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, getChamps, stream }
diff --git a/server/src/utils/shardBroadcast.js b/server/src/utils/shardBroadcast.js
index c12637f..c453efc 100644
--- a/server/src/utils/shardBroadcast.js
+++ b/server/src/utils/shardBroadcast.js
@@ -33,6 +33,9 @@ const PUBLIC_KINDS = new Set([
'server.hello',
'server.shutdown',
'server.crashed',
+ // Champion-spawn board deltas — the public Champions page renders these live.
+ 'champ.update',
+ 'champ.remove',
])
// Open response streams per channel.
diff --git a/server/src/utils/shardIngest.js b/server/src/utils/shardIngest.js
index 8b82c4a..6a6c782 100644
--- a/server/src/utils/shardIngest.js
+++ b/server/src/utils/shardIngest.js
@@ -33,6 +33,7 @@ const LOGGED_KINDS = new Set([
'karma.change',
'audit.set',
'audit.command',
+ 'admin.audit',
'cheat.fastwalk',
'link.request',
'server.hello',
@@ -132,6 +133,19 @@ async function applyStateChange(event, deps) {
lastRefreshed: event.lastRefreshed,
})
return
+ case 'champ.update':
+ await shardState.upsertChamp(event)
+ return
+ case 'champ.remove':
+ await shardState.removeChamp(event.serial)
+ return
+ case 'page.new':
+ case 'page.updated':
+ await shardState.upsertPage(event)
+ return
+ case 'page.closed':
+ await shardState.removePage(event.pageId)
+ return
default:
// No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and
// broadcasting still happen in ingest().
diff --git a/server/src/utils/uoLinkClient.js b/server/src/utils/uoLinkClient.js
index b925ace..9d487fe 100644
--- a/server/src/utils/uoLinkClient.js
+++ b/server/src/utils/uoLinkClient.js
@@ -100,6 +100,10 @@ function getHistory({ kind, limit = 100 } = {}) {
return call(`/history${qs ? `?${qs}` : ''}`)
}
const getEconomy = (limit = 100) => call(`/economy?limit=${encodeURIComponent(limit)}`)
+// Live board / queue projections — snapshotted on WS (re)connect and served from
+// our own store thereafter.
+const getChamps = () => call('/champs')
+const getPages = () => call('/pages')
// ── Commands ──────────────────────────────────────────────────────────────
const confirmLink = (code, websiteUserId) =>
@@ -109,6 +113,24 @@ const postTownCrier = ({ id, lines, durationSec }) =>
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
+// ── Staff write plane (§6) ─────────────────────────────────────────────────
+// Every call carries `actor` — the website username of the staff member — set by
+// the controller from the session, NEVER from the browser. The shard records it
+// for attribution and echoes an admin.audit event back over the WS feed.
+const adminKick = ({ actor, account, serial }) =>
+ call('/admin/kick', { method: 'POST', body: { actor, account, serial } })
+const adminBan = ({ actor, account, serial, durationSec, reason }) =>
+ call('/admin/ban', { method: 'POST', body: { actor, account, serial, durationSec, reason } })
+const adminUnban = ({ actor, account }) =>
+ call('/admin/unban', { method: 'POST', body: { actor, account } })
+const adminBroadcast = ({ actor, text, hue }) =>
+ call('/admin/broadcast', { method: 'POST', body: { actor, text, hue } })
+
+// ── Help-page (support) queue commands (§6) ────────────────────────────────
+const respondPage = (pageId, { message, close }) =>
+ call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
+const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' })
+
module.exports = {
invalidateConfig,
health,
@@ -118,8 +140,16 @@ module.exports = {
getVendors,
getHistory,
getEconomy,
+ getChamps,
+ getPages,
confirmLink,
linkLookup,
postTownCrier,
deleteTownCrier,
+ adminKick,
+ adminBan,
+ adminUnban,
+ adminBroadcast,
+ respondPage,
+ closePage,
}
diff --git a/server/src/utils/uoLinkSocket.js b/server/src/utils/uoLinkSocket.js
index e82370e..1e7e2a3 100644
--- a/server/src/utils/uoLinkSocket.js
+++ b/server/src/utils/uoLinkSocket.js
@@ -16,6 +16,7 @@ const WebSocket = require('ws')
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const uoLinkClient = require('./uoLinkClient')
const shardIngest = require('./shardIngest')
+const shardState = require('../model/shardState/shardState.model')
const log = require('./logger')('uo-link-socket')
const BACKOFF_MIN_MS = 1000
@@ -59,6 +60,21 @@ async function backfill() {
const series = [...eco.data.series].reverse()
for (const ev of series) await shardIngest.ingest(ev, { fromBackfill: true })
}
+
+ // Champ board + help-page queue have no replay stream — snapshot the
+ // authoritative current state directly (the sidecar guide's advice for both),
+ // reconciling our tables to it so a stale row from before a disconnect can't
+ // linger. Live champ.*/page.* deltas keep them fresh thereafter.
+ const champs = await uoLinkClient.getChamps()
+ if (champs.ok && champs.data && Array.isArray(champs.data.spawns)) {
+ await shardState.replaceChamps(champs.data.spawns)
+ log.info('snapshotted champ board from /champs', { count: champs.data.spawns.length })
+ }
+ const pages = await uoLinkClient.getPages()
+ if (pages.ok && pages.data && Array.isArray(pages.data.pages)) {
+ await shardState.replacePages(pages.data.pages)
+ log.info('snapshotted help-page queue from /pages', { count: pages.data.pages.length })
+ }
} catch (err) {
log.warn('backfill failed (continuing on live feed)', { message: err.message })
}
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index a9090cd..34bb1ba 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -1464,6 +1464,34 @@
}
}
},
+ "/api/v1/public/shard/champs": {
+ "get": {
+ "tags": [
+ "Public · Shard"
+ ],
+ "summary": "Current champion-spawn board (all categories)",
+ "description": "The live board of every champion / mini-champ / sea-boss spawn. Update in place via the champ.update / champ.remove frames on /shard/stream.",
+ "responses": {
+ "200": {
+ "description": "Champion spawns, ordered by name",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
"/api/v1/public/shard/stream": {
"get": {
"tags": [
@@ -2119,6 +2147,456 @@
]
}
},
+ "/api/v1/admin/shard/kick": {
+ "post": {
+ "tags": [
+ "Admin · Shard"
+ ],
+ "summary": "Kick every live session of an account (admin/moderator)",
+ "description": "",
+ "responses": {
+ "200": {
+ "description": "Kicked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Protected target or write plane disabled",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "account": {
+ "type": "string"
+ },
+ "serial": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/admin/shard/ban": {
+ "post": {
+ "tags": [
+ "Admin · Shard"
+ ],
+ "summary": "Ban an account, timed or indefinite (admin/moderator)",
+ "description": "",
+ "responses": {
+ "200": {
+ "description": "Banned",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Protected target or write plane disabled",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "account": {
+ "type": "string"
+ },
+ "serial": {
+ "type": "string"
+ },
+ "durationSec": {
+ "type": "integer"
+ },
+ "reason": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/admin/shard/unban": {
+ "post": {
+ "tags": [
+ "Admin · Shard"
+ ],
+ "summary": "Clear an account ban (admin/moderator)",
+ "description": "",
+ "responses": {
+ "200": {
+ "description": "Unbanned",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "account": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "account"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/admin/shard/broadcast": {
+ "post": {
+ "tags": [
+ "Admin · Shard"
+ ],
+ "summary": "Broadcast a system message to everyone online (admin/moderator)",
+ "description": "",
+ "responses": {
+ "200": {
+ "description": "Broadcast",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "text": {
+ "type": "string"
+ },
+ "hue": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "text"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/admin/shard/pages": {
+ "get": {
+ "tags": [
+ "Admin · Shard"
+ ],
+ "summary": "Open help-page (support) queue (admin/moderator)",
+ "description": "",
+ "responses": {
+ "200": {
+ "description": "Open pages",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/shard/pages/{id}/respond": {
+ "post": {
+ "tags": [
+ "Admin · Shard"
+ ],
+ "summary": "Reply to a help page, optionally closing it (admin/moderator)",
+ "description": "",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Page id (sender serial)."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Responded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "404": {
+ "description": "Unknown page",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ },
+ "close": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "message"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/admin/shard/pages/{id}/close": {
+ "post": {
+ "tags": [
+ "Admin · Shard"
+ ],
+ "summary": "Resolve a help page without a reply (admin/moderator)",
+ "description": "",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Page id (sender serial)."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Closed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/shard/audit": {
+ "get": {
+ "tags": [
+ "Admin · Shard"
+ ],
+ "summary": "Recent in-game moderation audit events (admin/moderator)",
+ "description": "",
+ "parameters": [
+ {
+ "name": "limit",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "admin.audit events, newest first",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ShardEvent"
+ }
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"/api/v1/admin/dashboard": {
"get": {
"tags": [
@@ -2819,6 +3297,143 @@
}
}
},
+ "/api/v1/admin/posts/{id}/announce": {
+ "get": {
+ "tags": [
+ "Admin · Posts"
+ ],
+ "summary": "Get the announcement pipeline status for a post",
+ "description": "",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "Post id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The announce job for the post, or null if never announced",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "nullable": true,
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Not authenticated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/posts/{id}/announce/retry": {
+ "post": {
+ "tags": [
+ "Admin · Posts"
+ ],
+ "summary": "Retry one announcement delivery leg (town crier or Discord)",
+ "description": "",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "Post id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Updated announce job",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "404": {
+ "description": "No announcement job for this post",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "leg": {
+ "type": "string",
+ "enum": [
+ "towncrier",
+ "discord"
+ ]
+ }
+ },
+ "required": [
+ "leg"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/admin/wiki/categories": {
"get": {
"tags": [
@@ -6023,6 +6638,298 @@
"bearerAuth": []
}
]
+ },
+ "get": {
+ "tags": [
+ "Admin · Users"
+ ],
+ "summary": "Get a single user (admin only)",
+ "description": "",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "User id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The user",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/users/{id}/shard/accounts": {
+ "get": {
+ "tags": [
+ "Admin · Users"
+ ],
+ "summary": "A user’s linked game accounts (admin only)",
+ "description": "",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "User id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Linked accounts",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ShardLink"
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/users/{id}/shard/sales": {
+ "get": {
+ "tags": [
+ "Admin · Users"
+ ],
+ "summary": "Recent vendor sales on a user’s accounts (admin only)",
+ "description": "",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "User id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Vendor sales",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ShardVendorSale"
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/users/{id}/shard/houses": {
+ "get": {
+ "tags": [
+ "Admin · Users"
+ ],
+ "summary": "Houses owned by a user’s accounts (admin only)",
+ "description": "",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "User id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Houses (IDOC first)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/users/{id}/shard/online": {
+ "get": {
+ "tags": [
+ "Admin · Users"
+ ],
+ "summary": "A user’s characters currently online (admin only)",
+ "description": "",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "User id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Online characters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
}
},
"/api/v1/admin/uo-link/config": {
diff --git a/server/test/shardIngest.champsPages.test.js b/server/test/shardIngest.champsPages.test.js
new file mode 100644
index 0000000..875ded8
--- /dev/null
+++ b/server/test/shardIngest.champsPages.test.js
@@ -0,0 +1,69 @@
+const { test, beforeEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const shardIngest = require('../src/utils/shardIngest')
+
+// Build a set of stub deps that record the champ/page/state calls the dispatcher
+// makes, plus a spy shardEvents.append and broadcast. Only the methods the tested
+// kinds touch need to be real; the rest are no-op async so ingest() never throws.
+function makeDeps() {
+ const calls = { champUpsert: [], champRemove: [], pageUpsert: [], pageRemove: [], appended: [], broadcast: [] }
+ const noop = async () => {}
+ return {
+ calls,
+ shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
+ shardState: {
+ upsertChamp: async (ev) => { calls.champUpsert.push(ev) },
+ removeChamp: async (serial) => { calls.champRemove.push(serial) },
+ upsertPage: async (ev) => { calls.pageUpsert.push(ev) },
+ removePage: async (id) => { calls.pageRemove.push(id) },
+ // Unused by these kinds but present so any stray routing is a no-op.
+ clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop, addEconomySample: noop,
+ },
+ uoLinkConfig: { recordStatus: noop },
+ broadcast: (ev) => { calls.broadcast.push(ev) },
+ log: { warn() {}, info() {}, error() {} },
+ }
+}
+
+beforeEach(() => shardIngest.reset())
+
+test('champ.update routes to shardState.upsertChamp and is not written to the event log', async () => {
+ const deps = makeDeps()
+ const ev = { kind: 'champ.update', serial: '0x1', category: 'champion', name: 'Abyss', status: 'active', t: 1 }
+ const r = await shardIngest.ingest(ev, deps)
+ assert.equal(deps.calls.champUpsert.length, 1)
+ assert.equal(deps.calls.champUpsert[0].serial, '0x1')
+ assert.equal(r.logged, false) // champ.* is state-only, not appended to shard_events
+ assert.equal(deps.calls.appended.length, 0)
+ assert.equal(deps.calls.broadcast.length, 1) // still broadcast live
+})
+
+test('champ.remove routes to shardState.removeChamp', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest({ kind: 'champ.remove', serial: '0x2', t: 2 }, deps)
+ assert.deepEqual(deps.calls.champRemove, ['0x2'])
+})
+
+test('page.new and page.updated upsert the page; page.closed removes it', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest({ kind: 'page.new', pageId: '0x24C', type: 'Bug', sender: { name: 'Al' }, t: 3 }, deps)
+ await shardIngest.ingest({ kind: 'page.updated', pageId: '0x24C', handled: true, t: 4 }, deps)
+ await shardIngest.ingest({ kind: 'page.closed', pageId: '0x24C', t: 5 }, deps)
+ assert.equal(deps.calls.pageUpsert.length, 2)
+ assert.deepEqual(deps.calls.pageRemove, ['0x24C'])
+})
+
+test('admin.audit is appended to the event log (moderation history)', async () => {
+ const deps = makeDeps()
+ const r = await shardIngest.ingest({ kind: 'admin.audit', action: 'ban', actor: 'web:jane', target: 'griefer', t: 6 }, deps)
+ assert.equal(r.logged, true)
+ assert.equal(deps.calls.appended.length, 1)
+ assert.equal(deps.calls.appended[0].kind, 'admin.audit')
+})
+
+test('champ.remove without a serial is a harmless no-op', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest({ kind: 'champ.remove', t: 7 }, deps)
+ assert.deepEqual(deps.calls.champRemove, [undefined])
+})