The 35 files behind twelve public pages, seven admin views, two player views and three core-page extensions, ported onto `window.__rg`. Every one of them imports exactly the seven kit members plus `lib/format.js`, which is the finding §2.7.1 predicted and this confirms. `client/src/core.js` is the port mechanism, and unlike the server's it is a plain read: `window.__rg` is published before any module chunk evaluates, so there is no gap to defer around and a ported component keeps its ordinary import shape. `client/src/api.js` rebuilds the UO namespaces over the request primitive — same URLs, because §1.2 freezes the API surface. SPA paths changed and API paths did not. `/site/shard` is `/uo/shard`, and the admin paths lost their now-redundant `shard-` prefixes (`/admin/uo/ops`), a clean break being the only moment that is free. `shim/rg.js` becomes the single reader of the global, so the "core did not publish its dependencies" message is reachable from whichever module the bundler happens to touch first rather than from whichever one is imported first — a guarantee that used to last until someone sorted the imports. Co-Authored-By: Claude <noreply@anthropic.com>
200 lines
7.8 KiB
JavaScript
200 lines
7.8 KiB
JavaScript
import { useMemo } from 'react'
|
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
|
import api from '../../api.js'
|
|
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.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
|
|
let progress = ''
|
|
if (s.status === 'cooldown') progress = until(s.restartAt) || 'restarting'
|
|
else if (s.status === 'active') {
|
|
progress = `${Number(s.kills || 0).toLocaleString()} / ${Number(s.maxKills || 0).toLocaleString()} kills`
|
|
}
|
|
return (
|
|
<>
|
|
<div className="sans" style={line}>
|
|
<span>
|
|
Level {s.level ?? 0}
|
|
{s.bossUp && s.boss ? ` — ${s.boss}` : ''}
|
|
</span>
|
|
<span>{progress}</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>
|
|
)
|
|
}
|