Merge pull request 'feat(shard): admin write plane, help-page queue, and public champion board' (#58) from feature/shard-admin-champs into main
All checks were successful
Build container images / build (push) Successful in 1m30s

Reviewed-on: UOM/website#58
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-14 18:46:50 +00:00
25 changed files with 2188 additions and 8 deletions

View File

@@ -18,6 +18,7 @@ import About from './routes/public/About.jsx'
import Status from './routes/public/Status.jsx' import Status from './routes/public/Status.jsx'
import Shard from './routes/public/Shard.jsx' import Shard from './routes/public/Shard.jsx'
import ShardActivity from './routes/public/ShardActivity.jsx' import ShardActivity from './routes/public/ShardActivity.jsx'
import ChampSpawns from './routes/public/ChampSpawns.jsx'
import Wiki from './routes/wiki/Wiki.jsx' import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx' import WikiArticle from './routes/wiki/WikiArticle.jsx'
import CmsPage from './routes/public/CmsPage.jsx' import CmsPage from './routes/public/CmsPage.jsx'
@@ -36,6 +37,7 @@ import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx' import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx' import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
import ShardAdmin from './routes/admin/views/ShardAdmin.jsx' import ShardAdmin from './routes/admin/views/ShardAdmin.jsx'
import ShardOps from './routes/admin/views/ShardOps.jsx'
import AdminCharacters from './routes/admin/views/AdminCharacters.jsx' import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
import AdminCharacter from './routes/admin/views/AdminCharacter.jsx' import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx' import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
@@ -77,6 +79,7 @@ export default function App() {
<Route path="/site/status" element={<Status />} /> <Route path="/site/status" element={<Status />} />
<Route path="/site/shard" element={<Shard />} /> <Route path="/site/shard" element={<Shard />} />
<Route path="/site/shard/activity" element={<ShardActivity />} /> <Route path="/site/shard/activity" element={<ShardActivity />} />
<Route path="/site/champs" element={<ChampSpawns />} />
<Route path="/wiki" element={<Wiki />} /> <Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} /> <Route path="/wiki/:slug" element={<WikiArticle />} />
{/* CMS pages: top-level /:slug, matched only after the named routes {/* CMS pages: top-level /:slug, matched only after the named routes
@@ -121,6 +124,14 @@ export default function App() {
<Route path="bot-activity" element={<BotActivityAdmin />} /> <Route path="bot-activity" element={<BotActivityAdmin />} />
<Route path="discord-bot" element={<DiscordBotAdmin />} /> <Route path="discord-bot" element={<DiscordBotAdmin />} />
<Route path="shard" element={<ShardAdmin />} /> <Route path="shard" element={<ShardAdmin />} />
<Route
path="shard-ops"
element={
<RoleGate roles={['admin', 'moderator']}>
<ShardOps />
</RoleGate>
}
/>
<Route path="characters" element={<AdminCharacters />} /> <Route path="characters" element={<AdminCharacters />} />
<Route path="characters/:serial" element={<AdminCharacter />} /> <Route path="characters/:serial" element={<AdminCharacter />} />
<Route path="auth-providers" element={<AuthProvidersAdmin />} /> <Route path="auth-providers" element={<AuthProvidersAdmin />} />

View File

@@ -94,6 +94,7 @@ export const api = {
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`), economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
online: () => req('/public/shard/online'), online: () => req('/public/shard/online'),
idoc: () => req('/public/shard/idoc'), idoc: () => req('/public/shard/idoc'),
champs: () => req('/public/shard/champs'),
}, },
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is // 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 // 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 }), postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }), 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) ----- // ----- Email delivery / Gmail OAuth2 (admin only) -----
getEmailConfig: () => req('/admin/email/config'), getEmailConfig: () => req('/admin/email/config'),
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }), saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),

View File

@@ -1,6 +1,12 @@
// Reusable character-sheet renderer for the char.profile shape returned by // Reusable character-sheet renderer for the char.profile shape returned by
// /public/shard/char/:serial. Presentational only — the parent handles loading // /public/shard/char/:serial. Presentational only — the parent handles loading
// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles). // 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' } 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 if (!char) return null
const stats = char.stats || {} const stats = char.stats || {}
const resist = stats.resist || {} const resist = stats.resist || {}
@@ -58,6 +64,14 @@ export default function CharacterSheet({ char }) {
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span> <span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
</div> </div>
{/* Staff moderation for this character's account (self-gates to staff). */}
{moderation && char.acct && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
<span className="sans dim" style={{ fontSize: '0.76rem' }}>Account <strong style={{ color: 'var(--ink)' }}>{char.acct}</strong></span>
<ShardAccountActions account={char.acct} />
</div>
)}
{/* Core stats */} {/* Core stats */}
<section> <section>
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div> <div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Loading, ErrorState } from './PageState.jsx' import { Loading, ErrorState } from './PageState.jsx'
import ShardAccountActions from './ShardAccountActions.jsx'
// Shared game-account linking + character roster, used by both the player portal // Shared game-account linking + character roster, used by both the player portal
// (/player) and the staff account page (/admin/account). `scope` is the api // (/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 [accounts, setAccounts] = useState(null)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -156,6 +157,7 @@ export default function GameAccounts({ scope, charTo, readOnly = false }) {
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}> <div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
{a.account} {a.account}
</div> </div>
{moderation && <ShardAccountActions account={a.account} style={{ marginBottom: 12 }} />}
<AccountRoster scope={scope} account={a.account} charTo={charTo} /> <AccountRoster scope={scope} account={a.account} charTo={charTo} />
</section> </section>
))} ))}

View File

@@ -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 (
<div className="sans" style={{ display: 'flex', flexDirection: 'column', gap: 8, ...style }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
<button onClick={kick} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'kick' ? '…' : 'Kick'}</button>
<button onClick={() => { setBanOpen((v) => !v); setOk(''); setErr('') }} disabled={!!busy} className="btn btn-sq" style={{ ...btn, borderColor: '#d98b84', color: '#d98b84' }}>Ban</button>
<button onClick={unban} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'unban' ? '…' : 'Unban'}</button>
{ok && <span style={{ color: '#7fd0a4', fontSize: '0.8rem' }}>{ok}</span>}
{err && <span style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
</div>
{banOpen && (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 8, padding: '10px 12px', border: '1px solid var(--line)', borderRadius: 8, background: 'rgba(217,139,132,0.06)' }}>
<label style={{ display: 'block' }}>
<span className="field-label">Duration (sec, blank = permanent)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" style={{ maxWidth: 150 }} />
</label>
<label style={{ display: 'block', flex: 1, minWidth: 160 }}>
<span className="field-label">Reason (optional)</span>
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
</label>
<button onClick={ban} disabled={busy === 'ban'} className="btn btn-primary btn-sq" style={{ borderColor: '#d98b84', background: '#d98b84', ...btn }}>
{busy === 'ban' ? 'Banning…' : `Confirm ban ${account}`}
</button>
</div>
)}
</div>
)
}

View File

@@ -12,6 +12,7 @@ const NAV = [
{ label: 'Newsletter', to: '/site/newsletter' }, { label: 'Newsletter', to: '/site/newsletter' },
{ label: 'Wiki', to: '/wiki' }, { label: 'Wiki', to: '/wiki' },
{ label: 'Shard', to: '/site/shard' }, { label: 'Shard', to: '/site/shard' },
{ label: 'Champions', to: '/site/champs' },
{ label: 'About', to: '/site/about' }, { label: 'About', to: '/site/about' },
] ]

View File

@@ -45,6 +45,24 @@ export function describe(ev) {
return 'Shard shut down' return 'Shard shut down'
case 'server.crashed': case 'server.crashed':
return `Shard crashed${p.error ? `: ${p.error}` : ''}` 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) // Staff / sensitive (admin channel only)
case 'audit.set': case 'audit.set':
return `${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old}${p.new})` return `${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old}${p.new})`

View File

@@ -63,6 +63,7 @@ const NAV = [
title: 'Moderation', title: 'Moderation',
items: [ items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] }, { 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/wiki': 'Wiki Pages',
'/admin/hero': 'Hero Editor', '/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation', '/admin/moderation': 'Moderation',
'/admin/shard-ops': 'In-Game Ops',
'/admin/settings': 'Site Settings', '/admin/settings': 'Site Settings',
'/admin/activity': 'Activity Log', '/admin/activity': 'Activity Log',
'/admin/bot-activity': 'Web Bot Activity', '/admin/bot-activity': 'Web Bot Activity',
@@ -136,11 +138,13 @@ export default function AdminLayout() {
const wide = location.pathname === '/admin/hero' const wide = location.pathname === '/admin/hero'
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)' 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 isModerator = user?.role === 'moderator'
const MOD_PATHS = ['/admin/moderation', '/admin/shard-ops', '/admin/account']
const visible = (item) => { const visible = (item) => {
if (item.roles && !item.roles.includes(user?.role)) return false 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 return true
} }
// Drop items the current role can't see, then drop any now-empty group so an // 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(() => { useEffect(() => {
if (!isModerator) return if (!isModerator) return
const p = location.pathname 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 }) navigate('/admin/moderation', { replace: true })
} }
}, [isModerator, location.pathname, navigate]) }, [isModerator, location.pathname, navigate])

View File

@@ -23,7 +23,7 @@ export default function AdminCharacter() {
{restarting && <ErrorState message="The game server is restarting — try again shortly." />} {restarting && <ErrorState message="The game server is restarting — try again shortly." />}
{forbidden && <ErrorState message="That character is not on an account linked to you." />} {forbidden && <ErrorState message="That character is not on an account linked to you." />}
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />} {error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
{!loading && !error && data && <CharacterSheet char={data} />} {!loading && !error && data && <CharacterSheet char={data} moderation />}
</div> </div>
) )
} }

View File

@@ -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 <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{ok}</span>
if (err) return <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>
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 (
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Broadcast</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
A system message shown to everyone online right now.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Message</span>
<input type="text" value={text} onChange={(e) => setText(e.target.value)} className="input" maxLength={300} placeholder="Server restart in 5 minutes" autoComplete="off" />
</label>
<label style={{ display: 'block', maxWidth: 140 }}>
<span className="field-label">Hue (optional)</span>
<input type="number" value={hue} onChange={(e) => setHue(e.target.value)} className="input" min={0} max={3000} placeholder="53" />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={send} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Sending…' : 'Broadcast'}</button>
<Flash ok={ok} err={err} />
</div>
</section>
)
}
// ── 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 (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Account actions</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
Kick, ban or unban a game account. Bans work even if the account is offline; the shard refuses to act on staff at or above co-owner.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Account</span>
<input type="text" value={account} onChange={(e) => setAccount(e.target.value)} className="input" placeholder="griefer42" autoComplete="off" style={{ maxWidth: 260 }} />
</label>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label style={{ display: 'block', maxWidth: 200 }}>
<span className="field-label">Ban duration (seconds, blank = permanent)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" />
</label>
<label style={{ display: 'block', flex: 1, minWidth: 200 }}>
<span className="field-label">Ban reason (optional)</span>
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
</label>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={kick} disabled={!!busy} className="btn btn-sq">{busy === 'kick' ? 'Kicking…' : 'Kick'}</button>
<button onClick={ban} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'ban' ? 'Banning…' : 'Ban'}</button>
<button onClick={unban} disabled={!!busy} className="btn btn-sq">{busy === 'unban' ? 'Unbanning…' : 'Unban'}</button>
<Flash ok={ok} err={err} />
</div>
</section>
)
}
// ── 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 (
<div className="panel" style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ minWidth: 0 }}>
<span className="sans" style={{ fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)' }}>{page.type || 'Page'}</span>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{page.sender?.name || page.pageId}
{page.handled && <span className="dim" style={{ fontSize: '0.72rem' }}> · claimed{page.handler ? ` by ${page.handler}` : ''}</span>}
</div>
</div>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{page.sentMs ? ago(page.sentMs) : ''}</span>
</div>
{page.message && <p className="sans" style={{ margin: 0, color: 'var(--ink)', fontSize: '0.88rem', lineHeight: 1.5 }}>{page.message}</p>}
<div className="sans dim" style={{ fontSize: '0.72rem' }}>
{page.map || '—'}{page.x != null ? ` (${page.x}, ${page.y})` : ''}
</div>
<textarea value={message} onChange={(e) => setMessage(e.target.value)} className="input" rows={2} placeholder="A GM is on the way." style={{ resize: 'vertical' }} />
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={() => respond(false)} disabled={!!busy} className="btn btn-sq">{busy === 'respond' ? 'Sending…' : 'Reply'}</button>
<button onClick={() => respond(true)} disabled={!!busy} className="btn btn-primary btn-sq">{busy === 'respond-close' ? 'Sending…' : 'Reply & close'}</button>
<button onClick={close} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'close' ? 'Closing…' : 'Close'}</button>
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
</div>
</div>
)
}
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 (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Support queue</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
Open help pages from players. A reply reaches them in game (or on their next login).
</p>
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>}
{pages == null ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading</p>
) : pages.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>The queue is empty.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{pages.map((p) => <PageRow key={p.pageId} page={p} onDone={load} />)}
</div>
)}
</section>
)
}
// ── 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 (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)', marginBottom: 12 }}>Audit log</h3>
{rows.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No moderation actions recorded yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 320, overflowY: 'auto' }}>
{rows.map((e) => (
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
</li>
))}
</ul>
)}
</section>
)
}
export default function ShardOps() {
return (
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 22 }}>
<Broadcast />
<AccountActions />
<SupportQueue />
<AuditLog />
</section>
)
}

View File

@@ -101,7 +101,7 @@ function ShardSections({ scope }) {
<> <>
<CharacterStats scope={scope} /> <CharacterStats scope={scope} />
<SectionTitle>Linked accounts &amp; characters</SectionTitle> <SectionTitle>Linked accounts &amp; characters</SectionTitle>
<GameAccounts scope={scope} readOnly charTo={(serial) => `/admin/characters/${serial}`} /> <GameAccounts scope={scope} readOnly moderation charTo={(serial) => `/admin/characters/${serial}`} />
<OnlineNow scope={scope} /> <OnlineNow scope={scope} />
<Houses scope={scope} /> <Houses scope={scope} />
<VendorSales fetchSales={scope.sales} /> <VendorSales fetchSales={scope.sales} />

View File

@@ -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 (
<span
className="sans"
style={{
flex: 'none',
fontSize: '0.68rem',
letterSpacing: '0.08em',
textTransform: 'uppercase',
padding: '3px 9px',
borderRadius: 999,
color: s.fg,
background: s.bg,
border: `1px solid ${s.border}`,
}}
>
{s.label}
</span>
)
}
// 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 (
<div style={{ height: 6, borderRadius: 4, background: 'rgba(255,255,255,0.07)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: tone, borderRadius: 4 }} />
</div>
)
}
// 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 (
<>
<div className="sans" style={line}>
<span>{s.boss || s.type}</span>
{s.hitsMax != null && <span>{Number(s.hits).toLocaleString()} / {Number(s.hitsMax).toLocaleString()} hp</span>}
</div>
<div style={{ marginTop: 6 }}><Meter value={s.hits} max={s.hitsMax} tone="#d9736f" /></div>
</>
)
}
if (s.category === 'mini') {
return (
<div className="sans" style={line}>
<span>Level {s.level ?? 0}{s.maxLevel != null ? ` / ${s.maxLevel}` : ''}</span>
<span>{s.status === 'active' ? 'Running' : 'Re-arming'}</span>
</div>
)
}
// champion
return (
<>
<div className="sans" style={line}>
<span>
Level {s.level ?? 0}
{s.bossUp && s.boss ? `${s.boss}` : ''}
</span>
<span>
{s.status === 'cooldown'
? until(s.restartAt) || 'restarting'
: s.status === 'active'
? `${Number(s.kills || 0).toLocaleString()} / ${Number(s.maxKills || 0).toLocaleString()} kills`
: ''}
</span>
</div>
{s.status === 'active' && (
<div style={{ marginTop: 6 }}><Meter value={s.kills} max={s.maxKills} /></div>
)}
</>
)
}
function ChampCard({ s }) {
return (
<div className="panel" style={{ padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<strong className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s.name || s.type || 'Spawn'}
</strong>
<StatusBadge status={s.status} />
</div>
<ChampDetail s={s} />
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.74rem' }}>
{s.map || '—'}{s.x != null ? ` (${s.x}, ${s.y})` : ''}
</div>
</div>
)
}
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 (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader eyebrow="Live" title="Champion spawns" lead="Every altar, mini-champ and sea boss across the shard, updating in real time." />
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the champion board right now." />}
{!loading && !error && (
<>
{board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No champion spawns are being tracked right now.</p>
</section>
) : (
<>
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', marginTop: -12, marginBottom: 24 }}>
{activeCount} active · {board.length} tracked
</p>
{SECTIONS.map((sec) => {
const rows = byCategory(sec.id)
if (rows.length === 0) return null
return (
<section key={sec.id} style={{ marginBottom: 28 }}>
<div style={{ marginBottom: 12 }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.1rem', color: 'var(--head)' }}>{sec.title}</h2>
<p className="sans dim" style={{ margin: '2px 0 0', fontSize: '0.8rem' }}>{sec.blurb}</p>
</div>
<div className="grid-2" style={{ gap: 12 }}>
{rows.map((s) => <ChampCard key={s.serial} s={s} />)}
</div>
</section>
)
})}
</>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -383,6 +383,54 @@ CREATE TABLE IF NOT EXISTS shard_account_links (
INDEX idx_shard_links_user (user_id) INDEX idx_shard_links_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) 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 -- 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 -- 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 -- writes them. They live in the same physical database as everything else

View File

@@ -113,6 +113,50 @@ const listHousesByAccounts = (accounts) =>
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 = { module.exports = {
upsertOnline, upsertOnline,
removeOnline, removeOnline,
@@ -127,4 +171,12 @@ module.exports = {
upsertHouse, upsertHouse,
listIdocHouses, listIdocHouses,
listHousesByAccounts, listHousesByAccounts,
upsertChamp,
removeChamp,
clearChamps,
listChamps,
upsertPage,
removePage,
clearPages,
listPages,
} }

View File

@@ -172,6 +172,129 @@ async function listOnlineForAccounts(accounts) {
return rows.map(shapeOnline) 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 = { module.exports = {
upsertOnline, upsertOnline,
setOffline, setOffline,
@@ -186,4 +309,14 @@ module.exports = {
upsertHouse, upsertHouse,
listIdoc, listIdoc,
listHousesForAccounts, listHousesForAccounts,
upsertChamp,
removeChamp,
clearChamps,
listChamps,
replaceChamps,
upsertPage,
removePage,
clearPages,
listPages,
replacePages,
} }

View File

@@ -12,6 +12,7 @@ const authProviders = require('./authProviders.controller')
const discordBot = require('./discordBot.controller') const discordBot = require('./discordBot.controller')
const emailConfig = require('./emailConfig.controller') const emailConfig = require('./emailConfig.controller')
const uoLink = require('./uoLink.controller') const uoLink = require('./uoLink.controller')
const shardOps = require('./shardOps.controller')
const usersShard = require('./usersShard.controller') const usersShard = require('./usersShard.controller')
const selfShard = require('../player/shard.controller') const selfShard = require('../player/shard.controller')
const moderation = require('./moderation.controller') const moderation = require('./moderation.controller')
@@ -181,6 +182,112 @@ adminRouter.get(
selfShard.getSales, 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) ─────────────────────────────── // ── Image uploads (screenshots/gallery) ───────────────────────────────
const UPLOAD_DIR = const UPLOAD_DIR =
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads') process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')

View File

@@ -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 }

View File

@@ -176,6 +176,14 @@ publicRouter.get(
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */ /* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
shard.getIdoc, 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( publicRouter.get(
'/shard/stream', '/shard/stream',
// #swagger.tags = ['Public · Shard'] // #swagger.tags = ['Public · Shard']

View File

@@ -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). // GET /public/shard/stream — public live-event SSE channel (safe kinds only).
function stream(req, res) { function stream(req, res) {
broadcast.subscribe(req, res, 'public') broadcast.subscribe(req, res, 'public')
} }
module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, stream } module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, getChamps, stream }

View File

@@ -33,6 +33,9 @@ const PUBLIC_KINDS = new Set([
'server.hello', 'server.hello',
'server.shutdown', 'server.shutdown',
'server.crashed', 'server.crashed',
// Champion-spawn board deltas — the public Champions page renders these live.
'champ.update',
'champ.remove',
]) ])
// Open response streams per channel. // Open response streams per channel.

View File

@@ -33,6 +33,7 @@ const LOGGED_KINDS = new Set([
'karma.change', 'karma.change',
'audit.set', 'audit.set',
'audit.command', 'audit.command',
'admin.audit',
'cheat.fastwalk', 'cheat.fastwalk',
'link.request', 'link.request',
'server.hello', 'server.hello',
@@ -132,6 +133,19 @@ async function applyStateChange(event, deps) {
lastRefreshed: event.lastRefreshed, lastRefreshed: event.lastRefreshed,
}) })
return 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: default:
// No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and // No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and
// broadcasting still happen in ingest(). // broadcasting still happen in ingest().

View File

@@ -100,6 +100,10 @@ function getHistory({ kind, limit = 100 } = {}) {
return call(`/history${qs ? `?${qs}` : ''}`) return call(`/history${qs ? `?${qs}` : ''}`)
} }
const getEconomy = (limit = 100) => call(`/economy?limit=${encodeURIComponent(limit)}`) 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 ────────────────────────────────────────────────────────────── // ── Commands ──────────────────────────────────────────────────────────────
const confirmLink = (code, websiteUserId) => const confirmLink = (code, websiteUserId) =>
@@ -109,6 +113,24 @@ const postTownCrier = ({ id, lines, durationSec }) =>
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } }) call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }) 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 = { module.exports = {
invalidateConfig, invalidateConfig,
health, health,
@@ -118,8 +140,16 @@ module.exports = {
getVendors, getVendors,
getHistory, getHistory,
getEconomy, getEconomy,
getChamps,
getPages,
confirmLink, confirmLink,
linkLookup, linkLookup,
postTownCrier, postTownCrier,
deleteTownCrier, deleteTownCrier,
adminKick,
adminBan,
adminUnban,
adminBroadcast,
respondPage,
closePage,
} }

View File

@@ -16,6 +16,7 @@ const WebSocket = require('ws')
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model') const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const uoLinkClient = require('./uoLinkClient') const uoLinkClient = require('./uoLinkClient')
const shardIngest = require('./shardIngest') const shardIngest = require('./shardIngest')
const shardState = require('../model/shardState/shardState.model')
const log = require('./logger')('uo-link-socket') const log = require('./logger')('uo-link-socket')
const BACKOFF_MIN_MS = 1000 const BACKOFF_MIN_MS = 1000
@@ -59,6 +60,21 @@ async function backfill() {
const series = [...eco.data.series].reverse() const series = [...eco.data.series].reverse()
for (const ev of series) await shardIngest.ingest(ev, { fromBackfill: true }) 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) { } catch (err) {
log.warn('backfill failed (continuing on live feed)', { message: err.message }) log.warn('backfill failed (continuing on live feed)', { message: err.message })
} }

View File

@@ -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": { "/api/v1/public/shard/stream": {
"get": { "get": {
"tags": [ "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": { "/api/v1/admin/dashboard": {
"get": { "get": {
"tags": [ "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": { "/api/v1/admin/wiki/categories": {
"get": { "get": {
"tags": [ "tags": [
@@ -6023,6 +6638,298 @@
"bearerAuth": [] "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 users 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 users 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 users 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 users 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": { "/api/v1/admin/uo-link/config": {

View File

@@ -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])
})