feat(shard): admin write plane, help-page queue, and public champion board
Wire up the three uo-link sidecar surfaces that weren't integrated yet. Champion spawns - Ingest champ.update/champ.remove into a new shard_champs table (served from our own store, like online/houses); public /site/champs board with a nav link, live via the existing SSE feed (champ.* added to the public allowlist). Staff write plane (admin + moderator) - kick / ban / unban / broadcast via /admin/shard/*; actor is stamped server-side from the session, never the browser. Sidecar status codes mapped (403 disabled/ protected, 404 unknown, 503/504 transient). admin.audit events are logged and surfaced at /admin/shard/audit. - New admin "In-Game Ops" view (/admin/shard-ops), plus per-account Kick/Ban/Unban on the user-detail and character views (ShardAccountActions, self-gated to staff). Help-page (support) queue - Ingest page.new/updated/closed into a new shard_pages table; respond/close via /admin/shard/pages/*. Champ board and page queue are snapshotted from the sidecar's /champs and /pages on every WS (re)connect (guarded so a failed call never wipes local state). Verified live end-to-end against MariaDB + the Rust sidecar + ServUO; unit tests cover ingest routing (shardIngest.champsPages.test.js). Swagger regenerated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user