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:
2026-08-11 18:39:55 -05:00
parent 5b5006c365
commit f7d27f7a06
50 changed files with 230 additions and 5629 deletions

View File

@@ -43,7 +43,6 @@ const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
const IconShard = () => <Icon><path d="M12 2l7 6-7 14-7-14z" /><path d="M5 8h14" /></Icon>
const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
@@ -76,8 +75,6 @@ export const NAV = [
items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] },
{ to: '/admin/houses', label: 'Houses', icon: IconShard, roles: ['admin', 'moderator'] },
],
},
{
@@ -91,15 +88,11 @@ export const NAV = [
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
{ to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] },
{ to: '/admin/shard-visibility', label: 'Shard Visibility', icon: IconShard, roles: ['admin'] },
{ to: '/admin/shard-atlas', label: 'Spawn Atlas', icon: IconShard, roles: ['admin'] },
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
],
},
{
items: [
{ to: '/admin/characters', label: 'My Characters', icon: IconShard },
{ to: '/admin/account', label: 'Account', icon: IconUser },
],
},
@@ -136,18 +129,12 @@ const TITLES = {
'/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation',
'/admin/moderation/appeals': 'Appeals',
'/admin/shard-ops': 'In-Game Ops',
'/admin/houses': 'House Registry',
'/admin/settings': 'Site Settings',
'/admin/appearance': 'Appearance',
'/admin/navigation': 'Navigation',
'/admin/activity': 'Activity Log',
'/admin/bot-activity': 'Web Bot Activity',
'/admin/discord-bot': 'Discord Bot',
'/admin/shard': 'Shard (uo-link)',
'/admin/shard-visibility': 'Shard Visibility',
'/admin/shard-atlas': 'Spawn Atlas',
'/admin/characters': 'My Characters',
'/admin/auth-providers': 'Authentication',
'/admin/users': 'Users',
'/admin/invites': 'Invites',
@@ -170,7 +157,6 @@ function moduleTitle(baseNav, pathname) {
// Fallback page title for dynamic sub-routes not in the exact-match TITLES map.
function sectionTitle(pathname) {
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
if (pathname.startsWith('/admin/characters')) return 'My Characters'
if (pathname.startsWith('/admin/users/')) return 'User'
return 'Admin'
}

View File

@@ -1,29 +0,0 @@
import { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import CharacterSheet from '../../../components/CharacterSheet.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { api } from '../../../api/client.js'
// A staff member's own character sheet inside the admin shell. Owner-checked —
// the endpoint only returns a sheet for a character on the caller's linked account.
export default function AdminCharacter() {
const { serial } = useParams()
const { loading, error, data } = useAsync(() => api.admin.shard.char(serial), [serial])
const restarting = error && error.status === 503
const forbidden = error && error.status === 403
return (
<div style={{ maxWidth: 760 }}>
<p style={{ margin: '0 0 18px' }}>
<Link to="/admin/characters" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
Back to my characters
</Link>
</p>
{loading && <Loading />}
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
{!loading && !error && data && <CharacterSheet char={data} moderation />}
</div>
)
}

View File

@@ -1,18 +0,0 @@
import CharacterStats from '../../../components/CharacterStats.jsx'
import GameAccounts from '../../../components/GameAccounts.jsx'
import VendorSales from '../../../components/VendorSales.jsx'
import { api } from '../../../api/client.js'
// Staff link their OWN in-game account and view their characters — the same
// shared component players use, pointed at the staff self-service endpoints.
// Sits inside the Admin shell, which supplies the "My Characters" page header;
// stat tiles bring it to parity with the Player Portal's Characters page.
export default function AdminCharacters() {
return (
<section style={{ maxWidth: 760 }}>
<CharacterStats scope={api.admin.shard} />
<GameAccounts scope={api.admin.shard} charTo={(serial) => `/admin/characters/${serial}`} />
<VendorSales fetchSales={api.admin.shard.sales} />
</section>
)
}

View File

@@ -1,119 +0,0 @@
import { useMemo, useState } from 'react'
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'
// Staff-only FULL house registry (admin + moderator). Owner, price, co-owners and
// decay — everything the public board hides. Loaded from /admin/shard/houses, kept
// live from the admin SSE channel (house.update / house.remove).
const HOUSE_KINDS = new Set(['house.update', 'house.remove', 'house.decay'])
const DECAY_TONE = {
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
}
function DecayBadge({ decay, isIdoc }) {
const label = isIdoc ? 'IDOC' : decay
if (!label) return null
const tone = DECAY_TONE[label] || 'var(--muted)'
return (
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 8px' }}>
{label}
</span>
)
}
function ownerLabel(h) {
return h.ownerName || h.ownerAcct || null
}
function HouseRow({ h }) {
const owner = ownerLabel(h)
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: 'center', gap: 8, minWidth: 0 }}>
<strong className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{h.name || 'An unnamed house'}
</strong>
<DecayBadge decay={h.decay} isIdoc={h.isIdoc} />
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
{owner ? <>Owned by <span style={{ color: 'var(--ink)' }}>{owner}</span></> : 'No owner'}
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
</div>
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
{h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''}
</div>
</div>
{h.price != null && (
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
<div style={{ fontSize: '0.92rem', color: 'var(--head)', fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()}</div>
<div className="dim" style={{ fontSize: '0.64rem', letterSpacing: '0.04em', textTransform: 'uppercase' }}>placement value</div>
</div>
)}
</div>
)
}
export default function HousesAdmin() {
const { loading, error, data } = useAsync(() => api.admin.shard.houses())
// Full registry deltas ride the admin SSE channel (never the public one).
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, filter: HOUSE_KINDS, max: 80 })
const [q, setQ] = useState('')
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.serial) continue
if (ev.kind === 'house.update') {
map.set(ev.serial, { ...ev, ownerName: ev.owner?.name ?? ev.ownerName, ownerAcct: ev.owner?.acct ?? ev.ownerAcct })
} else if (ev.kind === 'house.remove') {
map.delete(ev.serial)
} else if (ev.kind === 'house.decay') {
const cur = map.get(ev.serial) || { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y }
map.set(ev.serial, { ...cur, isIdoc: String(ev.to).toUpperCase() === 'IDOC' })
}
}
return [...map.values()]
}, [data, events])
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase()
const rows = needle
? board.filter((h) => [h.name, h.region, h.map, ownerLabel(h)].some((v) => v && String(v).toLowerCase().includes(needle)))
: board
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
}, [board, q])
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load the house registry." />
return (
<section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.82rem', margin: 0 }}>
{board.length.toLocaleString()} houses
<span className="dim" style={{ marginLeft: 10, color: connected ? '#7fd0a4' : 'var(--muted)' }}>{connected ? '● live' : '○ offline'}</span>
</p>
<input className="input sans" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by owner, region…" style={{ flex: 'none', width: 230, maxWidth: '55%', fontSize: '0.84rem' }} />
</div>
{board.length === 0 ? (
<div className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No houses are being tracked right now.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map((h) => <HouseRow key={h.serial} h={h} />)}
</div>
)}
{board.length > 0 && filtered.length === 0 && (
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No houses match {q}.</p>
)}
</section>
)
}

View File

@@ -35,18 +35,6 @@ const FIELDS = [
],
fallback: 'disabled',
},
{
key: 'game_account_signup',
label: 'Game-account creation',
help: 'Whether players can create a GAME account (for the game client) from the site. The game servers own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them. When enabled, a “Create a game account” form appears in the player portal.',
options: [
{ value: 'disabled', label: 'Disabled — link an existing account only' },
{ value: 'website', label: 'Website — the site creates game accounts' },
{ value: 'hybrid', label: 'Hybrid — site or in-game (recommended)' },
{ value: 'game', label: 'Game only — created in the game client, not the site' },
],
fallback: 'disabled',
},
]
export default function SettingsAdmin() {

View File

@@ -1,248 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useShardFeed } from '../../../lib/useShardFeed.js'
import { describe, kindLabel } from '../../../lib/shardEvents.js'
import { ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// Full live feed from the admin SSE channel — every kind, incl. staff audit,
// cheat detection and login attempts that the public channel never carries.
function AdminLiveFeed() {
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, max: 60 })
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Live feed (all events)</h3>
<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>
{events.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Waiting for shard events</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 360, overflowY: 'auto' }}>
{events.map((e) => (
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
<span className="sans" style={{ flex: 'none', fontSize: '0.6rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>{kindLabel(e.kind)}</span>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
</li>
))}
</ul>
)}
</section>
)
}
// uo-link sidecar control panel. The auth token is write-only over this API —
// stored encrypted, never returned — same convention as the Discord bot token.
// Saving (re)starts the WS ingest client, so Enabled/URL/token changes take
// effect immediately with no redeploy.
function Toggle({ checked, onChange, label }) {
return (
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
{label}
</label>
)
}
const STATUS_COLOR = {
connected: '#7fd0a4',
reconnecting: '#e0b070',
error: '#d98b84',
disconnected: 'var(--muted)',
}
function StatusPanel({ config }) {
const color = STATUS_COLOR[config.status] || 'var(--muted)'
const ingest = config.ingest || {}
const health = config.health || {}
return (
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
{config.status || 'disconnected'}
</span>
</div>
{config.statusDetail && (
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
)}
<div className="sans dim" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 16px', fontSize: '0.78rem', marginTop: 2 }}>
<span>Shard link: <strong style={{ color: 'var(--ink)' }}>{config.pluginConnected ? 'up' : 'down'}</strong></span>
<span>WS ingest: <strong style={{ color: 'var(--ink)' }}>{ingest.connected ? 'connected' : 'offline'}</strong></span>
<span>Reconnects: <strong style={{ color: 'var(--ink)' }}>{ingest.reconnects ?? 0}</strong></span>
<span>SSE clients: <strong style={{ color: 'var(--ink)' }}>{(config.sse?.publicClients ?? 0) + (config.sse?.adminClients ?? 0)}</strong></span>
{config.lastEventAt && <span style={{ gridColumn: '1 / -1' }}>Last event: {new Date(config.lastEventAt).toLocaleString()}</span>}
{health.uptime && <span style={{ gridColumn: '1 / -1' }}>Sidecar uptime: {health.uptime}</span>}
</div>
</div>
)
}
// ── Town crier ──────────────────────────────────────────────────────────────
function TownCrier() {
const [id, setId] = useState('')
const [text, setText] = useState('')
const [durationSec, setDurationSec] = useState(3600)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function post() {
setBusy(true); setMsg(''); setError('')
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
if (!id.trim() || lines.length === 0) {
setBusy(false)
return setError('An id and at least one line are required.')
}
try {
await api.admin.postTownCrier({ id: id.trim(), lines, durationSec: Number(durationSec) || undefined })
setMsg(`Posted “${id.trim()}”.`)
} catch (err) {
setError(err.message || 'Could not post.')
} finally {
setBusy(false)
}
}
async function remove() {
if (!id.trim()) return setError('Enter the id to remove.')
setBusy(true); setMsg(''); setError('')
try {
await api.admin.deleteTownCrier(id.trim())
setMsg(`Removed “${id.trim()}”.`)
} catch (err) {
setError(err.message || 'Could not remove.')
} finally {
setBusy(false)
}
}
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Town crier</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
Broadcast a message that every in-game town crier announces until it expires. Re-posting the same id replaces it.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Message id</span>
<input type="text" value={id} onChange={(e) => setId(e.target.value)} className="input" placeholder="news-42" autoComplete="off" style={{ maxWidth: 220 }} />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Lines (one per line)</span>
<textarea value={text} onChange={(e) => setText(e.target.value)} className="input" rows={3} placeholder={'Hear ye!\nMarket tax is now 5%.'} style={{ resize: 'vertical' }} />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Duration (seconds)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={1} max={86400} style={{ maxWidth: 160 }} />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={post} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Working…' : 'Post message'}</button>
<button onClick={remove} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>Remove by id</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
</section>
)
}
export default function ShardAdmin() {
const [config, setConfig] = useState(null)
const [error, setError] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [wsUrl, setWsUrl] = useState('')
const [token, setToken] = useState('')
const [protocol, setProtocol] = useState(3)
const [enabled, setEnabled] = useState(false)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [saveError, setSaveError] = useState('')
const pollRef = useRef(null)
const initializedRef = useRef(false)
const load = useCallback(async () => {
try {
const c = await api.admin.getUoLinkConfig()
setConfig(c)
// Seed the editable fields once; later polls only refresh the status panel
// so they never clobber what the admin is mid-typing.
if (!initializedRef.current) {
setBaseUrl(c.baseUrl || '')
setWsUrl(c.wsUrl || '')
setProtocol(c.protocol || 3)
setEnabled(c.enabled)
initializedRef.current = true
}
} catch {
setError('Could not load uo-link config.')
}
}, [])
useEffect(() => {
load()
pollRef.current = setInterval(load, 5000)
return () => clearInterval(pollRef.current)
}, [load])
async function save() {
setBusy(true); setMsg(''); setSaveError('')
try {
const body = { baseUrl, wsUrl, protocol: Number(protocol), enabled }
if (token) body.token = token
const saved = await api.admin.saveUoLinkConfig(body)
setConfig(saved)
setToken('')
setMsg('Saved.')
} catch (err) {
setSaveError(err.message || 'Could not save.')
} finally {
setBusy(false)
}
}
if (error) return <ErrorState message={error} />
if (!config) return <Loading />
return (
<section style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 20 }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Shard (uo-link)</h2>
<StatusPanel config={config} />
<Toggle checked={enabled} onChange={setEnabled} label="Enable the shard integration" />
<label style={{ display: 'block' }}>
<span className="field-label">Base URL (REST)</span>
<input type="text" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} className="input" autoComplete="off" placeholder="http://127.0.0.1:8080" />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">WebSocket URL (feed)</span>
<input type="text" value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} className="input" autoComplete="off" placeholder="ws://127.0.0.1:8080/ws" />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Auth token</span>
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} className="input" autoComplete="new-password" placeholder={config.hasToken ? '•••••••• configured — leave blank to keep' : 'Shared secret from sidecar.toml'} />
</label>
<label style={{ display: 'block', maxWidth: 140 }}>
<span className="field-label">Protocol</span>
<input type="number" value={protocol} onChange={(e) => setProtocol(e.target.value)} className="input" min={1} max={99} />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Saving…' : 'Save changes'}</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{saveError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{saveError}</span>}
</div>
<TownCrier />
<AdminLiveFeed />
</section>
)
}

View File

@@ -1,291 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useShardFeed } from '../../../lib/useShardFeed.js'
import { describe } from '../../../lib/shardEvents.js'
import { ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// In-game staff operations: the uo-link write plane (broadcast / kick / ban /
// unban) and the help-page support queue, plus a live audit log. Open to admins
// and moderators. The acting staff member (`actor`) is attached server-side from
// the session — nothing here sends it — so every action is attributable.
function Flash({ ok, err }) {
if (ok) return <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{ok}</span>
if (err) return <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>
return null
}
// ── Broadcast ────────────────────────────────────────────────────────────────
function Broadcast() {
const [text, setText] = useState('')
const [hue, setHue] = useState('')
const [busy, setBusy] = useState(false)
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
async function send() {
if (!text.trim()) return setErr('Enter a message.')
setBusy(true); setOk(''); setErr('')
try {
await api.admin.shardOps.broadcast({ text: text.trim(), hue: hue === '' ? undefined : Number(hue) })
setOk('Broadcast sent.')
setText('')
} catch (e) {
setErr(e.message || 'Could not broadcast.')
} finally {
setBusy(false)
}
}
return (
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Broadcast</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
A system message shown to everyone online right now.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Message</span>
<input type="text" value={text} onChange={(e) => setText(e.target.value)} className="input" maxLength={300} placeholder="Server restart in 5 minutes" autoComplete="off" />
</label>
<label style={{ display: 'block', maxWidth: 140 }}>
<span className="field-label">Hue (optional)</span>
<input type="number" value={hue} onChange={(e) => setHue(e.target.value)} className="input" min={0} max={3000} placeholder="53" />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={send} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Sending…' : 'Broadcast'}</button>
<Flash ok={ok} err={err} />
</div>
</section>
)
}
// ── Account actions (kick / ban / unban) ─────────────────────────────────────
function AccountActions() {
const [account, setAccount] = useState('')
const [durationSec, setDurationSec] = useState('')
const [reason, setReason] = useState('')
const [busy, setBusy] = useState('')
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
const acct = account.trim()
function guard() {
if (!acct) {
setErr('Enter an account name.')
return false
}
return true
}
async function run(label, fn, done) {
if (!guard()) return
setBusy(label); setOk(''); setErr('')
try {
const r = await fn()
setOk(done(r))
} catch (e) {
setErr(e.message || 'Action failed.')
} finally {
setBusy('')
}
}
const kick = () =>
run('kick', () => api.admin.shardOps.kick({ account: acct }), (r) => {
const n = r?.sessions != null ? r.sessions : null
const plural = n === 1 ? '' : 's'
const sessions = n != null ? ` (${n} session${plural})` : ''
return `Kicked ${acct}${sessions}.`
})
const ban = () =>
run(
'ban',
() =>
api.admin.shardOps.ban({
account: acct,
durationSec: durationSec === '' ? undefined : Number(durationSec),
reason: reason.trim() || undefined,
}),
() => {
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
return `Banned ${acct}${when}.`
},
)
const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`)
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Account actions</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
Kick, ban or unban a game account. Bans work even if the account is offline; the shard refuses to act on staff at or above co-owner.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Account</span>
<input type="text" value={account} onChange={(e) => setAccount(e.target.value)} className="input" placeholder="griefer42" autoComplete="off" style={{ maxWidth: 260 }} />
</label>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label style={{ display: 'block', maxWidth: 200 }}>
<span className="field-label">Ban duration (seconds, blank = permanent)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" />
</label>
<label style={{ display: 'block', flex: 1, minWidth: 200 }}>
<span className="field-label">Ban reason (optional)</span>
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
</label>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={kick} disabled={!!busy} className="btn btn-sq">{busy === 'kick' ? 'Kicking…' : 'Kick'}</button>
<button onClick={ban} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'ban' ? 'Banning…' : 'Ban'}</button>
<button onClick={unban} disabled={!!busy} className="btn btn-sq">{busy === 'unban' ? 'Unbanning…' : 'Unban'}</button>
<Flash ok={ok} err={err} />
</div>
</section>
)
}
// ── Support (help-page) queue ────────────────────────────────────────────────
function PageRow({ page, onDone }) {
const [message, setMessage] = useState('')
const [busy, setBusy] = useState('')
const [err, setErr] = useState('')
async function respond(close) {
if (!message.trim()) return setErr('Enter a reply first.')
setBusy(close ? 'respond-close' : 'respond'); setErr('')
try {
await api.admin.shardOps.respondPage(page.pageId, { message: message.trim(), close })
onDone()
} catch (e) {
setErr(e.message || 'Could not send.')
setBusy('')
}
}
async function close() {
setBusy('close'); setErr('')
try {
await api.admin.shardOps.closePage(page.pageId)
onDone()
} catch (e) {
setErr(e.message || 'Could not close.')
setBusy('')
}
}
return (
<div className="panel" style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ minWidth: 0 }}>
<span className="sans" style={{ fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)' }}>{page.type || 'Page'}</span>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{page.sender?.name || page.pageId}
{page.handled && <span className="dim" style={{ fontSize: '0.72rem' }}> · claimed{page.handler ? ` by ${page.handler}` : ''}</span>}
</div>
</div>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{page.sentMs ? ago(page.sentMs) : ''}</span>
</div>
{page.message && <p className="sans" style={{ margin: 0, color: 'var(--ink)', fontSize: '0.88rem', lineHeight: 1.5 }}>{page.message}</p>}
<div className="sans dim" style={{ fontSize: '0.72rem' }}>
{page.map || '—'}{page.x != null ? ` (${page.x}, ${page.y})` : ''}
</div>
<textarea value={message} onChange={(e) => setMessage(e.target.value)} className="input" rows={2} placeholder="A GM is on the way." style={{ resize: 'vertical' }} />
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={() => respond(false)} disabled={!!busy} className="btn btn-sq">{busy === 'respond' ? 'Sending…' : 'Reply'}</button>
<button onClick={() => respond(true)} disabled={!!busy} className="btn btn-primary btn-sq">{busy === 'respond-close' ? 'Sending…' : 'Reply & close'}</button>
<button onClick={close} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'close' ? 'Closing…' : 'Close'}</button>
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
</div>
</div>
)
}
function SupportQueue() {
const [pages, setPages] = useState(null)
const [err, setErr] = useState('')
const pollRef = useRef(null)
const load = useCallback(async () => {
try {
setPages(await api.admin.shardOps.pages())
} catch {
setErr('Could not load the support queue.')
}
}, [])
useEffect(() => {
load()
pollRef.current = setInterval(load, 7000)
return () => clearInterval(pollRef.current)
}, [load])
let queueBody
if (pages == null) {
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading</p>
} else if (pages.length === 0) {
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>The queue is empty.</p>
} else {
queueBody = (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{pages.map((p) => <PageRow key={p.pageId} page={p} onDone={load} />)}
</div>
)
}
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Support queue</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
Open help pages from players. A reply reaches them in game (or on their next login).
</p>
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>}
{queueBody}
</section>
)
}
// ── Audit log ────────────────────────────────────────────────────────────────
// Seeded from the stored admin.audit history, then kept live from the admin SSE
// channel (which carries every kind — we filter to admin.audit here).
function AuditLog() {
const [seed, setSeed] = useState([])
const { events } = useShardFeed({ url: api.adminShardStreamUrl, filter: new Set(['admin.audit']), max: 50 })
useEffect(() => {
api.admin.shardOps
.audit(50)
.then((rows) => setSeed(rows.map((r) => ({ ...r, _id: `seed-${r.id}` }))))
.catch(() => setSeed([]))
}, [])
// Live events on top; fall back to the seed for anything older than the live tail.
const oldestLive = events.length ? Math.min(...events.map((e) => e.t || 0)) : Infinity
const rows = [...events, ...seed.filter((s) => (s.t || 0) < oldestLive)].slice(0, 60)
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)', marginBottom: 12 }}>Audit log</h3>
{rows.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No moderation actions recorded yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 320, overflowY: 'auto' }}>
{rows.map((e) => (
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
</li>
))}
</ul>
)}
</section>
)
}
export default function ShardOps() {
return (
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 22 }}>
<Broadcast />
<AccountActions />
<SupportQueue />
<AuditLog />
</section>
)
}

View File

@@ -1,325 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// ── Admin · Shard visibility ────────────────────────────────────────────────
//
// Who may see which shard surface, and which sensitive fields within it.
// Admin-only, because this decides what ANONYMOUS visitors get.
//
// Two things the UI must communicate honestly, because they are not negotiable
// server-side (see docs/link/v3.md §3.4):
// • acct / webId are admin-only always and are not listed as editable fields.
// • an event kind the server doesn't know about never reaches anyone below
// admin, whatever is set here.
//
// Defaults reproduce the behavior the site had before this panel existed, so a
// fresh install shows "everything as it was" rather than an empty form.
const RUNG_LABEL = {
anonymous: 'Everyone',
logged_in: 'Signed in',
player: 'Linked players',
staff: 'Staff',
admin: 'Admins only',
}
const RUNG_HINT = {
anonymous: 'Visible to anyone, signed in or not.',
logged_in: 'Any signed-in account, linked or not.',
player: 'Accounts with a linked game account. Staff always qualify.',
staff: 'Admins and moderators.',
admin: 'Admins only.',
}
const FEATURE_LABEL = {
status: 'Shard status',
activity: 'Activity feed',
champs: 'Champion spawns',
guilds: 'Guilds',
governors: 'Town governors',
houses: 'Houses / IDOC',
presence: 'Players online',
ruleset: 'Shard rules',
atlas: 'Spawn atlas',
leaderboards: 'Leaderboards',
market: 'Marketplace',
}
const FEATURE_HINT = {
status: 'Connection state, online count, gold-supply series.',
activity: 'Deaths, kills, skill gains, quests, logins.',
champs: 'The live champion / mini-champ / sea-boss board.',
guilds: 'Guild rosters, alliances and leaders.',
governors: 'City Loyalty governors, elections and term history.',
houses: 'Houses in danger (IDOC). Owner and price are separate fields below.',
presence: 'Population aggregate and the staff-online widget.',
ruleset: 'Skill/stat caps, house limits, vet rewards and the rest of the ruleset.',
atlas: 'The spawn atlas and bestiary. Static shard content, not live state.',
leaderboards: 'Point and loyalty standings across every points system.',
market: 'The shard-wide player-vendor index.',
}
const FIELD_LABEL = {
owner: 'House owner',
price: 'House price',
location: 'In-game location (map + coordinates)',
connect: 'Server connect address',
// Keyed on the WIRE field, which for a leaderboard entry is `name` — the
// projection matches literal JSON keys, so the rule cannot be spelled after the
// field's meaning. The label is what carries the meaning to the admin.
name: 'Character names on leaderboards',
ownerName: 'Vendor owner name',
// One rule, one key — `location` is a nested object on both the wire frame and
// the stored read model precisely so that hiding it takes the facet, the
// coordinates, the region and the house together.
ownerSerial: 'Vendor owner character id',
}
function RungSelect({ value, onChange, ladder, disabled }) {
return (
<select
className="input"
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
style={{ maxWidth: 200 }}
>
{ladder.map((rung) => (
<option key={rung} value={rung}>
{RUNG_LABEL[rung] || rung}
</option>
))}
</select>
)
}
function FeatureRow({ name, settings, defaults, ladder, onPatch }) {
const fields = Object.entries(settings.fields || {})
const changed =
defaults &&
(settings.enabled !== defaults.enabled ||
settings.audience !== defaults.audience ||
settings.stream !== defaults.stream ||
JSON.stringify(settings.fields) !== JSON.stringify(defaults.fields))
return (
<div
style={{
border: '1px solid var(--line)',
borderRadius: 10,
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 12,
opacity: settings.enabled ? 1 : 0.62,
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<div style={{ minWidth: 0 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
{FEATURE_LABEL[name] || name}
{changed && (
<span
className="sans"
style={{ marginLeft: 8, fontSize: '0.62rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)' }}
>
changed
</span>
)}
</h3>
<p className="sans" style={{ margin: '4px 0 0', fontSize: '0.82rem', color: 'var(--muted)', lineHeight: 1.5 }}>
{FEATURE_HINT[name]}
</p>
</div>
<label
className="sans"
style={{ flex: 'none', display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)' }}
>
<input
type="checkbox"
checked={settings.enabled}
onChange={(e) => onPatch(name, { enabled: e.target.checked })}
/>
Enabled
</label>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 20, alignItems: 'flex-end' }}>
<label style={{ display: 'block' }}>
<span className="field-label">Who can see it</span>
<RungSelect
value={settings.audience}
ladder={ladder}
disabled={!settings.enabled}
onChange={(audience) => onPatch(name, { audience })}
/>
<span className="sans dim" style={{ display: 'block', marginTop: 4, fontSize: '0.75rem' }}>
{RUNG_HINT[settings.audience]}
</span>
</label>
<label
className="sans"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)', paddingBottom: 22 }}
>
<input
type="checkbox"
checked={settings.stream}
disabled={!settings.enabled}
onChange={(e) => onPatch(name, { stream: e.target.checked })}
/>
Live updates
</label>
</div>
{fields.length > 0 && (
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 12 }}>
<span className="field-label" style={{ display: 'block', marginBottom: 8 }}>
Sensitive fields
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
{fields.map(([field, rung]) => (
<label key={field} style={{ display: 'block' }}>
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginBottom: 4 }}>
{FIELD_LABEL[field] || field}
</span>
<RungSelect
value={rung}
ladder={ladder}
disabled={!settings.enabled}
onChange={(level) =>
onPatch(name, { fieldRules: { ...settings.fields, [field]: level } })
}
/>
</label>
))}
</div>
</div>
)}
</div>
)
}
export default function ShardVisibility() {
const [config, setConfig] = useState(null)
const [defaults, setDefaults] = useState(null)
const [ladder, setLadder] = useState([])
const [lockedFields, setLockedFields] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const data = await api.admin.getShardVisibility()
setConfig(data.features)
setDefaults(data.defaults)
setLadder(data.ladder || [])
setLockedFields(data.lockedFields || [])
} catch (err) {
setError(err.message || 'Could not load visibility settings.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
function patch(name, changes) {
setMsg('')
setConfig((prev) => {
const next = { ...prev[name], ...changes }
// `fieldRules` in the API is `fields` in the effective config.
if (changes.fieldRules) {
next.fields = changes.fieldRules
delete next.fieldRules
}
return { ...prev, [name]: next }
})
}
async function save() {
setSaving(true)
setMsg('')
setError('')
try {
const body = {}
for (const [name, s] of Object.entries(config)) {
body[name] = {
enabled: s.enabled,
audience: s.audience,
stream: s.stream,
fieldRules: s.fields || {},
}
}
const data = await api.admin.saveShardVisibility(body)
setConfig(data.features)
setMsg('Saved. Changes take effect within a few seconds, including on open live streams.')
} catch (err) {
setError(err.message || 'Could not save.')
} finally {
setSaving(false)
}
}
function resetToDefaults() {
setMsg('')
setConfig(structuredClone(defaults))
}
if (loading) return <Loading />
if (error && !config) return <ErrorState message={error} onRetry={load} />
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<header>
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
Shard visibility
</h2>
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
Choose who can see each shard surface on the public site, and how much detail they get.
Turning a feature off hides it entirely its pages return not found rather than
revealing that it exists. Live updates controls whether the feature streams changes in
real time; the pages still work without it, they just refresh on load.
</p>
{lockedFields.length > 0 && (
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.82rem', lineHeight: 1.6, maxWidth: 760 }}>
Not configurable: <strong style={{ color: 'var(--ink)' }}>{lockedFields.join(', ')}</strong>
game account names and website user ids are never shown below admin, on any surface. They
arent visible in game either, so publishing them would disclose something the shard
itself doesnt.
</p>
)}
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{Object.entries(config).map(([name, settings]) => (
<FeatureRow
key={name}
name={name}
settings={settings}
defaults={defaults?.[name]}
ladder={ladder}
onPatch={patch}
/>
))}
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={saving} className="btn btn-primary btn-sq">
{saving ? 'Saving…' : 'Save changes'}
</button>
<button onClick={resetToDefaults} disabled={saving} className="btn btn-sq">
Restore defaults
</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
</div>
)
}

View File

@@ -1,285 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// ── Admin · Spawn atlas ─────────────────────────────────────────────────────
//
// The atlas re-derives itself from the shard's ServUO tree on every boot, so
// this panel exists for the three things a restart cannot do:
//
// • point it at a different tree,
// • apply a map change without restarting, and
// • answer a refresh that was parsed but deliberately NOT applied because it
// would remove a facet.
//
// That last one is the reason the panel is worth building. Losing a facet looks
// exactly like a half-copied or mid-update tree, and boot cannot tell them
// apart — so it stages the decision for a human instead of guessing. Until
// someone decides here, the site keeps serving the atlas it already had.
// A refresh reports its outcome rather than throwing (the boot path must never
// be stopped by a bad tree), so these are answers, not errors — the panel says
// what happened in the shard's terms instead of showing a failure box.
const OUTCOME = {
imported: (r) =>
`Imported — ${r.counts?.points?.toLocaleString() ?? '?'} spawners, ${r.counts?.creatures?.toLocaleString() ?? '?'} creatures.`,
unchanged: (r) =>
r.reason === 'refresh previously rejected'
? 'Unchanged — this exact tree was already reviewed and declined.'
: 'Unchanged — the tree matches what is already loaded.',
needsReview: () => 'Staged for review: this refresh would remove a facet, so it was not applied.',
unavailable: (r) => `The tree could not be read: ${r.reason || 'unknown reason'}`,
skipped: () => 'No ServUO path is configured, so there is nothing to import.',
failed: (r) => `Refresh failed: ${r.reason || 'unknown reason'}`,
rejected: () => 'Declined. It will not be offered again until the tree changes.',
}
const describe = (result) => (OUTCOME[result?.status] || (() => `Result: ${result?.status}`))(result)
function Row({ label, children }) {
return (
<div
className="sans"
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 16,
padding: '7px 0',
borderBottom: '1px solid var(--line)',
fontSize: '0.86rem',
}}
>
<span className="dim">{label}</span>
<span style={{ color: 'var(--head)', textAlign: 'right', wordBreak: 'break-all' }}>{children}</span>
</div>
)
}
function PendingReview({ pending, busy, onApprove, onReject }) {
const declined = pending.status === 'rejected'
return (
<section
style={{
border: `1px solid ${declined ? 'var(--line)' : '#c58f4a'}`,
borderRadius: 10,
padding: 16,
background: declined ? 'transparent' : 'rgba(197,143,74,0.08)',
}}
>
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
{declined ? 'A refresh was declined' : 'A refresh is waiting for you'}
</h3>
<p className="sans" style={{ margin: '6px 0 12px', fontSize: '0.86rem', color: 'var(--muted)', lineHeight: 1.6 }}>
{declined ? (
<>
This tree was reviewed and declined, so it is not offered again until the files change.
Approving now applies it anyway.
</>
) : (
<>
The tree parses cleanly but would <strong>remove {pending.removedFacets?.length || 0} facet
</strong>
{(pending.removedFacets?.length || 0) === 1 ? '' : 's'} the site is currently serving. That
is what a half-copied or mid-update tree looks like as well as a real map change, so it was
not applied. Approving re-parses the tree as it is right now if you have since fixed the
mount, what lands is the corrected import.
</>
)}
</p>
<Row label="Would remove">{(pending.removedFacets || []).join(', ') || '—'}</Row>
<Row label="Would add">{(pending.addedFacets || []).join(', ') || '—'}</Row>
<Row label="Detected">{pending.detectedAt ? new Date(pending.detectedAt).toLocaleString() : '—'}</Row>
<div style={{ display: 'flex', gap: 10, marginTop: 14, flexWrap: 'wrap' }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={onApprove}>
Approve and import
</button>
{!declined && (
<button type="button" className="btn btn-sq" disabled={busy} onClick={onReject}>
Keep the current atlas
</button>
)}
</div>
</section>
)
}
export default function SpawnAtlas() {
const [status, setStatus] = useState(null)
const [path, setPath] = useState('')
const [force, setForce] = useState(false)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const data = await api.admin.atlas.status()
setStatus(data)
setPath(data.path || '')
} catch (err) {
setError(err.message || 'Could not load atlas status.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
// Every mutating action shares this: run it, report what it said, then reload
// status so the panel reflects the world rather than what we assumed happened.
async function run(action, fn) {
setBusy(true)
setMsg('')
setError('')
try {
const result = await fn()
setMsg(describe(result))
const fresh = await api.admin.atlas.status()
setStatus(fresh)
setPath(fresh.path || '')
} catch (err) {
setError(err.message || `Could not ${action}.`)
} finally {
setBusy(false)
}
}
async function savePath() {
setBusy(true)
setMsg('')
setError('')
try {
const fresh = await api.admin.atlas.setPath(path.trim())
setStatus(fresh)
setPath(fresh.path || '')
setMsg(
fresh.path === ''
? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.'
: fresh.treeReadable
? 'Saved. The tree is readable — import when you are ready.'
: 'Saved, but the tree could not be read from here. Check the mount and permissions.',
)
} catch (err) {
setError(err.message || 'Could not save the path.')
} finally {
setBusy(false)
}
}
if (loading) return <Loading />
if (error && !status) return <ErrorState message={error} />
const counts = status?.counts || null
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<header>
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
Spawn atlas
</h2>
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
The bestiary and spawn map on the public site, parsed from the shards own ServUO files.
It refreshes itself on every server start; everything here is for the times you dont want
to wait for one. Nothing on this page touches the sidecar the atlas is shard content, not
shard state, and stays complete while the shard is down.
</p>
</header>
{status?.pending && (
<PendingReview
pending={status.pending}
busy={busy}
onApprove={() => run('approve the refresh', () => api.admin.atlas.approve())}
onReject={() => run('decline the refresh', () => api.admin.atlas.reject())}
/>
)}
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 10px', fontSize: '1rem', color: 'var(--head)' }}>
What is loaded
</h3>
<Row label="Imported">
{status?.importedAt ? new Date(status.importedAt).toLocaleString() : 'Never'}
</Row>
<Row label="Facets">{status?.facets?.length ? status.facets.join(', ') : '—'}</Row>
{counts && (
<>
<Row label="Spawners">{counts.points?.toLocaleString() ?? '—'}</Row>
<Row label="Creatures">{counts.creatures?.toLocaleString() ?? '—'}</Row>
<Row label="Regions / landmarks">
{`${counts.regions?.toLocaleString() ?? '—'} / ${counts.landmarks?.toLocaleString() ?? '—'}`}
</Row>
<Row label="Champion altars">{counts.champions?.toLocaleString() ?? '—'}</Row>
</>
)}
<Row label="Tree readable">
{!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'}
</Row>
<Row label="Tree changed since import">
{status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'}
</Row>
</section>
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
ServUO tree
</h3>
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
Where the website reads the shards spawn files from the same host, a bind mount or a
shared volume. This setting wins over the <code>SERVUO_PATH</code> deploy default, so the
mount can move without a redeploy. Leave it blank to turn the atlas off.
</p>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
<input
className="input"
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="/srv/servuo"
style={{ flex: '1 1 320px', minWidth: 0 }}
/>
<button type="button" className="btn btn-sq" disabled={busy} onClick={savePath}>
Save path
</button>
</div>
</section>
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
Re-import
</h3>
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
Applies a map change without restarting. An unchanged tree costs nothing the source files
are hashed first and skipped when they match. A refresh that would remove a facet still
comes back here for approval rather than being applied.
</p>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<button
type="button"
className="btn btn-primary btn-sq"
disabled={busy || !status?.configured}
onClick={() => run('import the atlas', () => api.admin.atlas.import(force))}
>
{busy ? 'Working…' : 'Import now'}
</button>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: '0.85rem', cursor: 'pointer' }}>
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} />
Re-import even if the tree is unchanged
</label>
</div>
</section>
{(msg || error) && (
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
)}
</div>
)
}

View File

@@ -171,8 +171,9 @@ export default function UserDetail() {
<SecurityAdmin userId={id} />
{/* Whatever the installed module has to say about this user, or nothing
at all. Core's own UO sections fill it today (UserShardSections.jsx,
registered in main.jsx) — MODULE_API.md §3.7. */}
at all — core filled this with its own UO sections until Phase 3 slice
3, and now nothing does unless a module is installed
(MODULE_API.md §3.7). */}
<Slot name="admin.users.detail" userId={id} />
</section>
)

View File

@@ -1,163 +0,0 @@
// ── Core's fill for the `admin.users.detail` extension slot ────────────────
//
// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1. Every section below
// is UO, and every one of them leaves core with the client half in slice 3 —
// this file exists so that when they do, core deletes a registration and a file
// instead of unpicking a page.
//
// Core registers it through the same seam a module uses
// (`registerExtension('core', …)` in main.jsx), which is the client twin of the
// server's `registries.registerCore()` and the same trick `useShardFlags`
// already uses for the feature seam. The mechanism is therefore exercised by
// core's own content from the day it lands, rather than first proved by the
// change that depends on it.
//
// The slot hands over `userId` and nothing else — deliberately, not `scope`.
// `api.admin.userShard` is a UO binding that leaves core in slice 3, so a slot
// that passed it would be handing a module something core is about to delete.
// An extension builds its own client for the routes it registered at the other
// end (MODULE_API.md §3.5), and this file does exactly what the module will.
import { useMemo } from 'react'
import { useAsync } from '../../../lib/useAsync.js'
import { ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import CharacterStats from '../../../components/CharacterStats.jsx'
import GameAccounts from '../../../components/GameAccounts.jsx'
import VendorSales from '../../../components/VendorSales.jsx'
// Its own copy, not an export from UserDetail.jsx: six lines of presentational
// furniture that is not in the §3.4 kit, so a module filling this slot would
// vendor the same thing. Core's copy stays behind with core's own security
// panel, which is the other caller.
function SectionTitle({ children }) {
return (
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
{children}
</div>
)
}
// Currently-online characters on the user's accounts, with where they are. The
// per-character Online/Offline badge lives in the roster; this adds location.
function OnlineNow({ scope }) {
const { data } = useAsync(() => scope.online(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Online now</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map((c) => (
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
</span>
</li>
))}
</ul>
)}
</section>
)
}
// Shard "standing": city governorships held and guilds led by this user's
// accounts (both reliable current-state lookups). Renders nothing when empty.
function Standing({ scope }) {
const { data } = useAsync(() => scope.standing(), [scope])
if (!data) return null
const govs = data.governorOf || []
const guilds = data.guildsLed || []
if (govs.length === 0 && guilds.length === 0) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Standing</SectionTitle>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{govs.map((g) => (
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
Governor of {g.city}
</span>
))}
{guilds.map((g) => (
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
</span>
))}
</div>
</section>
)
}
// One house row — the many optional detail fields are gathered here so the
// Houses list stays a simple map.
function HouseRow({ house: h }) {
const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown')
const coords = h.x != null ? ` · ${h.x}, ${h.y}` : ''
const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : ''
const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''
return (
<li
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
>
<div style={{ minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{h.name || 'Unnamed house'}
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
{location}
{coords}
{owner}
{shares}
</div>
</div>
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
</div>
</li>
)
}
// Houses owned by the user's accounts, IDOC first (flagged).
function Houses({ scope }) {
const { data } = useAsync(() => scope.houses(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Houses</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this users accounts.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => (
<HouseRow key={h.serial} house={h} />
))}
</ul>
)}
</section>
)
}
export default function UserShardSections({ userId }) {
// Memoized so the child components' effects (keyed on `scope`) don't refetch
// on every render — the same reason UserDetail memoized it before this moved.
const scope = useMemo(() => api.admin.userShard(userId), [userId])
return (
<>
<CharacterStats scope={scope} />
<SectionTitle>Linked accounts &amp; characters</SectionTitle>
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/characters/${serial}`} />
<Standing scope={scope} />
<OnlineNow scope={scope} />
<Houses scope={scope} />
<VendorSales fetchSales={scope.sales} />
</>
)
}

View File

@@ -1,29 +0,0 @@
import { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import CharacterSheet from '../../components/CharacterSheet.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
// A player's character sheet inside the portal. Owner-checked: the endpoint only
// returns a sheet for a character on an account linked to the caller.
export default function PlayerCharacter() {
const { serial } = useParams()
const { loading, error, data } = useAsync(() => api.player.shard.char(serial), [serial])
const restarting = error && error.status === 503
const forbidden = error && error.status === 403
return (
<div>
<p style={{ margin: '0 0 18px' }}>
<Link to="/player" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
Back to characters
</Link>
</p>
{loading && <Loading />}
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
{!loading && !error && data && <CharacterSheet char={data} />}
</div>
)
}

View File

@@ -1,58 +0,0 @@
import GameAccounts from '../../components/GameAccounts.jsx'
import VendorSales from '../../components/VendorSales.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
// The logged-in player's characters. Shows the link prompt when no game account
// is linked, otherwise their characters grouped by account (shared component),
// plus their own home status and recent vendor sales.
const DECAY_TONE = {
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
}
// The caller's own houses (home status). Only their own — never anyone else's.
function MyHouses() {
const { data } = useAsync(() => api.player.shard.houses(), [])
if (!data || data.length === 0) return null
return (
<section style={{ marginTop: 30 }}>
<div className="field-label" style={{ marginBottom: 12 }}>My houses</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => {
const label = h.isIdoc ? 'IDOC' : (h.decay || h.stage)
const tone = h.isIdoc ? '#e05a5a' : (DECAY_TONE[label] || 'var(--muted)')
return (
<div key={h.serial} className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>{h.name || 'An unnamed house'}</div>
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{h.region || h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
</div>
</div>
{label && (
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 9px' }}>
{label}
</span>
)}
</div>
)
})}
</div>
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.76rem' }}>
Keep an eye on the decay status refresh a house in game before it reaches IDOC.
</p>
</section>
)
}
export default function PlayerCharacters() {
return (
<div>
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/char/${serial}`} />
<MyHouses />
<VendorSales fetchSales={api.player.shard.sales} />
</div>
)
}

View File

@@ -1,10 +1,11 @@
import { useMemo } from 'react'
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js'
import { firstDestinationFor } from '../../lib/adminNav.js'
import { useNavOverrides } from '../../lib/useNavOverrides.js'
import { withModuleNav } from '../../modules/nav.js'
import { useFeatureGate } from '../../modules/features.jsx'
@@ -32,29 +33,37 @@ function Icon({ children, size = 16 }) {
</svg>
)
}
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
// Exported because Admin -> Navigation edits this list. It stays declared here;
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
// carries a gate — every player sees all three — but an installed module's rows
// join this list before the merge and may carry a `feature`, so the filter after
// it is not dead code.
// carries a gate — every player sees both — but an installed module's rows join
// this list before the merge and may carry a `feature`, so the filter after it
// is not dead code.
//
// "Characters" was the first row and left with the client half in slice 3; the
// UO module registers it again at `/player/uo/characters`, in this position,
// with `order: 0`.
export const NAV = [
{ to: '/player', label: 'Characters', end: true, icon: IconUser },
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
{ to: '/account', label: 'Account', end: true, icon: IconGear },
]
// The sticky content header mirrors the active page. Character sheets live under
// /player/char/:serial and keep their own in-page back link.
// The sticky content header mirrors the active page. A module's pages are not
// here and cannot be — core does not know what they are called — so they title
// from their own nav row, the same rule AdminLayout's `moduleTitle` follows.
const TITLES = {
'/player': 'Characters',
'/account': 'Account',
'/account/appeals': 'Appeals',
}
function moduleTitle(baseNav, pathname) {
return baseNav
.filter((i) => i.moduleId && (pathname === i.to || pathname.startsWith(`${i.to}/`)))
.sort((a, b) => b.to.length - a.to.length)[0]?.label
}
const navBtnBase = {
textAlign: 'left',
borderRadius: 8,
@@ -80,9 +89,7 @@ export default function PlayerPortalLayout() {
)
const navigate = useNavigate()
const location = useLocation()
const title =
TITLES[location.pathname] ||
(location.pathname.startsWith('/player/char/') ? 'Character' : 'Player Portal')
const title = TITLES[location.pathname] || moduleTitle(baseNav, location.pathname) || 'Player Portal'
async function signOut() {
await logout()
@@ -182,3 +189,28 @@ export default function PlayerPortalLayout() {
</div>
)
}
/**
* What `/player` renders.
*
* It used to be `PlayerCharacters`, a UO page, which left the portal with no
* index at all when the client half was extracted (slice 3). Rather than pick a
* fixed destination or invent a core landing page, the index resolves to the
* first row of the portal nav this viewer can actually reach — so with the UO
* module installed a player still arrives at their characters, exactly as
* before, and with nothing installed they arrive at Account.
*
* Resolved from the BASE nav, before overrides: where everybody lands is
* behaviour, and an override is presentation (`firstDestinationFor`). `replace`
* so the back button leaves the portal rather than bouncing off this redirect.
*
* The same question exists one area over — the admin index is a hardcoded
* Dashboard — and if the two logged-in areas ever become one, this is the shape
* that answers for both. Nothing here assumes a portal separate from admin.
*/
export function PlayerIndex() {
const { user } = useAuth()
const baseNav = useMemo(() => withModuleNav(NAV, 'player'), [])
const to = firstDestinationFor(baseNav, user?.role, '/account')
return <Navigate to={to} replace />
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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' }}>&mdash;</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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}