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
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:
@@ -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 />} />
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
84
client/src/components/ShardAccountActions.jsx
Normal file
84
client/src/components/ShardAccountActions.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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})`
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
269
client/src/routes/admin/views/ShardOps.jsx
Normal file
269
client/src/routes/admin/views/ShardOps.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -101,7 +101,7 @@ function ShardSections({ scope }) {
|
|||||||
<>
|
<>
|
||||||
<CharacterStats scope={scope} />
|
<CharacterStats scope={scope} />
|
||||||
<SectionTitle>Linked accounts & characters</SectionTitle>
|
<SectionTitle>Linked accounts & 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} />
|
||||||
|
|||||||
203
client/src/routes/public/ChampSpawns.jsx
Normal file
203
client/src/routes/public/ChampSpawns.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
158
server/src/router/v1/admin/shardOps.controller.js
Normal file
158
server/src/router/v1/admin/shardOps.controller.js
Normal 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 }
|
||||||
@@ -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']
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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().
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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": {
|
"/api/v1/admin/uo-link/config": {
|
||||||
|
|||||||
69
server/test/shardIngest.champsPages.test.js
Normal file
69
server/test/shardIngest.champsPages.test.js
Normal 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])
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user