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>
251 lines
11 KiB
JavaScript
251 lines
11 KiB
JavaScript
import { Link } from 'react-router-dom'
|
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
|
import { describe } from '../../lib/shardEvents.js'
|
|
import { ago } from '../../lib/format.js'
|
|
import api from '../../api.js'
|
|
import PlayersOnline from '../../components/PlayersOnline.jsx'
|
|
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync, useAuth } from '../../core.js'
|
|
|
|
// Flavor line under the online/offline banner: online, configured-but-down, or
|
|
// not configured yet.
|
|
function statusMessage(online, enabled) {
|
|
if (online) return 'The gate to Britannia stands open.'
|
|
if (enabled) return 'The link to the game world is down — checking back automatically.'
|
|
return 'Live shard data is not configured yet.'
|
|
}
|
|
|
|
// ── Gold-supply sparkline ───────────────────────────────────────────────────
|
|
function Sparkline({ series }) {
|
|
if (!series || series.length < 2) return null
|
|
const w = 320
|
|
const h = 56
|
|
const golds = series.map((s) => Number(s.gold) || 0)
|
|
const min = Math.min(...golds)
|
|
const max = Math.max(...golds)
|
|
const span = max - min || 1
|
|
const pts = series
|
|
.map((s, i) => {
|
|
const x = (i / (series.length - 1)) * w
|
|
const y = h - ((Number(s.gold) || 0) - min) / span * h
|
|
return `${x.toFixed(1)},${y.toFixed(1)}`
|
|
})
|
|
.join(' ')
|
|
return (
|
|
<svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} preserveAspectRatio="none" aria-hidden="true">
|
|
<polyline points={pts} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
// ── Stat tile (matches Status.jsx) ──────────────────────────────────────────
|
|
function Stat({ value, label }) {
|
|
return (
|
|
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
|
|
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
|
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 6 }}>
|
|
{label}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function Shard() {
|
|
const { loading, error, data } = useAsync(() =>
|
|
Promise.all([
|
|
api.shard.status(),
|
|
api.shard.idoc(),
|
|
api.shard.economy(60),
|
|
api.shard.online(),
|
|
]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
|
|
)
|
|
const { events, connected } = useShardFeed({ max: 30 })
|
|
const { user } = useAuth()
|
|
// Staff in-game location is privileged: only admins/moderators see it. Players
|
|
// and the public see that staff are online but not where. The server enforces
|
|
// this too (it omits the location fields entirely for non-privileged callers).
|
|
const canSeeLocation = user?.role === 'admin' || user?.role === 'moderator'
|
|
|
|
const status = data?.status
|
|
const online = status?.pluginConnected
|
|
const gold = status?.economy?.gold
|
|
|
|
return (
|
|
<PublicLayout section="website">
|
|
<div className="shell-narrow page-body">
|
|
<PageHeader eyebrow="Live" title="Shard" />
|
|
|
|
{loading && <Loading />}
|
|
{error && <ErrorState message="Could not load shard data right now." />}
|
|
|
|
{!loading && !error && data && (
|
|
<>
|
|
<ConnectionBanner online={online} status={status} />
|
|
|
|
{/* Stat tiles */}
|
|
<section className="grid-2" style={{ gap: 14, marginBottom: 24 }}>
|
|
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
|
|
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
|
</section>
|
|
|
|
{/* Live players-online breakdown (total + region buckets) */}
|
|
<div style={{ marginBottom: 24 }}>
|
|
<PlayersOnline />
|
|
</div>
|
|
|
|
<StaffOnline list={data.online} canSeeLocation={canSeeLocation} />
|
|
|
|
{/* Economy sparkline */}
|
|
{data.economy && data.economy.length > 1 && (
|
|
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 10 }}>
|
|
Gold supply over time
|
|
</div>
|
|
<Sparkline series={data.economy} />
|
|
</section>
|
|
)}
|
|
|
|
<div style={{ marginBottom: 24 }}>
|
|
{/* Latest IDOC */}
|
|
<FeedList
|
|
title="Houses in danger (IDOC)"
|
|
empty="No houses are collapsing right now."
|
|
items={data.idoc.map((h) => {
|
|
const region = h.region ? ` — ${h.region}` : ''
|
|
return {
|
|
id: h.serial,
|
|
text: `${h.name || 'A house'}${region}`,
|
|
when: h.updatedAt,
|
|
}
|
|
})}
|
|
/>
|
|
</div>
|
|
|
|
{/* Live ticker */}
|
|
<section className="panel" style={{ padding: 20 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
|
Live feed
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
|
<Link to="/uo/shard/activity" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.78rem' }}>
|
|
View all activity →
|
|
</Link>
|
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
|
{connected ? 'Live' : 'Offline'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{events.length === 0 ? (
|
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
|
Waiting for something to happen in the world…
|
|
</p>
|
|
) : (
|
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{events.map((ev) => (
|
|
<li key={ev._id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(ev)}</span>
|
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(ev.t)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
</>
|
|
)}
|
|
</div>
|
|
</PublicLayout>
|
|
)
|
|
}
|
|
|
|
// Online/offline banner with the flavor line under it.
|
|
function ConnectionBanner({ online, status }) {
|
|
return (
|
|
<section
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 16,
|
|
padding: '24px 26px',
|
|
border: `1px solid ${online ? 'rgba(95,185,138,0.45)' : '#5a4a2a'}`,
|
|
borderRadius: 10,
|
|
background: online
|
|
? 'linear-gradient(180deg,rgba(22,46,34,0.5),rgba(16,26,20,0.4))'
|
|
: 'linear-gradient(180deg,rgba(58,46,22,0.5),rgba(30,26,16,0.4))',
|
|
marginBottom: 24,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
flex: 'none',
|
|
width: 12,
|
|
height: 12,
|
|
borderRadius: '50%',
|
|
background: online ? 'var(--mode-live)' : 'var(--mode-maint)',
|
|
boxShadow: `0 0 12px ${online ? 'rgba(95,185,138,0.7)' : 'rgba(230,194,106,0.7)'}`,
|
|
}}
|
|
/>
|
|
<div>
|
|
<strong className="display" style={{ display: 'block', fontSize: '1.2rem', color: online ? '#bfe6cf' : '#f0e3c4' }}>
|
|
{online ? 'The shard is online' : 'The shard is offline'}
|
|
</strong>
|
|
<span className="sans" style={{ color: online ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
|
|
{statusMessage(online, status?.enabled)}
|
|
</span>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// Linked staff accounts currently online; in-game location is admin/mod-only.
|
|
function StaffOnline({ list, canSeeLocation }) {
|
|
return (
|
|
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
|
Staff online
|
|
</div>
|
|
{(!list || list.length === 0) ? (
|
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No staff are online right now.</p>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{list.map((p) => (
|
|
<div key={p.serial} className="sans" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
|
<span style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
|
|
{p.name || p.serial}
|
|
</span>
|
|
{canSeeLocation && (
|
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>
|
|
{p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function FeedList({ title, items, empty }) {
|
|
return (
|
|
<section className="panel" style={{ padding: 20 }}>
|
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
|
{title}
|
|
</div>
|
|
{items.length === 0 ? (
|
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>{empty}</p>
|
|
) : (
|
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
{items.map((it) => (
|
|
<li key={it.id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.text}</span>
|
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(it.when)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|