feat(client): the whole client half (phase 3, slice 3)

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>
This commit is contained in:
2026-08-11 18:00:25 -05:00
parent 050a02c21d
commit 28f4b9afe2
47 changed files with 6031 additions and 70 deletions

View File

@@ -0,0 +1,199 @@
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>
)
}