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 ( {s.label} ) } // 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 (
) } // 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 ( <>
{s.boss || s.type} {s.hitsMax != null && {Number(s.hits).toLocaleString()} / {Number(s.hitsMax).toLocaleString()} hp}
) } if (s.category === 'mini') { return (
Level {s.level ?? 0}{s.maxLevel != null ? ` / ${s.maxLevel}` : ''} {s.status === 'active' ? 'Running' : 'Re-arming'}
) } // champion return ( <>
Level {s.level ?? 0} {s.bossUp && s.boss ? ` — ${s.boss}` : ''} {s.status === 'cooldown' ? until(s.restartAt) || 'restarting' : s.status === 'active' ? `${Number(s.kills || 0).toLocaleString()} / ${Number(s.maxKills || 0).toLocaleString()} kills` : ''}
{s.status === 'active' && (
)} ) } function ChampCard({ s }) { return (
{s.name || s.type || 'Spawn'}
{s.map || '—'}{s.x != null ? ` (${s.x}, ${s.y})` : ''}
) } 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 (
{connected ? 'Live' : 'Offline'}
{loading && } {error && } {!loading && !error && ( <> {board.length === 0 ? (

No champion spawns are being tracked right now.

) : ( <>

{activeCount} active · {board.length} tracked

{SECTIONS.map((sec) => { const rows = byCategory(sec.id) if (rows.length === 0) return null return (

{sec.title}

{sec.blurb}

{rows.map((s) => )}
) })} )} )}
) }