Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client, and bot). All changes are behaviour-preserving refactors — no route, protocol, schema, or config changes — verified against the full server (381) and client (43) test suites plus a clean client build. By rule: - S3776 (20, cognitive complexity): extract helpers/handlers so each function drops under the threshold — shard model upsert builders, page/wiki update, block validation, notification stream mapping (dispatch table), SSO mobile login, shard ingest deps, uo-link socket backfill/connect, the bot slash- command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/ CharacterStats React components. - S4624 (34, nested template literals): pull inner templates into locals / a withQs() helper; rewrite shardEvents.describe() as a formatter table. - S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small components, or guarded JSX expressions. - S6479 (12, array-index React keys): key by stable content instead of index (two in-editor lists left as-is; index matches their by-index edit model). - S6353 (6): [0-9]/[^0-9] -> \d/\D. S125 (5): reword state-shape comments that parsed as code. S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples. - S6481 (2): memoize Auth/Site context values (and SiteContext brand). - S4144: dedupe HeroEditor upload handler into useImageUpload(). - S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex -> prefix list): assorted one-liners. Co-Authored-By: Claude <noreply@anthropic.com>
255 lines
11 KiB
JavaScript
255 lines
11 KiB
JavaScript
import { Link } from 'react-router-dom'
|
|
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 { describe } from '../../lib/shardEvents.js'
|
|
import { ago } from '../../lib/format.js'
|
|
import { api } from '../../api/client.js'
|
|
import PlayersOnline from '../../components/PlayersOnline.jsx'
|
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
|
|
|
// 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="/site/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>
|
|
)
|
|
}
|