refactor(client): delete the UO client half (phase 3, slice 3)
35 files and 5,332 lines out — twelve public pages, seven admin views, two player views, eight components, the two `data/` leaves and the three `lib/` ones, plus the two tests that came with them. §2.7.1's estimate of 51 files / ~3,700 lines was measured differently and is corrected in the docs PR. The seams core keeps, each smaller than what it replaced: Nine rows leave the public header and six leave the admin sidebar, and both lists are now free of `feature` gates and of `IconShard`. `moduleTitle` already handled a module page's heading, so the six TITLES entries and the `/admin/characters` branch of `sectionTitle` simply go. `/player` had `PlayerCharacters` as its index — a UO page — and rather than name a replacement or invent a landing screen it now resolves to the first row of the portal nav this viewer can reach (`firstDestinationFor`, beside `allowedPathsFor` and reading the BASE nav for the same reason: an override is presentation and where everybody lands is behaviour). With the module installed that is still Characters, so a player's first screen after signing in does not change. Deliberately generic and deliberately not in the portal layout — the admin index is the same question with a hardcoded answer, and if the two logged-in areas ever become one this is what serves both. `game_account_signup` goes with the rest of core's UO prose: the mode list, the derived public flag, the validation and a Site Settings field whose help text named Bridge.cfg. The row itself is untouched and module-uo reads it through ctx.settings — the data stays, the semantics move. KNOWN BREAK, accepted by the org lead: the shipped Android app reads `gameAccountSignup` off `/public/settings` (PublicDto.kt:80). The field has a `= false` default so nothing crashes; the app silently stops offering game-account creation until it reads the module's `/public/shard/features` instead. Out of scope here, recorded in the Android plan, and it lands well before this workstream's cutover reaches `main`. 620 server + 161 client tests. Manifest 158 public + 2 internal, unchanged; routes.guards unchanged. The OpenAPI spec loses exactly one property, and only because it was hand-written in swagger.js — regeneration alone would have left the spec documenting a field core no longer returns. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,310 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// ── The spawn atlas ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// What the shard CONTAINS, as opposed to what it is doing: which creatures
|
||||
// spawn, where, and which champion altars are configured. There is no live feed
|
||||
// here and no `connected` indicator, deliberately — this is parsed from the
|
||||
// shard's own files and stays complete while the shard is down.
|
||||
//
|
||||
// Facet names come from the shard's data, never from a list in this file. A
|
||||
// shard running custom maps gets its own names in the filter with no code
|
||||
// change (docs/link/v3.md §6.1 R2).
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||
|
||||
const TABS = [
|
||||
{ key: 'creatures', label: 'Creatures' },
|
||||
{ key: 'champions', label: 'Champion altars' },
|
||||
{ key: 'places', label: 'Places' },
|
||||
]
|
||||
|
||||
function Chip({ active, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
padding: '5px 12px',
|
||||
borderRadius: 999,
|
||||
cursor: 'pointer',
|
||||
color: active ? 'var(--bg-deep)' : 'var(--muted)',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CreatureCard({ creature }) {
|
||||
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
|
||||
return (
|
||||
<Link
|
||||
to={`/site/atlas/${encodeURIComponent(creature.slug)}`}
|
||||
className="panel"
|
||||
style={{
|
||||
padding: '13px 15px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
className="display"
|
||||
style={{
|
||||
fontSize: '0.98rem',
|
||||
color: 'var(--head)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{creature.name}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||
{facets.length === 0
|
||||
? '—'
|
||||
: facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(creature.total)}</div>
|
||||
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>
|
||||
{num(creature.points)} spawners
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// The creature list owns its own paging rather than going through useAsync: a
|
||||
// "load more" appends to what is already on screen, which a hook that resets to
|
||||
// `{ loading: true, data: null }` on every dependency change cannot express.
|
||||
function Creatures({ q, facet }) {
|
||||
const [state, setState] = useState({ loading: true, error: null, items: [], total: 0 })
|
||||
const [more, setMore] = useState(false)
|
||||
|
||||
const load = useCallback(
|
||||
async (offset) => {
|
||||
const page = await api.atlas.creatures({ q, facet, limit: PAGE, offset })
|
||||
return page
|
||||
},
|
||||
[q, facet],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setState({ loading: true, error: null, items: [], total: 0 })
|
||||
load(0)
|
||||
.then((page) => {
|
||||
if (alive) setState({ loading: false, error: null, items: page.creatures || [], total: page.total || 0 })
|
||||
})
|
||||
.catch((error) => alive && setState({ loading: false, error, items: [], total: 0 }))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const loadMore = async () => {
|
||||
setMore(true)
|
||||
try {
|
||||
const page = await load(state.items.length)
|
||||
setState((s) => ({ ...s, items: [...s.items, ...(page.creatures || [])], total: page.total ?? s.total }))
|
||||
} catch {
|
||||
// A failed "load more" leaves what is already on screen alone; the button
|
||||
// simply stays available to retry.
|
||||
} finally {
|
||||
setMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.loading) return <Loading />
|
||||
if (state.error) return <ErrorState message="Could not load the bestiary right now." />
|
||||
if (state.items.length === 0) {
|
||||
return <EmptyState>Nothing in the atlas matches that.</EmptyState>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
||||
Showing {num(state.items.length)} of {num(state.total)}
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{state.items.map((c) => (
|
||||
<CreatureCard key={c.slug} creature={c} />
|
||||
))}
|
||||
</div>
|
||||
{state.items.length < state.total && (
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<button type="button" className="btn" onClick={loadMore} disabled={more}>
|
||||
{more ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// The CONFIGURED altar roster — where the altars are and what each summons. The
|
||||
// live board ("it is on level 3 right now") is a different page, /site/champs,
|
||||
// fed by the sidecar. Both exist; they are not the same thing.
|
||||
function Champions({ facet }) {
|
||||
const { loading, error, data } = useAsync(() => api.atlas.champions(facet), [facet])
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load the champion altars right now." />
|
||||
if (!data || data.length === 0) return <EmptyState>No champion altars are configured.</EmptyState>
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.map((champ) => (
|
||||
<div key={champ.slug} className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '0.98rem', color: 'var(--head)' }}>
|
||||
{champ.label || champ.name}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||
{champ.facet}
|
||||
{champ.group ? ` · ${champ.group}` : ''} · {champ.x}, {champ.y}
|
||||
</div>
|
||||
</div>
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.76rem', color: 'var(--muted)' }}>
|
||||
{champ.randomType ? 'Random champion' : champ.type || '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Regions and landmarks together: both answer "where is that?", and splitting
|
||||
// them into two tabs would make the visitor guess which list a name lives in.
|
||||
function Places({ q, facet }) {
|
||||
const { loading, error, data } = useAsync(
|
||||
() => Promise.all([api.atlas.regions({ q, facet }), api.atlas.landmarks({ q, facet })]),
|
||||
[q, facet],
|
||||
)
|
||||
const rows = useMemo(() => {
|
||||
if (!data) return []
|
||||
const [regions, landmarks] = data
|
||||
return [
|
||||
...regions.map((r) => ({ key: `r:${r.facet}:${r.name}`, name: r.name, facet: r.facet, detail: r.parent || r.type || 'Region', kind: 'Region' })),
|
||||
...landmarks.map((l) => ({ key: `l:${l.facet}:${l.group || ''}:${l.name}:${l.x}:${l.y}`, name: l.group ? `${l.group} — ${l.name}` : l.name, facet: l.facet, detail: `${l.x}, ${l.y}`, kind: 'Landmark' })),
|
||||
].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [data])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load places right now." />
|
||||
if (rows.length === 0) return <EmptyState>No regions or landmarks match that.</EmptyState>
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="panel" style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>{row.name}</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>{row.facet} · {row.detail}</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.66rem', letterSpacing: '0.06em', flex: 'none' }}>{row.kind}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Atlas() {
|
||||
const [tab, setTab] = useState('creatures')
|
||||
const [input, setInput] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [facet, setFacet] = useState('')
|
||||
const meta = useAsync(() => api.atlas.meta())
|
||||
|
||||
// Debounced: typing "lizardman" should be one request, not nine.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setQ(input.trim()), 250)
|
||||
return () => clearTimeout(timer)
|
||||
}, [input])
|
||||
|
||||
const facets = meta.data?.facets || []
|
||||
const counts = meta.data?.counts || null
|
||||
const imported = meta.data?.importedAt ? new Date(meta.data.importedAt) : null
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader
|
||||
eyebrow="Bestiary"
|
||||
title="Spawn atlas"
|
||||
lead="Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
|
||||
/>
|
||||
|
||||
{/* The atlas is only as good as its placement rate, so the page states
|
||||
it rather than implying every spawner resolved to a named place. */}
|
||||
{counts && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
|
||||
{num(counts.creatures)} creatures across {num(counts.points)} spawners
|
||||
{Number.isFinite(counts.unresolvedPoints) && counts.points
|
||||
? ` · ${Math.round(((counts.points - counts.unresolvedPoints) / counts.points) * 100)}% placed to a named region or landmark`
|
||||
: ''}
|
||||
{imported ? ` · parsed ${imported.toLocaleDateString()}` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
{TABS.map((t) => (
|
||||
<Chip key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
|
||||
{t.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab !== 'champions' && (
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={tab === 'creatures' ? 'Search creatures…' : 'Search regions and landmarks…'}
|
||||
style={{ width: '100%', marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{facets.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 18 }}>
|
||||
<Chip active={facet === ''} onClick={() => setFacet('')}>
|
||||
All facets
|
||||
</Chip>
|
||||
{facets.map((f) => (
|
||||
<Chip key={f} active={facet === f} onClick={() => setFacet(f)}>
|
||||
{f}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.error && <ErrorState message="Could not load the atlas right now." />}
|
||||
{!meta.error && !meta.loading && !imported && (
|
||||
<EmptyState>The spawn atlas has not been imported yet.</EmptyState>
|
||||
)}
|
||||
|
||||
{!meta.error && imported && (
|
||||
<>
|
||||
{tab === 'creatures' && <Creatures q={q} facet={facet} />}
|
||||
{tab === 'champions' && <Champions facet={facet} />}
|
||||
{tab === 'places' && <Places q={q} facet={facet} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// One creature: where it spawns, and what spawns alongside it.
|
||||
//
|
||||
// `places` is the point of the page — the aggregate that turns 62 raw
|
||||
// coordinates into "Shrines, Isamu-Jima, Yew". The individual spawners are
|
||||
// available underneath for the reader who actually wants a coordinate, but they
|
||||
// are secondary and collapsed by default.
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||
|
||||
// Spawn delays are stored in seconds. A raw "1200" tells the reader nothing.
|
||||
function delay(min, max) {
|
||||
const fmt = (s) => (s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`)
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) return null
|
||||
if (min === max) return fmt(min)
|
||||
return `${fmt(min)}–${fmt(max)}`
|
||||
}
|
||||
|
||||
function Panel({ title, right, children }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
||||
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
{right}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Places({ places }) {
|
||||
if (places.length === 0) {
|
||||
return <p className="sans dim" style={{ margin: 0 }}>No placed spawners.</p>
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{places.map((place) => (
|
||||
<div
|
||||
key={`${place.facet}:${place.label}`}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '6px 0',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ minWidth: 0, color: 'var(--head)' }}>{place.label}</span>
|
||||
<span className="dim" style={{ flex: 'none' }}>
|
||||
{place.facet} · {num(place.spawners)} spawner{place.spawners === 1 ? '' : 's'} · up to{' '}
|
||||
{num(place.maxAlive)} at once
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Spawners({ spawners, truncated }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (spawners.length === 0) return null
|
||||
return (
|
||||
<Panel
|
||||
title="Individual spawners"
|
||||
right={
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: '0.78rem' }}
|
||||
>
|
||||
{open ? 'Hide' : `Show ${num(spawners.length)}`}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{open && (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--muted)' }}>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Place</th>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Facet</th>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Coords</th>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Max</th>
|
||||
<th style={{ padding: '4px 0 8px 0' }}>Respawn</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{spawners.map((s) => (
|
||||
<tr key={s.id} style={{ borderTop: '1px solid var(--line)' }}>
|
||||
<td style={{ padding: '6px 8px 6px 0', color: 'var(--head)' }}>{s.label}</td>
|
||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.facet}</td>
|
||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.x}, {s.y}</td>
|
||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{num(s.maxCount)}</td>
|
||||
<td style={{ padding: '6px 0' }} className="dim">{delay(s.minDelay, s.maxDelay) || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{truncated && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||
Only the largest spawners are listed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AtlasCreature() {
|
||||
const { slug } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.atlas.creature(slug), [slug])
|
||||
|
||||
// A 404 here means "no such creature in this atlas", which is a real answer
|
||||
// and not a failure — a visitor following a stale link deserves to be told
|
||||
// that plainly rather than shown a generic error box.
|
||||
const missing = error?.status === 404 || error?.message === 'Not Found'
|
||||
|
||||
const facets = useMemo(
|
||||
() => Object.entries(data?.facets || {}).sort((a, b) => b[1] - a[1]),
|
||||
[data],
|
||||
)
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<p className="sans" style={{ marginBottom: 8 }}>
|
||||
<Link to="/site/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
|
||||
← Spawn atlas
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && !missing && <ErrorState message="Could not load that creature right now." />}
|
||||
{missing && <EmptyState>Nothing by that name spawns on this shard.</EmptyState>}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Bestiary"
|
||||
title={data.name}
|
||||
lead={`Up to ${num(data.total)} alive at once across ${num(data.points)} spawner${data.points === 1 ? '' : 's'}.`}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Panel
|
||||
title="Where it spawns"
|
||||
right={
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
{facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Places places={data.places || []} />
|
||||
</Panel>
|
||||
|
||||
<Spawners spawners={data.spawners || []} truncated={!!data.spawnersTruncated} />
|
||||
|
||||
{data.alsoHere?.length > 0 && (
|
||||
<Panel title="Shares a spawner with">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{data.alsoHere.map((other) => (
|
||||
<Link
|
||||
key={other.slug}
|
||||
to={`/site/atlas/${encodeURIComponent(other.slug)}`}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
padding: '4px 11px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--line)',
|
||||
color: 'var(--muted)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{other.name} <span className="dim">×{num(other.shared)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import { useMemo, useState } 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 { crestFor } from '../../data/cityCrests.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The town-governor board (City Loyalty). Loaded from /public/shard/governors,
|
||||
// kept live by merging city.update deltas by city. Empty on shards without the
|
||||
// City Loyalty system. Each city card links to its term history (look-back).
|
||||
const GOV_KINDS = new Set(['city.update'])
|
||||
|
||||
const PHASE = {
|
||||
none: null,
|
||||
nominate: { label: 'Nominations open', color: '#7f8fd0' },
|
||||
vote: { label: 'Voting', color: '#e6c26a' },
|
||||
pending: { label: 'Result pending', color: '#c9a24b' },
|
||||
}
|
||||
|
||||
// A short "in 3d" / "in 5h" for a future ISO timestamp (autoPickAt).
|
||||
function until(iso) {
|
||||
if (!iso) return ''
|
||||
const ms = new Date(iso).getTime() - Date.now()
|
||||
if (!Number.isFinite(ms) || ms <= 0) return ''
|
||||
const mins = Math.round(ms / 60000)
|
||||
if (mins < 60) return `in ${mins}m`
|
||||
const hrs = Math.round(mins / 60)
|
||||
if (hrs < 24) return `in ${hrs}h`
|
||||
return `in ${Math.round(hrs / 24)}d`
|
||||
}
|
||||
|
||||
function fmtDate(ms) {
|
||||
if (ms == null) return ''
|
||||
return new Date(Number(ms)).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function CityCrest({ city, size = 44 }) {
|
||||
const c = crestFor(city)
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
flex: 'none', width: size, height: size, borderRadius: '50%',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.5, background: 'rgba(255,255,255,0.04)',
|
||||
border: `2px solid ${c.color}`, boxShadow: `0 0 10px ${c.color}22`,
|
||||
}}
|
||||
>
|
||||
{c.sigil}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsible term history for one city, fetched on demand from the ledger.
|
||||
function TermHistory({ city }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { loading, error, data } = useAsync(
|
||||
() => (open ? api.shard.governorHistory(city, 25) : Promise.resolve(null)),
|
||||
[open, city],
|
||||
)
|
||||
return (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0, fontSize: '0.76rem' }}
|
||||
>
|
||||
{open ? 'Hide past governors' : 'Past governors →'}
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{loading && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Loading…</p>}
|
||||
{error && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Could not load history.</p>}
|
||||
{data && data.length === 0 && (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>No recorded terms yet.</p>
|
||||
)}
|
||||
{data && data.length > 0 && (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
{data.map((t) => (
|
||||
<li key={`${t.startedAt}-${t.governor?.name ?? 'vacant'}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.8rem', color: 'var(--ink)' }}>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t.governor?.name || 'Vacant'}
|
||||
</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.72rem' }}>
|
||||
{fmtDate(t.startedAt)}{t.endedAt ? ` – ${fmtDate(t.endedAt)}` : ' – present'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CityCard({ c }) {
|
||||
const phase = PHASE[c.electionPhase] || null
|
||||
const gov = c.governor
|
||||
const candidatePlural = c.candidates === 1 ? '' : 's'
|
||||
return (
|
||||
<div className="panel" style={{ padding: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<CityCrest city={c.city} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<strong className="display" style={{ fontSize: '1.05rem', color: 'var(--head)' }}>
|
||||
{crestFor(c.city).label || c.city}
|
||||
</strong>
|
||||
{phase && (
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.66rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: phase.color, border: `1px solid ${phase.color}66`, borderRadius: 999, padding: '2px 8px' }}>
|
||||
{phase.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="sans" style={{ marginTop: 3, fontSize: '0.9rem', color: gov ? 'var(--ink)' : 'var(--muted)' }}>
|
||||
{gov ? (
|
||||
<>Governor <strong style={{ color: 'var(--head)' }}>{gov.name}</strong></>
|
||||
) : (
|
||||
'Seat vacant'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{c.electionPhase && c.electionPhase !== 'none' && (
|
||||
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.78rem' }}>
|
||||
{c.candidates ? `${c.candidates} candidate${candidatePlural}` : 'No candidates yet'}
|
||||
{c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TermHistory city={c.city} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Governors() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.governors())
|
||||
const { events, connected } = useShardFeed({ filter: GOV_KINDS, max: 30 })
|
||||
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const c of data || []) if (c && c.city) map.set(c.city, c)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (ev.kind === 'city.update' && ev.city) map.set(ev.city, ev)
|
||||
}
|
||||
return [...map.values()].sort((a, b) => (a.city || '').localeCompare(b.city || ''))
|
||||
}, [data, events])
|
||||
|
||||
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="Governors of Britannia" lead="Who rules each city, and where the next election stands." />
|
||||
<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 governor board right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>
|
||||
City Loyalty governance is not enabled on this shard.
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<div className="grid-2" style={{ gap: 12 }}>
|
||||
{board.map((c) => <CityCard key={c.city} c={c} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { useMemo, useState } 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 guild board. Loaded once from /public/shard/guilds, then kept live by
|
||||
// merging guild.update / guild.remove deltas; guild.join drives a small "recently
|
||||
// joined" strip on top of the board.
|
||||
const GUILD_KINDS = new Set(['guild.update', 'guild.remove', 'guild.join'])
|
||||
|
||||
function Leader({ leader }) {
|
||||
if (!leader || !leader.name) return <span className="dim">—</span>
|
||||
return <span>{leader.name}</span>
|
||||
}
|
||||
|
||||
function GuildRow({ g }) {
|
||||
return (
|
||||
<div
|
||||
className="panel"
|
||||
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
|
||||
{g.abbr && (
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
flex: 'none',
|
||||
fontSize: '0.72rem',
|
||||
letterSpacing: '0.06em',
|
||||
color: 'var(--accent)',
|
||||
border: '1px solid rgba(201,162,75,0.4)',
|
||||
borderRadius: 5,
|
||||
padding: '1px 6px',
|
||||
}}
|
||||
>
|
||||
{g.abbr}
|
||||
</span>
|
||||
)}
|
||||
<strong
|
||||
className="display"
|
||||
style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{g.name || 'A guild'}
|
||||
</strong>
|
||||
</div>
|
||||
{g.alliance && (
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{g.alliance}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right', fontSize: '0.84rem', color: 'var(--ink)' }}>
|
||||
<div>
|
||||
<span style={{ color: '#7fd0a4' }}>{g.online ?? 0}</span>
|
||||
<span className="dim"> / {g.members ?? 0}</span>
|
||||
</div>
|
||||
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||
<Leader leader={g.leader} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Guilds() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.guilds())
|
||||
const { events, connected } = useShardFeed({ filter: GUILD_KINDS, max: 60 })
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
// Merge snapshot + live deltas by guild id (apply oldest → newest so live wins).
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const g of data || []) if (g && g.id != null) map.set(g.id, g)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (ev.kind === 'guild.update' && ev.id != null) map.set(ev.id, ev)
|
||||
else if (ev.kind === 'guild.remove' && ev.id != null) map.delete(ev.id)
|
||||
}
|
||||
return [...map.values()]
|
||||
}, [data, events])
|
||||
|
||||
// Recent joins strip (newest first, deduped, capped).
|
||||
const joins = useMemo(
|
||||
() => events.filter((e) => e.kind === 'guild.join' && e.who).slice(0, 6),
|
||||
[events],
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
const rows = needle
|
||||
? board.filter((g) =>
|
||||
[g.name, g.abbr, g.alliance].some((v) => v && v.toLowerCase().includes(needle)),
|
||||
)
|
||||
: board
|
||||
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||
}, [board, q])
|
||||
|
||||
const totalMembers = board.reduce((n, g) => n + (Number(g.members) || 0), 0)
|
||||
|
||||
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="Guilds" lead="Every guild on the shard — rosters, alliances and who's online, 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 guild board right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>No guilds are being tracked right now.</p>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
{joins.length > 0 && (
|
||||
<section className="panel" style={{ padding: '12px 16px', marginBottom: 18 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.66rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 8 }}>
|
||||
Recently joined
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
{joins.map((j) => (
|
||||
<div key={j._id} className="sans" style={{ fontSize: '0.84rem', color: 'var(--ink)' }}>
|
||||
<strong style={{ color: 'var(--head)' }}>{j.who.name}</strong>
|
||||
<span className="dim"> joined </span>
|
||||
{j.abbr ? `[${j.abbr}] ` : ''}{j.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
|
||||
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', margin: 0 }}>
|
||||
{board.length} guilds · {totalMembers.toLocaleString()} members
|
||||
</p>
|
||||
<input
|
||||
className="input sans"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search guilds…"
|
||||
style={{ flex: 'none', width: 190, maxWidth: '50%', fontSize: '0.84rem' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{filtered.map((g) => <GuildRow key={g.id} g={g} />)}
|
||||
</div>
|
||||
{filtered.length === 0 && (
|
||||
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No guilds match “{q}”.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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'
|
||||
|
||||
// PUBLIC houses board: only houses in danger (IDOC), shown by location. Owner,
|
||||
// price, decay detail and the full registry are staff-only (admin Houses view).
|
||||
// Loaded from /public/shard/houses (IDOC-only), kept live by house.decay: a
|
||||
// house entering IDOC appears, one leaving it drops off.
|
||||
const HOUSE_KINDS = new Set(['house.decay'])
|
||||
|
||||
function HouseRow({ h }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#e05a5a', boxShadow: '0 0 8px rgba(224,90,90,0.7)' }}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{h.region || 'The wilderness'}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', letterSpacing: '0.06em', color: '#e05a5a', border: '1px solid #e05a5a66', borderRadius: 999, padding: '2px 9px' }}>
|
||||
IDOC
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Houses() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.houses())
|
||||
const { events, connected } = useShardFeed({ filter: HOUSE_KINDS, max: 60 })
|
||||
|
||||
// Merge the IDOC snapshot with live house.decay deltas by serial: entering IDOC
|
||||
// adds/updates the row; anything else (refreshed, collapsed) drops it.
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (ev.kind !== 'house.decay' || !ev.serial) continue
|
||||
if (String(ev.to).toUpperCase() === 'IDOC') {
|
||||
map.set(ev.serial, { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y, z: ev.z, isIdoc: true })
|
||||
} else {
|
||||
map.delete(ev.serial)
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort((a, b) => (a.region || '').localeCompare(b.region || ''))
|
||||
}, [data, events])
|
||||
|
||||
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="Houses in danger" lead="Homes that have fallen into IDOC — where to find them before they collapse." />
|
||||
<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 houses board right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>No houses are collapsing right now.</p>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<p className="sans" style={{ color: '#e0928a', fontSize: '0.8rem', marginTop: -12, marginBottom: 20 }}>
|
||||
{board.length} in danger
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{board.map((h) => <HouseRow key={h.serial} h={h} />)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import { useMemo, useState } 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'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
// Points / loyalty leaderboards (Protocol 3.0 §7). The shard carries ~25 separate
|
||||
// point currencies — Queen's Loyalty, Void Pool, Clean Up Britannia, the nine city
|
||||
// loyalties, the Doom/Khaldun/Kotl treasure systems — every one of them a standing
|
||||
// players build over months, and none of them visible anywhere but an in-game gump
|
||||
// until now.
|
||||
//
|
||||
// Loaded from /public/shard/points, then kept current from the live feed. Unlike
|
||||
// the ruleset (one frame = the whole thing), a points.board frame describes ONE
|
||||
// system, so live frames are merged over the fetched set by system key rather than
|
||||
// replacing it.
|
||||
const POINTS_KINDS = new Set(['points.board'])
|
||||
|
||||
// A board's display name may arrive as a literal (`nameString`), a cliloc id
|
||||
// (`nameNumber`), or both — Name is a ServUO TextDefinition. We have no cliloc
|
||||
// table on the site, so a cliloc-only board falls back to humanising its own
|
||||
// PointsType key, which is already close to a display name ("CleanUpBritannia" →
|
||||
// "Clean Up Britannia"). Better than showing a bare number.
|
||||
const humanise = (key) =>
|
||||
String(key || '')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
|
||||
const boardTitle = (b) => b.nameString || humanise(b.system)
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||
|
||||
// Merge live frames over the fetched boards. Newest frame per system wins; a
|
||||
// system that has never appeared in either is simply absent.
|
||||
function mergeBoards(fetched, events) {
|
||||
const bySystem = new Map()
|
||||
for (const b of Array.isArray(fetched) ? fetched : []) {
|
||||
if (b && b.system) bySystem.set(b.system, b)
|
||||
}
|
||||
// Events arrive newest-first, so walk backwards and let the newest land last.
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const ev = events[i]
|
||||
if (ev && ev.system) bySystem.set(ev.system, ev)
|
||||
}
|
||||
return [...bySystem.values()].sort((a, b) => boardTitle(a).localeCompare(boardTitle(b)))
|
||||
}
|
||||
|
||||
function Medal({ rank }) {
|
||||
// Gold / silver / bronze for the podium, plain for the rest.
|
||||
const tone = rank === 1 ? '#c9a24b' : rank === 2 ? '#b6bcc6' : rank === 3 ? '#b3805a' : 'var(--muted)'
|
||||
return (
|
||||
<span
|
||||
className="display"
|
||||
style={{
|
||||
flex: 'none', width: 26, textAlign: 'right', color: tone,
|
||||
fontSize: rank <= 3 ? '1rem' : '0.86rem',
|
||||
}}
|
||||
>
|
||||
{rank}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// One ranked player. `name` is absent rather than empty when an admin has gated
|
||||
// the leaderboards `name` field above this viewer's rung — the row still renders,
|
||||
// because the standing itself is the point.
|
||||
function Entry({ entry, best }) {
|
||||
const pct = best > 0 ? Math.max(2, Math.round((entry.points / best) * 100)) : 0
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||||
<Medal rank={entry.rank} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
color: entry.name ? 'var(--ink)' : 'var(--muted)',
|
||||
fontSize: '0.86rem', fontStyle: entry.name ? 'normal' : 'italic',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{entry.name || 'Name hidden'}
|
||||
</span>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
|
||||
{num(entry.points)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden', marginTop: 3 }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Board({ board }) {
|
||||
const { siteTitle } = useSite()
|
||||
const top = Array.isArray(board.top) ? board.top : []
|
||||
// Bars are relative to the board leader, not to maxPoints: most systems have no
|
||||
// cap (maxPoints 0), and where there is one the leader is often nowhere near it,
|
||||
// which would render every bar as a stub.
|
||||
const best = top.reduce((m, e) => Math.max(m, e.points || 0), 0)
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10 }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.02rem', color: 'var(--head)' }}>
|
||||
{boardTitle(board)}
|
||||
</h2>
|
||||
{Number.isFinite(board.players) && (
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem', flex: 'none' }}>
|
||||
{num(board.players)} ranked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{top.length === 0 ? (
|
||||
// A board nobody has scored on still gets a row, so the page reads as a set
|
||||
// of standings waiting to be filled rather than a stack of blanks. It is
|
||||
// deliberately NOT shaped like an Entry — no medal, no bar, an em dash where
|
||||
// a score goes — because a placeholder that looked like a real standing would
|
||||
// be a fabricated one. The first real entry replaces it.
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10, padding: '6px 0' }}>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
color: 'var(--muted)', fontSize: '0.86rem',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{siteTitle}
|
||||
</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.82rem', flex: 'none' }}>—</span>
|
||||
</div>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
|
||||
Nobody has earned points here yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{top.map((entry) => (
|
||||
<Entry key={`${board.system}-${entry.rank}-${entry.serial}`} entry={entry} best={best} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Number.isFinite(board.maxPoints) && board.maxPoints > 0 && (
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||
Maximum {num(board.maxPoints)} points
|
||||
</span>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Leaderboards() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.points())
|
||||
// Buffer generously: a single sweep can emit a frame for every system at once,
|
||||
// and a board dropped from the buffer would silently revert to its fetched copy.
|
||||
const { events, connected } = useShardFeed({ filter: POINTS_KINDS, max: 60 })
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const boards = useMemo(() => mergeBoards(data, events), [data, events])
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return boards
|
||||
// Match the board name, the raw system key, or any ranked player on it — the
|
||||
// last is what makes the filter useful ("where do I appear?").
|
||||
return boards.filter(
|
||||
(b) =>
|
||||
boardTitle(b).toLowerCase().includes(q) ||
|
||||
String(b.system).toLowerCase().includes(q) ||
|
||||
(b.top || []).some((e) => e.name && e.name.toLowerCase().includes(q)),
|
||||
)
|
||||
}, [boards, query])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader
|
||||
eyebrow="Live"
|
||||
title="Leaderboards"
|
||||
lead="Loyalty and points standings, straight from the shard — every currency the server tracks, updated as players climb."
|
||||
/>
|
||||
<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 leaderboards right now." />}
|
||||
|
||||
{!loading && !error && boards.length === 0 && (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>
|
||||
The shard has not published any leaderboards yet.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!loading && !error && boards.length > 0 && (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Filter by board or player name…"
|
||||
aria-label="Filter leaderboards"
|
||||
style={{ maxWidth: 340, marginBottom: 14 }}
|
||||
/>
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<p className="sans dim">No board or ranked player matches “{query}”.</p>
|
||||
) : (
|
||||
<div className="grid-2" style={{ gap: 12, alignItems: 'start' }}>
|
||||
{shown.map((board) => (
|
||||
<Board key={board.system} board={board} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// ── The player-vendor marketplace ───────────────────────────────────────────
|
||||
//
|
||||
// What every player vendor on the shard is selling, for how much, and where it
|
||||
// is standing — the same index the in-game Vendor Search gump reads, honouring
|
||||
// the same per-vendor opt-out, reachable without logging in to the game.
|
||||
//
|
||||
// Three things this page must be honest about, all of them consequences of how
|
||||
// the data is gathered (docs/link/v3.md §8):
|
||||
//
|
||||
// • **The prices are not live.** The shard sweeps vendors round-robin, so a
|
||||
// shop can be a full cycle behind. The banner says how far, from `staleAt`.
|
||||
// A page that implied live prices would send people across the world to a
|
||||
// vendor whose item sold twenty minutes ago.
|
||||
// • **A shop can be truncated.** A commodity reseller with thousands of stacks
|
||||
// publishes only the first N, and saying so beats presenting a partial shop
|
||||
// as complete.
|
||||
// • **An item may have no name.** On a shard whose operator has not converted
|
||||
// a cliloc table, `displayName` is null and the honest render is the item id
|
||||
// — not an invented name.
|
||||
//
|
||||
// There is deliberately no live feed here. The market feature's SSE stream ships
|
||||
// disabled: a firehose of whole vendor inventories would be the site's single
|
||||
// biggest bandwidth consumer, and nothing on this page needs it.
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
|
||||
|
||||
const SORTS = [
|
||||
{ key: 'price_asc', label: 'Cheapest' },
|
||||
{ key: 'price_desc', label: 'Priciest' },
|
||||
{ key: 'recent', label: 'Recently seen' },
|
||||
]
|
||||
|
||||
// How old the index may be, in words. `staleAt` is the OLDEST vendor row, so
|
||||
// this is a worst case rather than an average — which is the number worth
|
||||
// showing, because the one stale shop is the one that wastes a trip.
|
||||
function staleness(staleAt) {
|
||||
if (!staleAt) return null
|
||||
const ms = Date.now() - new Date(staleAt).getTime()
|
||||
if (!Number.isFinite(ms) || ms < 0) return null
|
||||
const mins = Math.round(ms / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`
|
||||
const hours = Math.round(mins / 60)
|
||||
if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
|
||||
return `${Math.round(hours / 24)} days ago`
|
||||
}
|
||||
|
||||
// The item's name, or an honest statement that we do not have one. Never a
|
||||
// fabricated label — "Item 3922" would be indistinguishable from a real name.
|
||||
const itemLabel = (l) => l.displayName || l.name || `id ${l.itemId}`
|
||||
|
||||
function Chip({ active, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
padding: '5px 12px',
|
||||
borderRadius: 999,
|
||||
cursor: 'pointer',
|
||||
color: active ? 'var(--bg-deep)' : 'var(--muted)',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ListingRow({ listing }) {
|
||||
const v = listing.vendor || {}
|
||||
// `location` is one field the admin can gate away wholesale, so everything
|
||||
// that reads from it has to tolerate its absence rather than assuming a map.
|
||||
const loc = v.location || null
|
||||
const where = loc ? [loc.region, loc.map].filter(Boolean).join(', ') : null
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
className="display"
|
||||
style={{ fontSize: '0.98rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{listing.amount > 1 ? `${num(listing.amount)} × ` : ''}
|
||||
{itemLabel(listing)}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||
{v.serial ? (
|
||||
<Link to={`/site/market/vendors/${encodeURIComponent(v.serial)}`} style={{ color: 'inherit' }}>
|
||||
{v.shopName || 'an unnamed shop'}
|
||||
</Link>
|
||||
) : (
|
||||
v.shopName || 'an unnamed shop'
|
||||
)}
|
||||
{v.ownerName ? ` · ${v.ownerName}` : ''}
|
||||
{where ? ` · ${where}` : ''}
|
||||
{/* Priced by the container it sits in, exactly as the in-game search
|
||||
reports it — the price buys the whole container, not this item. */}
|
||||
{listing.child ? ' · sold with its container' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(listing.price)}</div>
|
||||
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>gold</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Market() {
|
||||
const [input, setInput] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [map, setMap] = useState('')
|
||||
const [region, setRegion] = useState('')
|
||||
const [sort, setSort] = useState('price_asc')
|
||||
const [minPrice, setMinPrice] = useState('')
|
||||
const [maxPrice, setMaxPrice] = useState('')
|
||||
// Applied prices are separate from the typed ones so the search fires when the
|
||||
// user is done, not on every digit of "250000".
|
||||
const [prices, setPrices] = useState({ min: '', max: '' })
|
||||
|
||||
const [state, setState] = useState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
|
||||
const [more, setMore] = useState(false)
|
||||
|
||||
const meta = useAsync(() => api.shard.marketMeta())
|
||||
|
||||
// Debounced: typing "vanquishing" should be one request, not eleven — and the
|
||||
// endpoint is rate-limited, so an undebounced box would 429 a fast typist.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setQ(input.trim()), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [input])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setPrices({ min: minPrice, max: maxPrice }), 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [minPrice, maxPrice])
|
||||
|
||||
const load = useCallback(
|
||||
(offset) =>
|
||||
api.shard.market({
|
||||
q,
|
||||
map,
|
||||
region,
|
||||
sort,
|
||||
minPrice: prices.min,
|
||||
maxPrice: prices.max,
|
||||
limit: PAGE,
|
||||
offset,
|
||||
}),
|
||||
[q, map, region, sort, prices],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
|
||||
load(0)
|
||||
.then((page) => {
|
||||
if (!alive) return
|
||||
setState({
|
||||
loading: false,
|
||||
error: null,
|
||||
listings: page.listings || [],
|
||||
total: page.total || 0,
|
||||
staleAt: page.staleAt || null,
|
||||
})
|
||||
})
|
||||
.catch((error) => alive && setState({ loading: false, error, listings: [], total: 0, staleAt: null }))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const loadMore = async () => {
|
||||
setMore(true)
|
||||
try {
|
||||
const page = await load(state.listings.length)
|
||||
setState((s) => ({
|
||||
...s,
|
||||
listings: [...s.listings, ...(page.listings || [])],
|
||||
total: page.total ?? s.total,
|
||||
staleAt: page.staleAt ?? s.staleAt,
|
||||
}))
|
||||
} catch {
|
||||
// A failed "load more" leaves what is on screen alone; the button stays
|
||||
// available to retry.
|
||||
} finally {
|
||||
setMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
const maps = meta.data?.maps || []
|
||||
const regions = meta.data?.regions || []
|
||||
const age = staleness(state.staleAt)
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader
|
||||
eyebrow="Marketplace"
|
||||
title="Player vendors"
|
||||
lead="Every shop on the shard, searchable from here — the same index the in-game vendor search reads, and it honours the same per-vendor opt-out."
|
||||
/>
|
||||
|
||||
{/* Not decoration. The sweep is round-robin, so the index is inherently
|
||||
up to one full cycle old and the page has to say so. */}
|
||||
{age && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
|
||||
Prices last refreshed {age}
|
||||
{meta.data?.vendors ? ` · ${num(meta.data.vendors)} shops` : ''}
|
||||
{meta.data?.items ? ` · ${num(meta.data.items)} listings` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Search listings…"
|
||||
style={{ width: '100%', marginBottom: 10 }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
value={minPrice}
|
||||
onChange={(e) => setMinPrice(e.target.value)}
|
||||
placeholder="Min price"
|
||||
style={{ maxWidth: 140 }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
value={maxPrice}
|
||||
onChange={(e) => setMaxPrice(e.target.value)}
|
||||
placeholder="Max price"
|
||||
style={{ maxWidth: 140 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
{SORTS.map((s) => (
|
||||
<Chip key={s.key} active={sort === s.key} onClick={() => setSort(s.key)}>
|
||||
{s.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Facet and region names come from the shard's own data, never a list in
|
||||
this file — a shard running custom maps gets its own names here with
|
||||
no code change (docs/link/v3.md §6.1 R2). */}
|
||||
{maps.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
<Chip active={map === ''} onClick={() => setMap('')}>All facets</Chip>
|
||||
{maps.map((m) => (
|
||||
<Chip key={m} active={map === m} onClick={() => setMap(m)}>{m}</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{regions.length > 0 && (
|
||||
<select
|
||||
className="input"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
style={{ width: '100%', marginBottom: 18 }}
|
||||
>
|
||||
<option value="">Anywhere</option>
|
||||
{regions.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{state.loading && <Loading />}
|
||||
{state.error && <ErrorState message="Could not load the marketplace right now." />}
|
||||
|
||||
{!state.loading && !state.error && state.listings.length === 0 && (
|
||||
<EmptyState>
|
||||
{meta.data?.vendors
|
||||
? 'Nothing on the shard matches that.'
|
||||
: 'No player vendors have been indexed yet.'}
|
||||
</EmptyState>
|
||||
)}
|
||||
|
||||
{!state.loading && !state.error && state.listings.length > 0 && (
|
||||
<>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
||||
Showing {num(state.listings.length)} of {num(state.total)}
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{state.listings.map((l) => (
|
||||
<ListingRow key={`${l.vendor?.serial}:${l.serial}`} listing={l} />
|
||||
))}
|
||||
</div>
|
||||
{state.listings.length < state.total && (
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<button type="button" className="btn" onClick={loadMore} disabled={more}>
|
||||
{more ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// One player vendor: where to find it and everything it is selling.
|
||||
//
|
||||
// The page a search result points at. Two states it has to render honestly and
|
||||
// which the search list cannot (docs/link/v3.md §8):
|
||||
//
|
||||
// • `truncated` — the shop holds more than the shard publishes per frame. A
|
||||
// commodity reseller with thousands of stacks is a real thing, and showing
|
||||
// 250 of 3,104 as if it were the whole shop would be a lie about the shard.
|
||||
// • a gated `location` — an admin may put vendor whereabouts behind a rung, in
|
||||
// which case there is nothing to render and the page says so rather than
|
||||
// showing an empty coordinate.
|
||||
|
||||
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
|
||||
|
||||
const itemLabel = (i) => i.displayName || i.name || `id ${i.itemId}`
|
||||
|
||||
export default function MarketVendor() {
|
||||
const { serial } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.shard.marketVendor(serial), [serial])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body"><Loading /></div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<ErrorState message="That shop is not in the index — it may have been dismissed or hidden." />
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<Link to="/site/market" className="sans">← Back to the marketplace</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const loc = data.location || null
|
||||
const items = data.items || []
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader
|
||||
eyebrow={data.ownerName ? `Run by ${data.ownerName}` : 'Player vendor'}
|
||||
title={data.shopName || 'An unnamed shop'}
|
||||
lead={
|
||||
loc
|
||||
? [loc.house, loc.region, loc.map].filter(Boolean).join(' · ') +
|
||||
(Number.isFinite(loc.x) ? ` — ${loc.x}, ${loc.y}` : '')
|
||||
: 'This shard does not publish vendor locations.'
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '-12px 0 18px' }}>
|
||||
{data.truncated
|
||||
? `Showing ${num(data.count)} of ${num(data.total)} listings — this shop holds more than the shard publishes.`
|
||||
: `${num(data.total)} listing${data.total === 1 ? '' : 's'}`}
|
||||
{data.updatedAt ? ` · last seen ${new Date(data.updatedAt).toLocaleString()}` : ''}
|
||||
</p>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<EmptyState>This shop has nothing priced for sale.</EmptyState>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{items.map((i) => (
|
||||
<div
|
||||
key={i.serial}
|
||||
className="panel"
|
||||
style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}
|
||||
>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>
|
||||
{i.amount > 1 ? `${num(i.amount)} × ` : ''}
|
||||
{itemLabel(i)}
|
||||
{i.child ? <span className="dim"> · sold with its container</span> : null}
|
||||
</span>
|
||||
<span className="sans" style={{ flex: 'none', color: 'var(--head)', fontSize: '0.88rem' }}>
|
||||
{num(i.price)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ marginTop: 20 }}>
|
||||
<Link to="/site/market" className="sans">← Back to the marketplace</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
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 shard ruleset. Loaded from /public/shard/ruleset, replaced wholesale by any
|
||||
// world.ruleset frame on the live feed (the shard re-emits the entire ruleset, so
|
||||
// there is nothing to merge — latest wins).
|
||||
//
|
||||
// Everything on this page is published BY THE SHARD from its own Config/*.cfg, so
|
||||
// it cannot drift the way a hand-written rules page does. That is the whole point
|
||||
// of the feature, and the page says so.
|
||||
const RULESET_KINDS = new Set(['world.ruleset'])
|
||||
|
||||
// Skill and stat caps arrive in tenths, the way ServUO stores them: 1000 is 100.0
|
||||
// skill. Showing the raw number would be actively misleading.
|
||||
const tenths = (v) => (Number.isFinite(v) ? (v / 10).toFixed(1) : null)
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : null)
|
||||
|
||||
const pct = (v) => (Number.isFinite(v) ? `${v}%` : null)
|
||||
|
||||
// The systems block is a flat bag of booleans; these are their display names, and
|
||||
// the order here is the order they render. A key the shard sends that we don't
|
||||
// know about still renders, humanised, rather than being silently dropped — a new
|
||||
// plugin must not go invisible against an older client.
|
||||
const SYSTEM_LABELS = {
|
||||
cityLoyalty: 'City Loyalty (governors)',
|
||||
vvv: 'Vice vs Virtue',
|
||||
factions: 'Factions',
|
||||
siege: 'Siege ruleset',
|
||||
chat: 'In-game chat',
|
||||
store: 'Ultima Store',
|
||||
dailyRares: 'Daily rares',
|
||||
honesty: 'Honesty virtue',
|
||||
shadowguard: 'Shadowguard',
|
||||
treasureMaps: 'Treasure maps',
|
||||
vetRewards: 'Veteran rewards',
|
||||
testCenter: 'Test Center',
|
||||
}
|
||||
|
||||
const humanise = (key) =>
|
||||
key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())
|
||||
|
||||
function Panel({ title, children }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: 18 }}>
|
||||
<h2
|
||||
className="display"
|
||||
style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// A label/value row. Rows whose value is null are dropped by the caller, so a
|
||||
// block never renders a dangling label for something the shard didn't publish.
|
||||
function Row({ label, value }) {
|
||||
return (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '5px 0',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
<span className="dim" style={{ minWidth: 0 }}>{label}</span>
|
||||
<strong style={{ flex: 'none', color: 'var(--head)' }}>{value}</strong>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Rows({ items }) {
|
||||
const rows = items.filter(([, value]) => value !== null && value !== undefined)
|
||||
if (rows.length === 0) return null
|
||||
return (
|
||||
<div>
|
||||
{rows.map(([label, value]) => (
|
||||
<Row key={label} label={label} value={value} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SystemPill({ label, on }) {
|
||||
const color = on ? '#8fdcae' : 'var(--muted)'
|
||||
return (
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 7,
|
||||
fontSize: '0.8rem',
|
||||
padding: '5px 11px',
|
||||
borderRadius: 999,
|
||||
color,
|
||||
background: on ? 'rgba(95,185,138,0.12)' : 'rgba(140,150,165,0.1)',
|
||||
border: `1px solid ${on ? 'rgba(95,185,138,0.4)' : 'var(--line)'}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{ width: 7, height: 7, borderRadius: '50%', background: color, flex: 'none' }}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Systems({ systems }) {
|
||||
// Known keys first in their declared order, then anything the shard added that
|
||||
// this build doesn't know about.
|
||||
const known = Object.keys(SYSTEM_LABELS).filter((k) => k in systems)
|
||||
const extra = Object.keys(systems).filter((k) => !(k in SYSTEM_LABELS))
|
||||
const keys = [...known, ...extra]
|
||||
if (keys.length === 0) return null
|
||||
return (
|
||||
<Panel title="Systems">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{keys.map((k) => (
|
||||
<SystemPill key={k} label={SYSTEM_LABELS[k] || humanise(k)} on={!!systems[k]} />
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Caps({ caps }) {
|
||||
return (
|
||||
<Panel title="Skill & stat caps">
|
||||
<Rows
|
||||
items={[
|
||||
['Individual skill cap', tenths(caps.skill)],
|
||||
['Total skill cap', tenths(caps.totalSkill)],
|
||||
['Total stat cap', num(caps.stat)],
|
||||
['Strength cap', num(caps.str)],
|
||||
['Dexterity cap', num(caps.dex)],
|
||||
['Intelligence cap', num(caps.int)],
|
||||
['Strength max', num(caps.strMax)],
|
||||
['Dexterity max', num(caps.dexMax)],
|
||||
['Intelligence max', num(caps.intMax)],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountsAndHousing({ accounts, housing, vetRewards }) {
|
||||
const items = []
|
||||
if (accounts) {
|
||||
items.push(['Accounts per IP', num(accounts.perIp)])
|
||||
items.push(['Character slots', num(accounts.charSlots)])
|
||||
items.push([
|
||||
'In-game account creation',
|
||||
accounts.autoCreate === undefined ? null : accounts.autoCreate ? 'Enabled' : 'Website only',
|
||||
])
|
||||
}
|
||||
if (housing) items.push(['Houses per account', num(housing.accountHouseLimit)])
|
||||
if (vetRewards?.enabled) {
|
||||
items.push(['Veteran reward interval', vetRewards.rewardIntervalDays
|
||||
? `${vetRewards.rewardIntervalDays} days`
|
||||
: null])
|
||||
}
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<Panel title="Accounts & housing">
|
||||
<Rows items={items} />
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Champions({ champions }) {
|
||||
const t = champions.rankThresholds
|
||||
return (
|
||||
<Panel title="Champion spawns">
|
||||
<Rows
|
||||
items={[
|
||||
['Power scrolls per spawn', num(champions.powerScrolls)],
|
||||
['Stat scrolls per spawn', num(champions.statScrolls)],
|
||||
['Scroll drop chance', pct(champions.scrollChance)],
|
||||
['Transcendence chance', pct(champions.transcendenceChance)],
|
||||
[
|
||||
'Red skulls per rank',
|
||||
Array.isArray(t) && t.length > 0 ? t.join(' · ') : null,
|
||||
],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Felucca({ loot }) {
|
||||
return (
|
||||
<Panel title="Felucca bonuses">
|
||||
<Rows
|
||||
items={[
|
||||
['Luck bonus', num(loot.feluccaLuckBonus)],
|
||||
['Loot budget bonus', num(loot.feluccaBudgetBonus)],
|
||||
['Max item properties', num(loot.feluccaMaxProps)],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Vendors({ vendors }) {
|
||||
return (
|
||||
<Panel title="Vendors">
|
||||
<Rows
|
||||
items={[
|
||||
['Restock delay', vendors.restockDelayMinutes
|
||||
? `${vendors.restockDelayMinutes} min`
|
||||
: null],
|
||||
['Max items sold at once', num(vendors.maxSell)],
|
||||
['Economy stock amount', num(vendors.economyStockAmount)],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Pvp({ vvv }) {
|
||||
return (
|
||||
<Panel title="Vice vs Virtue">
|
||||
<Rows
|
||||
items={[
|
||||
['Starting silver', num(vvv.startSilver)],
|
||||
['Enhanced rules', vvv.enhancedRules === undefined
|
||||
? null
|
||||
: vvv.enhancedRules ? 'On' : 'Off'],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Schedule({ schedule }) {
|
||||
const items = []
|
||||
if (schedule.autoSaveEnabled && schedule.autoSaveFrequencyMinutes) {
|
||||
items.push(['World save', `every ${schedule.autoSaveFrequencyMinutes} min`])
|
||||
} else if (schedule.autoSaveEnabled === false) {
|
||||
items.push(['World save', 'Disabled'])
|
||||
}
|
||||
if (schedule.autoRestartEnabled) {
|
||||
const h = String(schedule.autoRestartHour ?? 0).padStart(2, '0')
|
||||
const m = String(schedule.autoRestartMinute ?? 0).padStart(2, '0')
|
||||
items.push(['Automatic restart', `${h}:${m} server time`])
|
||||
if (schedule.autoRestartFrequencyHours) {
|
||||
items.push(['Restart interval', `every ${schedule.autoRestartFrequencyHours}h`])
|
||||
}
|
||||
}
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<Panel title="Save & restart schedule">
|
||||
<Rows items={items} />
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Rules() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.ruleset())
|
||||
const { events, connected } = useShardFeed({ filter: RULESET_KINDS, max: 4 })
|
||||
|
||||
// The newest world.ruleset on the feed wins outright over the fetched copy —
|
||||
// the frame is a complete ruleset, not a delta.
|
||||
const ruleset = useMemo(() => events[0] || data || null, [data, events])
|
||||
|
||||
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="Shard ruleset"
|
||||
lead="Published by the server itself, straight from its configuration — so it cannot drift from how the shard actually plays."
|
||||
/>
|
||||
<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 shard ruleset right now." />}
|
||||
|
||||
{!loading && !error && !ruleset && (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>
|
||||
The shard has not published its ruleset yet.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!loading && !error && ruleset && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Panel title="Shard">
|
||||
<Rows
|
||||
items={[
|
||||
['Name', ruleset.shard || null],
|
||||
['Expansion', ruleset.expansion || null],
|
||||
['Connect', ruleset.connect || null],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
{ruleset.systems && <Systems systems={ruleset.systems} />}
|
||||
{ruleset.caps && <Caps caps={ruleset.caps} />}
|
||||
<AccountsAndHousing
|
||||
accounts={ruleset.accounts}
|
||||
housing={ruleset.housing}
|
||||
vetRewards={ruleset.vetRewards}
|
||||
/>
|
||||
{ruleset.champions && <Champions champions={ruleset.champions} />}
|
||||
{ruleset.loot && <Felucca loot={ruleset.loot} />}
|
||||
{ruleset.vendors && <Vendors vendors={ruleset.vendors} />}
|
||||
{ruleset.vvv?.enabled && <Pvp vvv={ruleset.vvv} />}
|
||||
{ruleset.schedule && <Schedule schedule={ruleset.schedule} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
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, categoryOf, kindLabel, CATEGORIES } from '../../lib/shardEvents.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// Public activity feed: the full shard event log, filterable by category, with a
|
||||
// live tail that prepends new events as they happen.
|
||||
export default function ShardActivity() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.feed({ limit: 150 }))
|
||||
const { events: live } = useShardFeed({ max: 60 })
|
||||
const [cat, setCat] = useState('all')
|
||||
|
||||
// Merge the live tail with the loaded history, de-duped by kind+t, newest first.
|
||||
const merged = useMemo(() => {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const e of [...live, ...(data || [])]) {
|
||||
const key = `${e.kind}-${e.t}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
out.push(e)
|
||||
}
|
||||
return out.sort((a, b) => (b.t || 0) - (a.t || 0))
|
||||
}, [live, data])
|
||||
|
||||
const filtered = cat === 'all' ? merged : merged.filter((e) => categoryOf(e.kind) === cat)
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader eyebrow="Live" title="Shard Activity" />
|
||||
<p style={{ marginTop: -8, marginBottom: 18 }}>
|
||||
<Link to="/site/shard" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>← Back to shard</Link>
|
||||
</p>
|
||||
|
||||
{/* Category tabs */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 18 }}>
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setCat(c.id)}
|
||||
className="pill"
|
||||
style={cat === c.id ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : undefined}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the activity feed right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
filtered.length === 0 ? (
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.9rem' }}>Nothing here yet — events will appear as they happen in the world.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{filtered.map((e) => (
|
||||
<li key={e._id || `${e.kind}-${e.t}`} className="panel" style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>
|
||||
{kindLabel(e.kind)}
|
||||
</span>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', fontSize: '0.92rem' }}>{describe(e)}</span>
|
||||
<span className="sans dim" style={{ flex: 'none', fontSize: '0.76rem' }}>{ago(e.t)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user