feat(client): the whole client half (phase 3, slice 3)
The 35 files behind twelve public pages, seven admin views, two player views and three core-page extensions, ported onto `window.__rg`. Every one of them imports exactly the seven kit members plus `lib/format.js`, which is the finding §2.7.1 predicted and this confirms. `client/src/core.js` is the port mechanism, and unlike the server's it is a plain read: `window.__rg` is published before any module chunk evaluates, so there is no gap to defer around and a ported component keeps its ordinary import shape. `client/src/api.js` rebuilds the UO namespaces over the request primitive — same URLs, because §1.2 freezes the API surface. SPA paths changed and API paths did not. `/site/shard` is `/uo/shard`, and the admin paths lost their now-redundant `shard-` prefixes (`/admin/uo/ops`), a clean break being the only moment that is free. `shim/rg.js` becomes the single reader of the global, so the "core did not publish its dependencies" message is reachable from whichever module the bundler happens to touch first rather than from whichever one is imported first — a guarantee that used to last until someone sorted the imports. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
231
client/src/api.js
Normal file
231
client/src/api.js
Normal file
@@ -0,0 +1,231 @@
|
||||
// ── This module's own API bindings ─────────────────────────────────────────
|
||||
//
|
||||
// Core hands out the request PRIMITIVE and nothing above it (MODULE_API.md
|
||||
// §3.5): same-origin `/api/v1`, cookies included, JSON in and out, `ApiError` on
|
||||
// a non-2xx. The paths are ours, because the routes at the other end are ours —
|
||||
// `server/router/**` in this repo serves every one of them.
|
||||
//
|
||||
// This file is the client half of the pair that moved in slice 1, and the two
|
||||
// halves are checked against each other by nothing but review, so the ordering
|
||||
// below mirrors the router tree deliberately: public, then admin, then player.
|
||||
//
|
||||
// **The URLs are unchanged from the ones core used to call.** MODULE_SYSTEM.md
|
||||
// §1.2 freezes the API surface across the extraction — the shipped Android app
|
||||
// calls `/api/v1/admin/shard/kick` and six of its neighbours — so what moved is
|
||||
// which repo declares them, never what they are. Only the SPA route paths
|
||||
// changed (`/uo/*`, `/admin/uo/*`, `/player/uo/*`), and those are not API URLs.
|
||||
|
||||
import rg from './core.js'
|
||||
|
||||
const { request: req, BASE } = rg.api
|
||||
|
||||
/** Prefix a non-empty query string with "?" — core's `withQs`, which is not in the kit. */
|
||||
const withQs = (s) => (s ? `?${s}` : '')
|
||||
|
||||
// ── public: live shard data (uo-link) ──────────────────────────────────────
|
||||
// Token-free, same-origin reads backed by the ingested feed plus a cached live
|
||||
// character round-trip.
|
||||
export const shard = {
|
||||
status: () => req('/public/shard/status'),
|
||||
feed: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.kind) qs.set('kind', opts.kind)
|
||||
if (opts.limit) qs.set('limit', opts.limit)
|
||||
return req(`/public/shard/feed${withQs(qs.toString())}`)
|
||||
},
|
||||
economy: (limit) => req(`/public/shard/economy${withQs(limit ? `limit=${limit}` : '')}`),
|
||||
online: () => req('/public/shard/online'),
|
||||
idoc: () => req('/public/shard/idoc'),
|
||||
champs: () => req('/public/shard/champs'),
|
||||
// Protocol 2.0 boards.
|
||||
guilds: () => req('/public/shard/guilds'),
|
||||
governors: () => req('/public/shard/governors'),
|
||||
governorHistory: (city, limit) =>
|
||||
req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(limit ? `limit=${limit}` : '')}`),
|
||||
presence: () => req('/public/shard/presence'),
|
||||
houses: () => req('/public/shard/houses'),
|
||||
// Protocol 3.0: the shard's published ruleset. Resolves to null when the shard
|
||||
// has never published one — a real answer, not an error.
|
||||
ruleset: () => req('/public/shard/ruleset'),
|
||||
// Protocol 3.0: points/loyalty leaderboards, one board per point system.
|
||||
// `pointsBoard` 404s for a system the shard has never published.
|
||||
points: () => req('/public/shard/points'),
|
||||
pointsBoard: (system) => req(`/public/shard/points/${encodeURIComponent(system)}`),
|
||||
// Protocol 3.0: the player-vendor marketplace. Rate-limited server-side, so
|
||||
// the page debounces its search box rather than firing per keystroke.
|
||||
market: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.q) qs.set('q', opts.q)
|
||||
if (opts.minPrice != null && opts.minPrice !== '') qs.set('minPrice', opts.minPrice)
|
||||
if (opts.maxPrice != null && opts.maxPrice !== '') qs.set('maxPrice', opts.maxPrice)
|
||||
if (opts.itemId != null && opts.itemId !== '') qs.set('itemId', opts.itemId)
|
||||
if (opts.map) qs.set('map', opts.map)
|
||||
if (opts.region) qs.set('region', opts.region)
|
||||
if (opts.sort) qs.set('sort', opts.sort)
|
||||
if (opts.limit) qs.set('limit', opts.limit)
|
||||
if (opts.offset) qs.set('offset', opts.offset)
|
||||
return req(`/public/shard/market${withQs(qs.toString())}`)
|
||||
},
|
||||
marketMeta: () => req('/public/shard/market/meta'),
|
||||
marketVendor: (serial, opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.limit) qs.set('limit', opts.limit)
|
||||
if (opts.offset) qs.set('offset', opts.offset)
|
||||
return req(`/public/shard/market/vendors/${encodeURIComponent(serial)}${withQs(qs.toString())}`)
|
||||
},
|
||||
// Which shard surfaces this caller may reach, plus the audience rung they
|
||||
// resolved to. Drives nav so we never render a link that would 403 — and, as
|
||||
// of slice 3, also carries `gameAccountSignup`: whether this site offers
|
||||
// game-account creation at all (see server/router/public/shard.controller.js).
|
||||
features: () => req('/public/shard/features'),
|
||||
}
|
||||
|
||||
// ── public: the spawn atlas (Protocol 3.0 Part C) ──────────────────────────
|
||||
// Static shard CONTENT, parsed from the shard's own ServUO tree — deliberately
|
||||
// not under /shard, because nothing here depends on the sidecar and the pages
|
||||
// stay populated while the shard is offline.
|
||||
export const atlas = {
|
||||
creatures: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.q) qs.set('q', opts.q)
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
if (opts.limit) qs.set('limit', opts.limit)
|
||||
if (opts.offset) qs.set('offset', opts.offset)
|
||||
return req(`/public/atlas/creatures${withQs(qs.toString())}`)
|
||||
},
|
||||
creature: (slug, opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
if (opts.points) qs.set('points', opts.points)
|
||||
return req(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`)
|
||||
},
|
||||
regions: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
if (opts.q) qs.set('q', opts.q)
|
||||
return req(`/public/atlas/regions${withQs(qs.toString())}`)
|
||||
},
|
||||
landmarks: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
if (opts.q) qs.set('q', opts.q)
|
||||
return req(`/public/atlas/landmarks${withQs(qs.toString())}`)
|
||||
},
|
||||
// The CONFIGURED altar roster, not the live board — see `shard.champs()` for
|
||||
// "which spawn is on level 3 right now".
|
||||
champions: (facet) =>
|
||||
req(`/public/atlas/champions${withQs(facet ? `facet=${encodeURIComponent(facet)}` : '')}`),
|
||||
meta: () => req('/public/atlas/meta'),
|
||||
}
|
||||
|
||||
// ── admin ──────────────────────────────────────────────────────────────────
|
||||
export const admin = {
|
||||
// The account/character/house reads a staff member makes across the whole shard.
|
||||
shard: {
|
||||
link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }),
|
||||
accounts: () => req('/admin/shard/accounts'),
|
||||
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
||||
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
||||
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
||||
sales: () => req('/admin/shard/sales'),
|
||||
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
|
||||
createAccount: (account, password) =>
|
||||
req('/admin/shard/account', { method: 'POST', body: { account, password } }),
|
||||
},
|
||||
|
||||
// The sidecar's own configuration and the town crier it drives.
|
||||
getUoLinkConfig: () => req('/admin/uo-link/config'),
|
||||
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
|
||||
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
|
||||
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
|
||||
// Whether this site offers game-account creation, and in which direction.
|
||||
// Core's Site Settings used to carry this; it is ours as of slice 3, because
|
||||
// "the game server's own SignupMode must agree" is not a sentence core can own.
|
||||
getSignupMode: () => req('/admin/uo-link/signup-mode'),
|
||||
saveSignupMode: (mode) => req('/admin/uo-link/signup-mode', { method: 'PUT', body: { mode } }),
|
||||
|
||||
// Per-feature shard visibility: who may see which shard surface, and which
|
||||
// sensitive fields within it. Admin only — it decides what ANONYMOUS visitors
|
||||
// get. acct/webId are admin-only always and the API rejects any attempt to
|
||||
// configure them.
|
||||
getShardVisibility: () => req('/admin/shard/visibility'),
|
||||
saveShardVisibility: (features) => req('/admin/shard/visibility', { method: 'PUT', body: { features } }),
|
||||
|
||||
// The atlas re-derives itself from the ServUO tree on every boot; these are for
|
||||
// applying a map change without a restart, and for the approve/reject decision
|
||||
// on a refresh that would remove a facet.
|
||||
atlas: {
|
||||
status: () => req('/admin/shard/atlas'),
|
||||
import: (force = false) => req('/admin/shard/atlas/import', { method: 'POST', body: { force } }),
|
||||
approve: () => req('/admin/shard/atlas/approve', { method: 'POST', body: {} }),
|
||||
reject: () => req('/admin/shard/atlas/reject', { method: 'POST', body: {} }),
|
||||
setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }),
|
||||
},
|
||||
|
||||
// In-game staff operations: write plane + support queue (admin/moderator).
|
||||
// `actor` is stamped server-side from the session — never sent from here.
|
||||
shardOps: {
|
||||
kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
|
||||
ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
|
||||
unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
|
||||
broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
|
||||
pages: () => req('/admin/shard/pages'),
|
||||
respondPage: (id, data) => req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
|
||||
closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
|
||||
audit: (limit) => req(`/admin/shard/audit${withQs(limit ? `limit=${limit}` : '')}`),
|
||||
},
|
||||
|
||||
/**
|
||||
* One user's shard presence, for the `admin.users.detail` extension slot.
|
||||
*
|
||||
* A factory rather than a flat namespace because every call is scoped to the
|
||||
* user whose page this is. The three that are NOT — roster, vendors, char —
|
||||
* are keyed by an account or a serial the scoped calls just returned, and they
|
||||
* are the same routes `admin.shard` uses; they are repeated here so the slot's
|
||||
* components take one `scope` object and never reach for a second one.
|
||||
*/
|
||||
userShard: (id) => ({
|
||||
accounts: () => req(`/admin/users/${id}/shard/accounts`),
|
||||
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
||||
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
||||
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
||||
sales: () => req(`/admin/users/${id}/shard/sales`),
|
||||
houses: () => req(`/admin/users/${id}/shard/houses`),
|
||||
online: () => req(`/admin/users/${id}/shard/online`),
|
||||
standing: () => req(`/admin/users/${id}/shard/standing`),
|
||||
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
|
||||
}),
|
||||
}
|
||||
|
||||
// ── player self-service ────────────────────────────────────────────────────
|
||||
// Mirrors `admin.shard`, self-scoped: the server derives the caller from the
|
||||
// session and never takes an account id from the client.
|
||||
export const player = {
|
||||
shard: {
|
||||
link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }),
|
||||
accounts: () => req('/player/shard/accounts'),
|
||||
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
|
||||
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
|
||||
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
|
||||
sales: () => req('/player/shard/sales'),
|
||||
houses: () => req('/player/shard/houses'), // the caller's own houses
|
||||
createAccount: (account, password) =>
|
||||
req('/player/shard/account', { method: 'POST', body: { account, password } }),
|
||||
},
|
||||
}
|
||||
|
||||
// ── SSE endpoints ──────────────────────────────────────────────────────────
|
||||
// Full paths including `/api/v1`, because `request` is fetch-only and an
|
||||
// EventSource builds its own URL. `BASE` is core's — it owns where the API is
|
||||
// mounted, and a module hardcoding `/api/v1` would be asserting something about
|
||||
// core that core has not promised (MODULE_API.md §3.5).
|
||||
//
|
||||
// The admin stream carries every kind, including audit and cheat detection, and
|
||||
// needs the staff session cookie.
|
||||
export const shardStreamUrl = `${BASE}/public/shard/stream`
|
||||
export const adminShardStreamUrl = `${BASE}/admin/uo-link/stream`
|
||||
|
||||
export const api = { shard, atlas, admin, player, shardStreamUrl, adminShardStreamUrl }
|
||||
|
||||
export default api
|
||||
293
client/src/components/CharacterSheet.jsx
Normal file
293
client/src/components/CharacterSheet.jsx
Normal file
@@ -0,0 +1,293 @@
|
||||
// Reusable character-sheet renderer for the char.profile shape returned by
|
||||
// /public/shard/char/:serial. Presentational only — the parent handles loading
|
||||
// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles).
|
||||
//
|
||||
// `moderation` opts in the in-game kick/ban controls for the character's account;
|
||||
// they self-gate to staff (ShardAccountActions), so passing it from a page a
|
||||
// player can reach is safe.
|
||||
|
||||
import ShardAccountActions from './ShardAccountActions.jsx'
|
||||
|
||||
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
|
||||
|
||||
// What to call an equipped item.
|
||||
//
|
||||
// Items on the wire carry a `LabelNumber`, not a name, so this used to be able
|
||||
// to show nothing but the layer and `id 12345`. The server now resolves the
|
||||
// cliloc against its own table and attaches `clilocName` (see
|
||||
// docs/website/CLILOCS.md); a shard with no cliloc file configured sends none,
|
||||
// and the layer fallback below is exactly what the sheet did before.
|
||||
//
|
||||
// A player-given `name` outranks the resolved type name — "Bob's lucky axe"
|
||||
// should not be relabelled "hatchet" — and the server applies the same
|
||||
// precedence, so this only re-states it for a profile that arrived with both.
|
||||
const itemName = (it) => it.name || it.clilocName || it.layer || 'Item'
|
||||
|
||||
// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already
|
||||
// computed display strings; reward entries may be a cliloc NUMBER-as-string or a
|
||||
// literal string.
|
||||
//
|
||||
// `rewardResolved` is the server's parallel array with the numeric entries turned
|
||||
// into words (null where the cliloc table had nothing, or is not configured at
|
||||
// all). Prefer it, and keep the literal-only path as the fallback for a profile
|
||||
// served before the cliloc table existed — a numeric entry with no resolution is
|
||||
// still skipped rather than shown as a raw number.
|
||||
function displayTitles(titles) {
|
||||
if (!titles) return []
|
||||
const out = []
|
||||
if (titles.fameKarma) out.push(titles.fameKarma)
|
||||
if (titles.skill) out.push(titles.skill)
|
||||
const raw = Array.isArray(titles.reward) ? titles.reward : []
|
||||
const resolved = Array.isArray(titles.rewardResolved) ? titles.rewardResolved : null
|
||||
const reward = raw.map((r, i) => resolved?.[i] ?? (/^\d+$/.test(String(r)) ? null : String(r)))
|
||||
const sel = typeof titles.selected === 'number' ? titles.selected : -1
|
||||
// Prefer the selected reward title; fall back to the first one that resolved.
|
||||
// The `??` matters: a selected title whose cliloc did not resolve must fall
|
||||
// through to the fallback rather than suppress the chip entirely.
|
||||
const candidate = (sel >= 0 && sel < reward.length ? reward[sel] : null) ?? reward.find(Boolean)
|
||||
if (candidate) out.push(String(candidate))
|
||||
return [...new Set(out.filter(Boolean))]
|
||||
}
|
||||
|
||||
// The char.profile `points` block (Protocol 3.0 §7.3): one entry per point system
|
||||
// the character actually holds a score in. Systems at zero are omitted by the
|
||||
// shard, so an empty list means "this character has earned nothing anywhere",
|
||||
// which is a normal state for a new character and renders as nothing at all.
|
||||
//
|
||||
// `nameString` may be null when the system's name is a cliloc; fall back to
|
||||
// humanising the PointsType key, exactly as the leaderboards page does. `rank` is
|
||||
// absent unless the shard runs with Bridge.cfg PointsProfileRank=true — absent and
|
||||
// "unranked" are different, so the chip only appears when it was actually sent.
|
||||
const humanisePoints = (key) =>
|
||||
String(key || '')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
|
||||
function PointsRow({ entry }) {
|
||||
const label = entry.nameString || humanisePoints(entry.system)
|
||||
const max = Number.isFinite(entry.maxPoints) && entry.maxPoints > 0 ? entry.maxPoints : 0
|
||||
const pct = max ? Math.min(100, Math.round((entry.points / max) * 100)) : 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3, gap: 10 }}>
|
||||
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>
|
||||
{label}
|
||||
{Number.isFinite(entry.rank) && (
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}> · #{entry.rank}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
|
||||
{(entry.points ?? 0).toLocaleString()}
|
||||
{max > 0 && <span className="dim"> / {max.toLocaleString()}</span>}
|
||||
</span>
|
||||
</div>
|
||||
{/* Only systems with a real cap get a bar; an uncapped score has nothing to
|
||||
be a fraction of, and a full-width bar would imply completion. */}
|
||||
{max > 0 && (
|
||||
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TitleChip({ children, tone = 'var(--muted)' }) {
|
||||
return (
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.72rem', padding: '3px 9px', borderRadius: 999,
|
||||
border: `1px solid ${tone}55`, color: tone, whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatTile({ value, label }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
|
||||
<div className="display" style={{ fontSize: '1.35rem', color: 'var(--head)' }}>{value}</div>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 4 }}>{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Vital({ label, cur, max }) {
|
||||
const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0
|
||||
return (
|
||||
<div className="panel" style={{ padding: '12px 14px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
||||
<span className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>{label}</span>
|
||||
<span className="display" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>{cur ?? '—'}<span className="dim" style={{ fontSize: '0.8rem' }}> / {max ?? '—'}</span></span>
|
||||
</div>
|
||||
<div style={{ height: 6, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CharacterSheet({ char, moderation = false }) {
|
||||
if (!char) return null
|
||||
const stats = char.stats || {}
|
||||
const resist = stats.resist || {}
|
||||
// Skills the character actually has, best first.
|
||||
const skills = (char.skills || [])
|
||||
.filter((s) => (s.value || s.base || 0) > 0)
|
||||
.sort((a, b) => (b.value || 0) - (a.value || 0))
|
||||
const equipment = char.equipment || []
|
||||
// Best standing first, so the character's strongest loyalty leads. Guarded for
|
||||
// an older shard plugin that sends no `points` block at all.
|
||||
const points = (Array.isArray(char.points) ? char.points : [])
|
||||
.filter((p) => p && (p.points || 0) > 0)
|
||||
.sort((a, b) => (b.points || 0) - (a.points || 0))
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
{/* Identity */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>{char.name || 'Unknown'}</h2>
|
||||
{char.title && <span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>{char.title}</span>}
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 999,
|
||||
border: '1px solid var(--line)', fontSize: '0.74rem',
|
||||
color: char.online ? '#7fd0a4' : 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: char.online ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{char.online ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
|
||||
</div>
|
||||
|
||||
{/* Titles + standing (guild led / governorship) — all optional */}
|
||||
{(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: -8 }}>
|
||||
{char.governorOf && char.governorOf.map((city) => (
|
||||
<TitleChip key={`gov-${city}`} tone="#c9a24b">Governor of {city}</TitleChip>
|
||||
))}
|
||||
{char.guild && (
|
||||
<TitleChip tone="var(--accent)">
|
||||
Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name}
|
||||
</TitleChip>
|
||||
)}
|
||||
{displayTitles(char.titles).map((t) => <TitleChip key={t}>{t}</TitleChip>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Staff moderation for this character's account (self-gates to staff). */}
|
||||
{moderation && char.acct && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>Account <strong style={{ color: 'var(--ink)' }}>{char.acct}</strong></span>
|
||||
<ShardAccountActions account={char.acct} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Core stats */}
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>
|
||||
<div className="grid-3" style={{ gap: 12 }}>
|
||||
<StatTile value={stats.str ?? '—'} label="Strength" />
|
||||
<StatTile value={stats.dex ?? '—'} label="Dexterity" />
|
||||
<StatTile value={stats.int ?? '—'} label="Intelligence" />
|
||||
</div>
|
||||
<div className="grid-3" style={{ gap: 12, marginTop: 12 }}>
|
||||
<Vital label="Hits" cur={stats.hits} max={stats.hitsMax} />
|
||||
<Vital label="Mana" cur={stats.mana} max={stats.manaMax} />
|
||||
<Vital label="Stamina" cur={stats.stam} max={stats.stamMax} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Resistances */}
|
||||
{Object.keys(resist).length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Resistances</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => (
|
||||
<div key={k} className="panel" style={{ padding: '10px 16px', textAlign: 'center', minWidth: 84 }}>
|
||||
<div className="display" style={{ color: 'var(--head)', fontSize: '1.1rem' }}>{resist[k] ?? 0}</div>
|
||||
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.66rem', textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: 2 }}>{RESIST_LABELS[k]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{skills.length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Skills <span className="dim">({skills.length})</span></div>
|
||||
<div className="grid-2" style={{ gap: '8px 18px' }}>
|
||||
{skills.map((s) => {
|
||||
const cap = s.cap || 100
|
||||
const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100))
|
||||
return (
|
||||
<div key={s.n}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 }}>
|
||||
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>{s.n}</span>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem' }}>{s.value}</span>
|
||||
</div>
|
||||
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Loyalty & points — one entry per system this character has scored in */}
|
||||
{points.length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>
|
||||
Loyalty & points <span className="dim">({points.length})</span>
|
||||
</div>
|
||||
<div className="grid-2" style={{ gap: '8px 18px' }}>
|
||||
{points.map((p) => (
|
||||
<PointsRow key={p.system} entry={p} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Equipment */}
|
||||
{equipment.length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{equipment.map((it) => {
|
||||
const label = itemName(it)
|
||||
const layer = it.layer || 'Item'
|
||||
// The layer only earns its own line once the headline is a real
|
||||
// name; when it IS the headline, repeating it is just noise.
|
||||
const detail = [label === layer ? null : layer, `id ${it.itemId}`, it.hue ? `hue ${it.hue}` : null]
|
||||
return (
|
||||
<div key={it.serial} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: 5, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.05)' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{label}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem' }}>{detail.filter(Boolean).join(' · ')}</div>
|
||||
</div>
|
||||
{it.mods && Object.keys(it.mods).length > 0 && (
|
||||
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
|
||||
{Object.entries(it.mods).map(([k, v]) => (
|
||||
<span key={k} className="pill" style={{ fontSize: '0.7rem', padding: '2px 8px' }}>{k} {v}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
client/src/components/CharacterStats.jsx
Normal file
79
client/src/components/CharacterStats.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// A small stat-tile row for a "My Characters" page: total characters, how many
|
||||
// are online right now, and how many game accounts are linked. `scope` is the
|
||||
// shard api object (admin or player self-service). Renders nothing until an
|
||||
// account is linked, so the empty/link-prompt state below it stands alone.
|
||||
//
|
||||
// It fetches the same rosters GameAccounts loads; for a personal page that's at
|
||||
// most a couple of extra live round-trips, and keeps this presentational bit
|
||||
// decoupled from GameAccounts' per-account roster loading.
|
||||
|
||||
function Tile({ 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.68rem', fontWeight: 700, letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: 8 }}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Fold the settled roster results into totals. `complete` is false when any
|
||||
// account's roster failed (a partial result — shown as a dash rather than a
|
||||
// misleadingly low count).
|
||||
function summarizeRosters(rosters) {
|
||||
let chars = 0
|
||||
let online = 0
|
||||
let complete = true
|
||||
for (const r of rosters) {
|
||||
if (r.status !== 'fulfilled') {
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
const cs = r.value.chars || []
|
||||
chars += cs.length
|
||||
online += cs.filter((c) => c.online).length
|
||||
}
|
||||
return { chars, online, complete }
|
||||
}
|
||||
|
||||
export default function CharacterStats({ scope }) {
|
||||
const [stats, setStats] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const accounts = await scope.accounts()
|
||||
const linked = accounts.length
|
||||
if (linked === 0) {
|
||||
if (!cancelled) setStats({ linked: 0 })
|
||||
return
|
||||
}
|
||||
// Roster is a live round-trip and can be unavailable (503); tolerate a
|
||||
// partial result so a restarting shard doesn't blank the whole row.
|
||||
const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account)))
|
||||
if (!cancelled) setStats({ linked, ...summarizeRosters(rosters) })
|
||||
} catch {
|
||||
if (!cancelled) setStats({ error: true })
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [scope])
|
||||
|
||||
// Hidden until we know an account is linked (or while first loading).
|
||||
if (!stats || stats.error || stats.linked === 0) return null
|
||||
|
||||
// Counts depend on live rosters; show a dash if none came back.
|
||||
const count = (n) => (stats.complete || stats.chars > 0 ? n : '—')
|
||||
|
||||
return (
|
||||
<section className="grid-3" style={{ gap: 14, marginBottom: 26 }}>
|
||||
<Tile value={count(stats.chars)} label="Characters" />
|
||||
<Tile value={count(stats.online)} label="Online now" />
|
||||
<Tile value={stats.linked} label={stats.linked === 1 ? 'Linked account' : 'Linked accounts'} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
69
client/src/components/CreateGameAccountForm.jsx
Normal file
69
client/src/components/CreateGameAccountForm.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
// Reusable "create a game account" form (its own username + password — the game
|
||||
// client credentials, distinct from the website login). Calls `submit(account,
|
||||
// password)` which should POST /player/shard/account; on success calls onCreated.
|
||||
// Used by the player portal (self-serve) and the invite-accept page alike.
|
||||
export default function CreateGameAccountForm({ submit, onCreated, compact = false }) {
|
||||
const [account, setAccount] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault()
|
||||
setMsg(''); setError('')
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/.test(account)) {
|
||||
return setError('Account name must be 3–30 letters, numbers, . _ or -.')
|
||||
}
|
||||
if (password.length < 8) return setError('Password must be at least 8 characters.')
|
||||
setBusy(true)
|
||||
try {
|
||||
await submit(account, password)
|
||||
setMsg(`Game account “${account}” created and linked.`)
|
||||
setAccount(''); setPassword('')
|
||||
if (onCreated) await onCreated()
|
||||
} catch (err) {
|
||||
if (err.status === 409) setError('That account name is already taken.')
|
||||
else if (err.status === 429) setError('The account limit for your network has been reached.')
|
||||
else if (err.status === 403) setError('Game-account signup is not available right now.')
|
||||
else if (err.status === 503) setError('The game server is unavailable — try again shortly.')
|
||||
else setError(err.message || 'Could not create the account right now.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
{!compact && (
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Choose the username and password you’ll type into the game client. These are your
|
||||
<strong style={{ color: 'var(--head)' }}> game</strong> credentials — separate from your website login.
|
||||
</p>
|
||||
)}
|
||||
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||
<span className="field-label">Game account name</span>
|
||||
<input
|
||||
type="text" autoComplete="off" value={account}
|
||||
onChange={(e) => setAccount(e.target.value)} className="input" placeholder="e.g. darrow"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||
<span className="field-label">Game password</span>
|
||||
<input
|
||||
type="password" autoComplete="new-password" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} className="input"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
||||
{msg && <p className="sans" style={{ margin: '0 0 12px', color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</p>}
|
||||
|
||||
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Creating…' : 'Create game account'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
228
client/src/components/GameAccounts.jsx
Normal file
228
client/src/components/GameAccounts.jsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import ShardAccountActions from './ShardAccountActions.jsx'
|
||||
import CreateGameAccountForm from './CreateGameAccountForm.jsx'
|
||||
import api from '../api.js'
|
||||
import { useGameAccountSignup } from '../lib/useShardFeatures.js'
|
||||
import { ErrorState, Loading } from '../core.js'
|
||||
|
||||
// Shared game-account linking + character roster, used by both the player portal
|
||||
// (/player) and the staff account page (/admin/account). `scope` is the api
|
||||
// object with { link, accounts, roster } (player or admin self-service); `charTo`
|
||||
// maps a serial to the route for that character's sheet. `readOnly` drops the
|
||||
// link forms and self-voice copy for the admin case where staff view *another*
|
||||
// user's accounts (no `scope.link`) at /admin/users/:id.
|
||||
|
||||
function LinkForm({ scope, onLinked, compact }) {
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setMsg(''); setError('')
|
||||
if (!code.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const { account } = await scope.link(code.trim())
|
||||
setMsg(`Linked ${account}.`)
|
||||
setCode('')
|
||||
await onLinked()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not link that code.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: compact ? 0 : 6 }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
{!compact && <span className="field-label">Link code</span>}
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder="AB12CD"
|
||||
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Linking…' : 'Link account'}
|
||||
</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>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountRoster({ scope, account, charTo }) {
|
||||
const [roster, setRoster] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [unavailable, setUnavailable] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(''); setUnavailable(false)
|
||||
try {
|
||||
setRoster(await scope.roster(account))
|
||||
} catch (err) {
|
||||
if (err.status === 503) setUnavailable(true)
|
||||
else setError(err.message || 'Could not load this account.')
|
||||
}
|
||||
}, [scope, account])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (unavailable) {
|
||||
return (
|
||||
<div>
|
||||
<p className="sans" style={{ margin: '0 0 8px', color: '#e0b070', fontSize: '0.85rem' }}>The game server is restarting — try again shortly.</p>
|
||||
<button className="pill" onClick={load}>Retry</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (error) return <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
||||
if (!roster) return <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>Loading…</p>
|
||||
|
||||
const chars = roster.chars || []
|
||||
if (chars.length === 0) return <p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>No characters on this account.</p>
|
||||
|
||||
return (
|
||||
<div className="grid-2" style={{ gap: 12 }}>
|
||||
{chars.map((c) => (
|
||||
<Link
|
||||
key={c.serial}
|
||||
to={charTo(c.serial)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', border: '1px solid var(--line)', borderRadius: 10, textDecoration: 'none', background: 'rgba(255,255,255,0.02)' }}
|
||||
>
|
||||
<span style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '1rem', textTransform: 'uppercase' }}>
|
||||
{(c.name || '?').charAt(0)}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="display" style={{ color: 'var(--head)', fontSize: '1.02rem' }}>{c.name}</div>
|
||||
<div className="sans" style={{ fontSize: '0.76rem', color: c.online ? '#7fd0a4' : 'var(--muted)' }}>{c.online ? 'Online' : 'Offline'}</div>
|
||||
</div>
|
||||
<span className="sans dim" style={{ fontSize: '1.1rem' }}>›</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Compact per-account "Unlink" button for the admin (readOnly) view. Confirms,
|
||||
// then calls onUnlink(account) and reloads. Errors surface inline.
|
||||
function UnlinkButton({ account, onUnlink }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
async function go() {
|
||||
if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await onUnlink(account)
|
||||
} catch (err) {
|
||||
const byStatus = { 403: 'Protected account — refused.', 404: 'Not linked.' }
|
||||
setError(byStatus[err.status] || err.message || 'Could not unlink.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<button type="button" onClick={go} disabled={busy} className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }}>
|
||||
{busy ? 'Unlinking…' : 'Unlink'}
|
||||
</button>
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.76rem' }}>{error}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) {
|
||||
const [accounts, setAccounts] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
// Whether the site currently offers game-account creation. Only relevant for
|
||||
// the self-service (non-readOnly) view with a createAccount scope.
|
||||
//
|
||||
// From OUR public features endpoint as of slice 3, not core's public settings:
|
||||
// the flag derives from the `uo.game_account_signup` setting, which this module
|
||||
// owns, because "the game server's own SignupMode must agree" is not a sentence
|
||||
// core can own. Same cached call the nav gates use, so this costs no round-trip.
|
||||
const signupOk = useGameAccountSignup()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
setAccounts(await scope.accounts())
|
||||
} catch {
|
||||
setError(readOnly ? 'Could not load this user’s game accounts.' : 'Could not load your game accounts.')
|
||||
}
|
||||
}, [scope, readOnly])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const canCreate = !readOnly && Boolean(scope.createAccount) && signupOk === true
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!accounts) return <Loading />
|
||||
|
||||
// No linked accounts. In read-only (admin viewing another user) this is just an
|
||||
// empty state; otherwise it's the link-your-account prompt.
|
||||
if (accounts.length === 0) {
|
||||
if (readOnly) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||
This user has not linked a game account.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Already play? In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
|
||||
one-time code, then enter it below to see your characters, stats, skills and vendors here.
|
||||
</p>
|
||||
<LinkForm scope={scope} onLinked={load} />
|
||||
</div>
|
||||
{canCreate && (
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Create a new game account</div>
|
||||
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Linked — characters grouped by account.
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
|
||||
{accounts.map((a) => (
|
||||
<section key={a.account}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
||||
{a.account}
|
||||
</div>
|
||||
{onUnlink && <UnlinkButton account={a.account} onUnlink={async (acct) => { await onUnlink(acct); await load() }} />}
|
||||
</div>
|
||||
{moderation && <ShardAccountActions account={a.account} style={{ marginBottom: 12 }} />}
|
||||
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
|
||||
</section>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
|
||||
<LinkForm scope={scope} onLinked={load} compact />
|
||||
{canCreate && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>Create another game account</div>
|
||||
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} compact />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
client/src/components/InviteGameAccountStep.jsx
Normal file
51
client/src/components/InviteGameAccountStep.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'react'
|
||||
import api from '../api.js'
|
||||
import { useGameAccountSignup } from '../lib/useShardFeatures.js'
|
||||
import CreateGameAccountForm from './CreateGameAccountForm.jsx'
|
||||
|
||||
// ── This module's fill for the `player.invite.accepted` slot ───────────────
|
||||
//
|
||||
// Core's invite-acceptance page (`routes/player/AcceptInvite.jsx`) used to render
|
||||
// this step itself: it read `gameAccountSignup` out of core's public settings and
|
||||
// posted to `api.player.shard.createAccount`. Both of those are ours, and the
|
||||
// page they sat on is not — an invite is a core concept and staff get invited
|
||||
// too. So slice 3 declared a third extension slot rather than moving the page or
|
||||
// leaving core importing a module component. MODULE_API.md §3.7.
|
||||
//
|
||||
// **The whole decision about whether there is a step at all is on this side.**
|
||||
// Core renders the shell and a "skip" control whenever the slot is filled, and
|
||||
// hands us `onDone`. If this shard does not offer website-created game accounts
|
||||
// there is nothing to do here, so we call `onDone` and the invitee goes straight
|
||||
// to the portal — which is exactly what core's own code did when the flag was
|
||||
// off, only now the flag is not core's to read.
|
||||
//
|
||||
// The spinner while the answer is in flight is the honest cost of that split: the
|
||||
// invitee sees core's chrome for one cached request before this either renders or
|
||||
// stands aside. Rendering the form optimistically and retracting it would be
|
||||
// worse, and asking core to wait on a module before painting would put a module's
|
||||
// latency in front of a core page.
|
||||
export default function InviteGameAccountStep({ onDone }) {
|
||||
const signupOk = useGameAccountSignup()
|
||||
|
||||
useEffect(() => {
|
||||
if (signupOk === false) onDone()
|
||||
}, [signupOk, onDone])
|
||||
|
||||
// `null` is "not yet", not "no" — see useGameAccountSignup.
|
||||
if (signupOk !== true) {
|
||||
return (
|
||||
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}>
|
||||
<span className="spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
Your account is ready. Create a game account now to play, or skip and do it later from your portal.
|
||||
</p>
|
||||
<CreateGameAccountForm submit={api.player.shard.createAccount} onCreated={onDone} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
84
client/src/components/PlayersOnline.jsx
Normal file
84
client/src/components/PlayersOnline.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useShardFeed } from '../lib/useShardFeed.js'
|
||||
import { bucketize } from '../data/regionBuckets.js'
|
||||
import api from '../api.js'
|
||||
import { useAsync } from '../core.js'
|
||||
|
||||
// Compact live "Players Online" widget. Loads the presence.online aggregate once,
|
||||
// then keeps the total + region breakdown current from the presence.online SSE
|
||||
// kind. The raw byRegion map is rolled up into display buckets (see
|
||||
// data/regionBuckets.js). NOT a page — drop it into any panel/column.
|
||||
const PRESENCE_KINDS = new Set(['presence.online'])
|
||||
|
||||
export default function PlayersOnline() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.presence())
|
||||
const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 4 })
|
||||
|
||||
// The freshest snapshot wins: the newest buffered presence.online event, else
|
||||
// the initial fetch.
|
||||
const snapshot = events[0] || data
|
||||
|
||||
const { total, rows } = useMemo(() => {
|
||||
const count = Number(snapshot?.count) || 0
|
||||
const { rows: bucketRows } = bucketize(snapshot?.byRegion)
|
||||
return { total: count, rows: bucketRows }
|
||||
}, [snapshot])
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ padding: 20 }}>
|
||||
<div
|
||||
className="sans"
|
||||
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Players online
|
||||
</span>
|
||||
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)', lineHeight: 1 }}>
|
||||
{loading ? '—' : total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="sans dim" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
|
||||
Population is unavailable right now.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{rows.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>
|
||||
{total > 0 ? 'Locations are settling…' : 'The realm is quiet.'}
|
||||
</p>
|
||||
) : (
|
||||
rows.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
fontSize: '0.9rem',
|
||||
color: 'var(--ink)',
|
||||
}}
|
||||
>
|
||||
<span>{r.label}</span>
|
||||
{/* tabular figures keep the right-aligned counts in a clean column */}
|
||||
<span className="dim" style={{ fontVariantNumeric: 'tabular-nums' }}>{r.count}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
88
client/src/components/ShardAccountActions.jsx
Normal file
88
client/src/components/ShardAccountActions.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react'
|
||||
import api from '../api.js'
|
||||
import { useAuth } from '../core.js'
|
||||
|
||||
// Compact in-game moderation controls (kick / ban / unban) scoped to a single
|
||||
// game account. Reused wherever a linked account or character is shown to staff:
|
||||
// the admin user-detail account list and the character sheet. Self-gates on role
|
||||
// (admin/moderator) so it is safe to render inside components that players also
|
||||
// see — a player never gets the controls, and the API enforces the same gate.
|
||||
//
|
||||
// `actor` is stamped server-side from the session; nothing here sends it. Kick is
|
||||
// reversible (they reconnect) so it acts immediately; Ban reveals an inline
|
||||
// confirm with an optional duration + reason before it fires.
|
||||
export default function ShardAccountActions({ account, style }) {
|
||||
const { user } = useAuth()
|
||||
const [busy, setBusy] = useState('')
|
||||
const [ok, setOk] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [banOpen, setBanOpen] = useState(false)
|
||||
const [durationSec, setDurationSec] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
|
||||
// Only staff who can actually use the write plane see the controls.
|
||||
if (!user || !['admin', 'moderator'].includes(user.role) || !account) return null
|
||||
|
||||
async function run(label, fn, done) {
|
||||
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 }), (r) => {
|
||||
const n = r && r.sessions != null ? r.sessions : null
|
||||
const plural = n === 1 ? '' : 's'
|
||||
const sessions = n != null ? ` (${n} session${plural})` : ''
|
||||
return `Kicked${sessions}.`
|
||||
})
|
||||
const unban = () => run('unban', () => api.admin.shardOps.unban(account), () => 'Unbanned.')
|
||||
const ban = () =>
|
||||
run('ban', () =>
|
||||
api.admin.shardOps.ban({
|
||||
account,
|
||||
durationSec: durationSec === '' ? undefined : Number(durationSec),
|
||||
reason: reason.trim() || undefined,
|
||||
}),
|
||||
() => {
|
||||
setBanOpen(false)
|
||||
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
|
||||
return `Banned${when}.`
|
||||
})
|
||||
|
||||
const btn = { fontSize: '0.72rem', padding: '4px 10px' }
|
||||
|
||||
return (
|
||||
<div className="sans" style={{ display: 'flex', flexDirection: 'column', gap: 8, ...style }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
|
||||
<button onClick={kick} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'kick' ? '…' : 'Kick'}</button>
|
||||
<button onClick={() => { setBanOpen((v) => !v); setOk(''); setErr('') }} disabled={!!busy} className="btn btn-sq" style={{ ...btn, borderColor: '#d98b84', color: '#d98b84' }}>Ban…</button>
|
||||
<button onClick={unban} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'unban' ? '…' : 'Unban'}</button>
|
||||
{ok && <span style={{ color: '#7fd0a4', fontSize: '0.8rem' }}>{ok}</span>}
|
||||
{err && <span style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
|
||||
</div>
|
||||
|
||||
{banOpen && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 8, padding: '10px 12px', border: '1px solid var(--line)', borderRadius: 8, background: 'rgba(217,139,132,0.06)' }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Duration (sec, blank = permanent)</span>
|
||||
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" style={{ maxWidth: 150 }} />
|
||||
</label>
|
||||
<label style={{ display: 'block', flex: 1, minWidth: 160 }}>
|
||||
<span className="field-label">Reason (optional)</span>
|
||||
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
|
||||
</label>
|
||||
<button onClick={ban} disabled={busy === 'ban'} className="btn btn-primary btn-sq" style={{ borderColor: '#d98b84', background: '#d98b84', ...btn }}>
|
||||
{busy === 'ban' ? 'Banning…' : `Confirm ban ${account}`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
client/src/components/ShardStatusLink.jsx
Normal file
24
client/src/components/ShardStatusLink.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
// ── Core's fill for the `site.footer.status` extension slot ────────────────
|
||||
//
|
||||
// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1; the contract is
|
||||
// MODULE_API.md §3.7.
|
||||
//
|
||||
// This is the whole of what used to be four lines inline in SiteFooter.jsx, and
|
||||
// it is a file now for one reason: `/uo/shard` is a UO page, so the link goes
|
||||
// when the client half goes, and core should be deleting a registration rather
|
||||
// than editing its footer under extraction pressure.
|
||||
//
|
||||
// Note what core kept and what it handed over. Core owns the position in the row
|
||||
// and the separator around it, and passes `linkStyle` so the row stays visually
|
||||
// one row. The label, the destination, and the decision to render at all are
|
||||
// this file's — which is exactly the division a module inherits.
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function ShardStatusLink({ linkStyle }) {
|
||||
return (
|
||||
<Link to="/uo/shard" style={linkStyle}>
|
||||
Shard Status
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
41
client/src/components/VendorSales.jsx
Normal file
41
client/src/components/VendorSales.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ago } from '../lib/format.js'
|
||||
|
||||
// Owner-private recent player-vendor sales. `fetchSales` is the scope method
|
||||
// (api.player.shard.sales / api.admin.shard.sales) — the server only returns
|
||||
// sales for accounts linked to the caller.
|
||||
export default function VendorSales({ fetchSales }) {
|
||||
const [sales, setSales] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
fetchSales()
|
||||
.then((rows) => active && setSales(rows))
|
||||
.catch(() => active && setError('Could not load your vendor sales.'))
|
||||
return () => { active = false }
|
||||
}, [fetchSales])
|
||||
|
||||
if (error) return null
|
||||
if (!sales) return null
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 12 }}>Recent vendor sales</div>
|
||||
{sales.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No vendor sales recorded yet.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{sales.map((s) => (
|
||||
<li key={`${s.t}-${s.itemType}-${s.price}`} 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' }}>
|
||||
{s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} — {Number(s.price || 0).toLocaleString()}gp
|
||||
</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(s.t)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
72
client/src/core.js
Normal file
72
client/src/core.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// ── What core hands this module, on the client side ────────────────────────
|
||||
//
|
||||
// The client twin of `server/core.js`, and deliberately much simpler than it.
|
||||
// Every ported page imports its layout, its state components and its hooks from
|
||||
// here, so the boundary is one file and `client/scripts/checkExternals.js` has
|
||||
// one place to look. The normative contract is MODULE_API.md §3.2 and §3.4.
|
||||
//
|
||||
// **Why this is a plain read and the server's is a lazy accessor.** On the
|
||||
// server, `ctx` arrives at `register(ctx)` — after every `require` has already
|
||||
// run — so `server/core.js` has to defer resolution to call time or a router
|
||||
// would capture `undefined` at file scope. There is no such gap here.
|
||||
// `window.__rg` is published by core's own bundle (client/src/modules/shared.js),
|
||||
// and every module chunk is a deferred script the server injects *after* that
|
||||
// bundle's tag, so by the time the first line of this file executes the global
|
||||
// is already there. Reading it once, at module scope, is safe — and it means a
|
||||
// ported component keeps the ordinary `import { PageHeader } from '…'` shape
|
||||
// rather than being wrapped in an accessor that would cost it its identity.
|
||||
//
|
||||
// The absent-global case is handled by `shim/rg.js`, which every shim beside it
|
||||
// also goes through — the shims touch the global before this file does, so a
|
||||
// check here would be unreachable.
|
||||
|
||||
import { createElement } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { rg as shared } from './shim/rg.js'
|
||||
|
||||
const rg = shared()
|
||||
|
||||
// ── The shared-dependency self-check ───────────────────────────────────────
|
||||
//
|
||||
// Slice 0 carried this in entry.jsx, back when nothing else imported React and
|
||||
// an unexercised alias was an unproven one. The aliases are thoroughly exercised
|
||||
// now — thirty-five files import React and ten import the router — so what is
|
||||
// left for a runtime check to do is narrower, and worth keeping for exactly that
|
||||
// reason: the two BUILD guards (`assertSharedNotBundled` at resolution time,
|
||||
// `checkExternals.js` on the artifact) both reason about the chunk in isolation,
|
||||
// and neither can see the one failure that only exists once the chunk meets a
|
||||
// core: a `window.__rg` whose React is not the React that rendered the page.
|
||||
//
|
||||
// Identity is the only question worth asking. A second React satisfies every
|
||||
// type check, renders its first element happily, and then throws about an invalid
|
||||
// hook call somewhere unrelated.
|
||||
if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.createRoot || Link !== rg.router.Link) {
|
||||
console.error(
|
||||
'[module-uo] the bindings this chunk imported are not the ones core published — it has bundled ' +
|
||||
'its own copy of a shared dependency. Check the aliases in vite.config.js (MODULE_API.md §3.6).',
|
||||
)
|
||||
}
|
||||
|
||||
// The curated kit (§3.4). Seven members, closed: anything else this module needs
|
||||
// it bundles itself, which is why `components/` next door exists at all.
|
||||
export const {
|
||||
PublicLayout,
|
||||
PageHeader,
|
||||
Loading,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
useAsync,
|
||||
useAuth,
|
||||
useSite,
|
||||
} = rg.ui
|
||||
|
||||
// The registry, for entry.jsx. Everything else here is read by pages.
|
||||
export const registry = rg.registry
|
||||
|
||||
// The core API version this module was loaded against. Logged by entry.jsx —
|
||||
// `module.json`'s `coreApi` range is checked by the loader before this file is
|
||||
// ever served, so there is nothing to re-check, only something to report.
|
||||
export const coreApiVersion = rg.version
|
||||
|
||||
export default rg
|
||||
31
client/src/data/cityCrests.js
Normal file
31
client/src/data/cityCrests.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// Placeholder heraldry for the eight City-Loyalty cities. Each entry is a simple
|
||||
// emoji sigil + a ring colour — enough to make the Governors board and the
|
||||
// governor badge read as distinct "crests" today, swappable for real artwork
|
||||
// later WITHOUT touching any component: drop an `img` (an imported asset URL or a
|
||||
// public path) onto an entry and update CityCrest to prefer it.
|
||||
//
|
||||
// Keyed by the exact `city` string the sidecar sends (see INTEGRATION.md §4:
|
||||
// Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia).
|
||||
|
||||
export const CITY_CRESTS = {
|
||||
Britain: { sigil: '⚜', color: '#c9a24b', label: 'Britain' },
|
||||
Moonglow: { sigil: '🔮', color: '#7f8fd0', label: 'Moonglow' },
|
||||
Minoc: { sigil: '⚒', color: '#b0763f', label: 'Minoc' },
|
||||
Trinsic: { sigil: '⚓', color: '#5f9bd0', label: 'Trinsic' },
|
||||
Yew: { sigil: '🌳', color: '#5fb98a', label: 'Yew' },
|
||||
Jhelom: { sigil: '⚔', color: '#c76f6f', label: 'Jhelom' },
|
||||
SkaraBrae: { sigil: '🐎', color: '#9a8bbf', label: 'Skara Brae' },
|
||||
NewMagincia: { sigil: '🕊', color: '#cfc3a0', label: 'New Magincia' },
|
||||
}
|
||||
|
||||
const FALLBACK = { sigil: '🏰', color: '#8c96a5', label: '' }
|
||||
|
||||
// Look up a crest by the raw city key, tolerating spacing variants
|
||||
// ("Skara Brae" / "New Magincia"). `label` falls back to the given name.
|
||||
export function crestFor(city) {
|
||||
if (!city) return FALLBACK
|
||||
const key = String(city).replace(/\s+/g, '')
|
||||
const crest = CITY_CRESTS[city] || CITY_CRESTS[key]
|
||||
if (crest) return crest
|
||||
return { ...FALLBACK, label: String(city) }
|
||||
}
|
||||
72
client/src/data/regionBuckets.js
Normal file
72
client/src/data/regionBuckets.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// Roll the sidecar's raw presence.online `byRegion` map (many named ServUO
|
||||
// regions) up into a handful of labelled display buckets for the "Players Online"
|
||||
// widget. This is the ONE place to retune the grouping — edit BUCKETS (order +
|
||||
// membership) and the widget follows. Anything not matched lands in "Wilderness"
|
||||
// so the bucket counts always reconcile to the true total.
|
||||
|
||||
// Named cities/towns, matched as a prefix on the (space/apostrophe-stripped)
|
||||
// region name so "skara brae", "serpent's hold", etc. all resolve. Kept as a
|
||||
// list rather than one giant alternation regex (simpler to read and retune).
|
||||
const TOWN_PREFIXES = [
|
||||
'moonglow', 'minoc', 'trinsic', 'jhelom', 'yew', 'skarabrae', 'magincia',
|
||||
'newmagincia', 'vesper', 'nujelm', 'cove', 'ocllo', 'serpenthold', 'serpentshold',
|
||||
'wind', 'delucia', 'papua',
|
||||
]
|
||||
const normalizeRegion = (r) => String(r).toLowerCase().replace(/['’\s]/g, '')
|
||||
|
||||
// Ordered list of buckets. `label` shows in the widget; `match(region)` decides
|
||||
// membership. First matching bucket wins; the last bucket is the catch-all.
|
||||
export const BUCKETS = [
|
||||
{
|
||||
id: 'britain',
|
||||
label: 'Britain',
|
||||
// Passthrough for the capital + its immediate surrounds.
|
||||
match: (r) => /^britain/i.test(r),
|
||||
},
|
||||
{
|
||||
id: 'towns',
|
||||
label: 'Towns',
|
||||
// The other named cities/towns.
|
||||
match: (r) => {
|
||||
const norm = normalizeRegion(r)
|
||||
return TOWN_PREFIXES.some((t) => norm.startsWith(t))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dungeons',
|
||||
label: 'Dungeons',
|
||||
match: (r) =>
|
||||
/(despise|destard|deceit|shame|hythloth|covetous|wrong|terathan|fire|ice|orc cave|dungeon|abyss|doom|khaldun|wrong|blackthorn|exodus|labyrinth|underworld)/i.test(
|
||||
r,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'housing',
|
||||
label: 'Housing',
|
||||
// House regions expose themselves as named house/townhouse regions.
|
||||
match: (r) => /(house|townhouse|homestead|tent)/i.test(r),
|
||||
},
|
||||
{
|
||||
id: 'wilderness',
|
||||
label: 'Wilderness',
|
||||
// Catch-all: the unnamed "Wilderness" region + anything unmatched above.
|
||||
match: () => true,
|
||||
},
|
||||
]
|
||||
|
||||
// Given a raw { region: count } map, return [{ id, label, count }] in BUCKETS
|
||||
// order, dropping empty buckets, with the summed total also returned.
|
||||
export function bucketize(byRegion = {}) {
|
||||
const totals = new Map(BUCKETS.map((b) => [b.id, 0]))
|
||||
let total = 0
|
||||
for (const [region, n] of Object.entries(byRegion || {})) {
|
||||
const count = Number(n) || 0
|
||||
total += count
|
||||
const bucket = BUCKETS.find((b) => b.match(String(region))) || BUCKETS[BUCKETS.length - 1]
|
||||
totals.set(bucket.id, totals.get(bucket.id) + count)
|
||||
}
|
||||
const rows = BUCKETS.map((b) => ({ id: b.id, label: b.label, count: totals.get(b.id) })).filter(
|
||||
(r) => r.count > 0,
|
||||
)
|
||||
return { rows, total }
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
// ── module-uo's client entry point ─────────────────────────────────────────
|
||||
//
|
||||
// This file is the whole of the chunk's top-level behaviour: core injects
|
||||
// `dist/entry.js` as a `<script type="module" src>` before `</body>`, the module
|
||||
// registers what it has, and core renders it. The normative contract is
|
||||
// MODULE_API.md §3.3.
|
||||
// Core injects `dist/entry.js` as a `<script type="module" src>` before
|
||||
// `</body>`, this file registers what the module has, and core renders it. The
|
||||
// normative contract is MODULE_API.md §3.3.
|
||||
//
|
||||
// **Registration is synchronous and happens at evaluation time.** Module scripts
|
||||
// are deferred, so this runs after core's bundle — which is where `window.__rg`
|
||||
@@ -14,69 +13,177 @@
|
||||
// bug cost the Phase 2 client PR an afternoon and no unit test in either repo
|
||||
// can see it, which is why §7.7's browser smoke exists.
|
||||
//
|
||||
// Slice 0 of the Phase 3 extraction (MODULE_SYSTEM.md §2.7.1) registers NOTHING,
|
||||
// on purpose. What it proves is the delivery path itself, and the imports below
|
||||
// are how it proves the hardest part of it.
|
||||
// So everything below is a plain top-level call, and every page is a static
|
||||
// import. Lazy-loading the routes would be the natural instinct for a chunk this
|
||||
// size and it is the one thing this seam cannot have.
|
||||
|
||||
// These four specifiers are the whole shared-dependency contract, written the
|
||||
// ordinary way — which is the point. `vite.config.js` aliases each to a shim
|
||||
// that re-exports from `window.__rg`, so what ends up in the chunk is core's
|
||||
// React, core's renderer and core's router, and no second copy of any of them.
|
||||
// A module author writes these imports exactly as they would in any app.
|
||||
import { registry, coreApiVersion } from './core.js'
|
||||
import { IconShard } from './icons.jsx'
|
||||
import { useShardFlags } from './lib/useShardFeatures.js'
|
||||
|
||||
// Public pages — the twelve that used to live at /site/*.
|
||||
import Shard from './routes/public/Shard.jsx'
|
||||
import ShardActivity from './routes/public/ShardActivity.jsx'
|
||||
import ChampSpawns from './routes/public/ChampSpawns.jsx'
|
||||
import Guilds from './routes/public/Guilds.jsx'
|
||||
import Governors from './routes/public/Governors.jsx'
|
||||
import Houses from './routes/public/Houses.jsx'
|
||||
import Rules from './routes/public/Rules.jsx'
|
||||
import Atlas from './routes/public/Atlas.jsx'
|
||||
import AtlasCreature from './routes/public/AtlasCreature.jsx'
|
||||
import Leaderboards from './routes/public/Leaderboards.jsx'
|
||||
import Market from './routes/public/Market.jsx'
|
||||
import MarketVendor from './routes/public/MarketVendor.jsx'
|
||||
|
||||
// Admin views.
|
||||
import ShardAdmin from './routes/admin/ShardAdmin.jsx'
|
||||
import ShardOps from './routes/admin/ShardOps.jsx'
|
||||
import ShardVisibility from './routes/admin/ShardVisibility.jsx'
|
||||
import SpawnAtlas from './routes/admin/SpawnAtlas.jsx'
|
||||
import HousesAdmin from './routes/admin/HousesAdmin.jsx'
|
||||
import AdminCharacters from './routes/admin/AdminCharacters.jsx'
|
||||
import AdminCharacter from './routes/admin/AdminCharacter.jsx'
|
||||
|
||||
// Player-portal views.
|
||||
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
|
||||
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
|
||||
|
||||
// Extension-slot fills (§3.7) — module content inside a core page.
|
||||
import ShardStatusLink from './components/ShardStatusLink.jsx'
|
||||
import UserShardSections from './routes/admin/UserShardSections.jsx'
|
||||
import InviteGameAccountStep from './components/InviteGameAccountStep.jsx'
|
||||
|
||||
const ID = 'uo'
|
||||
|
||||
// ── Routes ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// They are here in slice 0 rather than arriving with the first page because an
|
||||
// unexercised alias is an unproven one: with nothing importing `react`, the
|
||||
// build emits a 0.2 kB chunk, `checkExternals` passes vacuously, and the seam
|
||||
// this whole slice exists to prove has not been touched.
|
||||
import { createElement, isValidElement } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Link } from 'react-router-dom'
|
||||
// Paths are relative to this module's namespace and core prefixes them:
|
||||
// `/uo/…`, `/admin/uo/…`, `/player/uo/…`. A module cannot write the segment its
|
||||
// routes hang under however it spells `path`, which is the point.
|
||||
//
|
||||
// **These SPA paths changed and the API paths did not.** `/site/shard` is now
|
||||
// `/uo/shard` and `/admin/shard-ops` is now `/admin/uo/ops` — a clean break with
|
||||
// no redirects, settled in MODULE_SYSTEM.md §2.7. Every URL in `api.js` is
|
||||
// byte-identical to the one core called, because §1.2 freezes the API surface
|
||||
// and the shipped Android app calls seven of these routes.
|
||||
//
|
||||
// The admin paths lost their `shard-` prefixes on the way through: under a `/uo/`
|
||||
// namespace `/admin/uo/shard-visibility` says "shard" twice, and a clean break is
|
||||
// the only moment that tidy-up is free.
|
||||
//
|
||||
// `gate` is core's own RoleGate, applied by core. A module cannot supply an auth
|
||||
// wrapper — the sidebar and the route table have to agree about who may see what.
|
||||
const STAFF = { roles: ['admin', 'moderator'] }
|
||||
|
||||
const rg = window.__rg
|
||||
registry.registerRoutes(ID, {
|
||||
public: [
|
||||
{ path: 'shard', element: <Shard /> },
|
||||
{ path: 'shard/activity', element: <ShardActivity /> },
|
||||
{ path: 'champs', element: <ChampSpawns /> },
|
||||
{ path: 'guilds', element: <Guilds /> },
|
||||
{ path: 'governors', element: <Governors /> },
|
||||
{ path: 'houses', element: <Houses /> },
|
||||
{ path: 'rules', element: <Rules /> },
|
||||
{ path: 'atlas', element: <Atlas /> },
|
||||
{ path: 'atlas/:slug', element: <AtlasCreature /> },
|
||||
{ path: 'leaderboards', element: <Leaderboards /> },
|
||||
{ path: 'market', element: <Market /> },
|
||||
{ path: 'market/vendors/:serial', element: <MarketVendor /> },
|
||||
],
|
||||
admin: [
|
||||
// Admin-only: the sidecar's configuration, who may see which surface, and
|
||||
// the atlas import. No `gate` on the other three because AdminLayout already
|
||||
// requires staff and these carry their own role rows below.
|
||||
{ path: 'link', element: <ShardAdmin /> },
|
||||
{ path: 'visibility', element: <ShardVisibility /> },
|
||||
{ path: 'atlas', element: <SpawnAtlas /> },
|
||||
{ path: 'ops', element: <ShardOps />, gate: STAFF },
|
||||
{ path: 'houses', element: <HousesAdmin />, gate: STAFF },
|
||||
// Self-service, and deliberately ungated: a staff member's own characters
|
||||
// are theirs to read whatever their role. Staff are a superset of players.
|
||||
{ path: 'characters', element: <AdminCharacters /> },
|
||||
{ path: 'characters/:serial', element: <AdminCharacter /> },
|
||||
],
|
||||
player: [
|
||||
{ path: 'characters', element: <PlayerCharacters /> },
|
||||
{ path: 'characters/:serial', element: <PlayerCharacter /> },
|
||||
],
|
||||
})
|
||||
|
||||
// A module that cannot see the global is a module core did not load — which
|
||||
// means the injection or the ordering broke, not the module. Say so, once,
|
||||
// rather than throwing a TypeError about a property of undefined three frames
|
||||
// deep in a component.
|
||||
if (!rg) {
|
||||
console.error('[module-uo] window.__rg is missing — core did not publish its shared dependencies before this chunk evaluated.')
|
||||
} else {
|
||||
// JSX, so the `react/jsx-runtime` alias is exercised too. That one is the
|
||||
// easiest of the four to get wrong and the hardest to notice: Vite's
|
||||
// object-form alias prefix-matches, so a `react` key silently captures
|
||||
// `react/jsx-runtime` as well, and the failure surfaces as `jsx is not a
|
||||
// function` in whichever component happens to render first.
|
||||
const probe = <span>module-uo</span>
|
||||
// ── Nav ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Rows interleave into CORE groups rather than appending as a "UO" block, which
|
||||
// is what keeps the extraction invisible in the sidebar (MODULE_SYSTEM.md §1.4).
|
||||
//
|
||||
// `feature` names a flag resolved by the provider registered below — by THIS
|
||||
// module, so the strings are the bare names they have always been and nothing
|
||||
// parses a namespace out of them.
|
||||
registry.registerNav(ID, {
|
||||
area: 'public',
|
||||
items: [
|
||||
{ label: 'Shard', to: '/uo/shard', feature: 'status' },
|
||||
{ label: 'Champions', to: '/uo/champs', feature: 'champs' },
|
||||
{ label: 'Guilds', to: '/uo/guilds', feature: 'guilds' },
|
||||
{ label: 'Governors', to: '/uo/governors', feature: 'governors' },
|
||||
{ label: 'Houses', to: '/uo/houses', feature: 'houses' },
|
||||
{ label: 'Rules', to: '/uo/rules', feature: 'ruleset' },
|
||||
{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas' },
|
||||
{ label: 'Leaderboards', to: '/uo/leaderboards', feature: 'leaderboards' },
|
||||
{ label: 'Market', to: '/uo/market', feature: 'market' },
|
||||
],
|
||||
})
|
||||
|
||||
// The self-check: are the bindings this chunk imported the SAME objects core
|
||||
// published? Identity is the only question worth asking. A bundled second
|
||||
// React satisfies every type check, renders its first element happily, and
|
||||
// then throws about an invalid hook call somewhere unrelated.
|
||||
const shared = [
|
||||
['react', createElement === rg.react.createElement],
|
||||
['react/jsx-runtime', isValidElement(probe)],
|
||||
['react-dom/client', createRoot === rg.reactDom.createRoot],
|
||||
['react-router-dom', Link === rg.router.Link],
|
||||
]
|
||||
const bundled = shared.filter(([, ok]) => !ok).map(([name]) => name)
|
||||
registry.registerNav(ID, {
|
||||
area: 'admin',
|
||||
items: [
|
||||
// Moderation: no `order`, because these two are last in that group today and
|
||||
// "append after core's rows" is exactly that — and stays that way if core
|
||||
// adds a moderation row later, which an explicit index would not.
|
||||
{ label: 'In-Game Ops', to: '/admin/uo/ops', icon: IconShard, group: 'Moderation', roles: ['admin', 'moderator'] },
|
||||
{ label: 'Houses', to: '/admin/uo/houses', icon: IconShard, group: 'Moderation', roles: ['admin', 'moderator'] },
|
||||
// System: these three sit MID-list, between Discord Bot and Web Bot Activity.
|
||||
// Core's rows are keyed by their index and an explicit `order` beats a
|
||||
// coincidental one at a tie, so all three asking for 8 — Web Bot Activity's
|
||||
// index once the UO rows are gone — lands them ahead of it, in this order.
|
||||
{ label: 'Shard (uo-link)', to: '/admin/uo/link', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
||||
{ label: 'Shard Visibility', to: '/admin/uo/visibility', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
||||
{ label: 'Spawn Atlas', to: '/admin/uo/atlas', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
||||
// No group: a trailing untitled group of its own, below core's Account row
|
||||
// rather than beside it (§3.3). One position lower than it sits today, and
|
||||
// the alternative — letting a module into core's furniture groups — is worse.
|
||||
{ label: 'My Characters', to: '/admin/uo/characters', icon: IconShard },
|
||||
],
|
||||
})
|
||||
|
||||
if (bundled.length) {
|
||||
console.error(
|
||||
`[module-uo] ${bundled.join(', ')} did not come from window.__rg — the chunk has bundled its own copy. ` +
|
||||
'Check the aliases in vite.config.js (MODULE_API.md §3.6).',
|
||||
)
|
||||
} else {
|
||||
// Registrations land here, slice by slice:
|
||||
//
|
||||
// rg.registry.registerRoutes('uo', { public: [...], admin: [...], player: [...] })
|
||||
// rg.registry.registerNav('uo', { area: 'public', items: [...] })
|
||||
// rg.registry.registerFeatureProvider('uo', 'uo', useShardFeatures)
|
||||
//
|
||||
// `MODULE_API_VERSION` is checked by core against `module.json`'s `coreApi`
|
||||
// before this file is ever served, so there is nothing to re-check here. It
|
||||
// is logged because a mismatch between the core that validated the manifest
|
||||
// and the core that published this global would otherwise be invisible from
|
||||
// the browser, which is where the client half actually fails.
|
||||
console.info(`[module-uo] loaded against core API ${rg.version}; shared dependencies OK`)
|
||||
}
|
||||
}
|
||||
registry.registerNav(ID, {
|
||||
area: 'player',
|
||||
// Order 0: Characters is the portal's first row today, and with the module
|
||||
// installed it is also what core's `/player` index resolves to.
|
||||
items: [{ label: 'Characters', to: '/player/uo/characters', order: 0 }],
|
||||
})
|
||||
|
||||
// ── Feature provider ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Core keeps a generic flag context and owns none of the semantics. Until this
|
||||
// slice core registered this same hook itself under owner id `core`, so that the
|
||||
// seam was exercised by real content from the day it was built; the registration
|
||||
// moves here and core's is deleted.
|
||||
registry.registerFeatureProvider(ID, ID, useShardFlags)
|
||||
|
||||
// ── Extension slots ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Three core pages have a piece of this module in them. Each was core's own fill
|
||||
// under owner id `core` until this slice, so all three are a swap rather than an
|
||||
// addition — and each throws rather than failing open if the slot is unknown or
|
||||
// already filled, which is how a slice that forgot to delete core's half finds
|
||||
// out immediately instead of rendering core's content forever (§3.7).
|
||||
registry.registerExtension(ID, 'site.footer.status', ShardStatusLink)
|
||||
registry.registerExtension(ID, 'admin.users.detail', UserShardSections)
|
||||
registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
|
||||
|
||||
// `module.json`'s `coreApi` range is checked by the loader before this file is
|
||||
// ever served, so there is nothing to re-check here. It is logged because a
|
||||
// mismatch between the core that validated the manifest and the core that
|
||||
// published this global would otherwise be invisible from the browser, which is
|
||||
// where the client half actually fails.
|
||||
console.info(`[module-uo] registered against core API ${coreApiVersion}`)
|
||||
|
||||
38
client/src/icons.jsx
Normal file
38
client/src/icons.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
// The nav glyph for this module's sidebar rows.
|
||||
//
|
||||
// `icon` is part of the nav-item contract as of MODULE_API 1.3.0 (§3.3): core
|
||||
// renders whatever component the row carries, exactly as it renders its own
|
||||
// rows' icons. Before that it did not, and the six UO rows would have extracted
|
||||
// as the only text-only entries in a sidebar where everything else has a glyph —
|
||||
// which reads as breakage rather than as a design.
|
||||
//
|
||||
// The wrapper matches core's own `Icon` (AdminLayout.jsx) — 18px, currentColor,
|
||||
// 1.6 stroke — deliberately and by copy, not by import. It is four attributes of
|
||||
// presentation, not a component: putting it in the shared kit would freeze core's
|
||||
// icon sizing into the contract, where changing it later would be a MAJOR bump.
|
||||
// A module that wants to look like the sidebar it is in matches the sidebar.
|
||||
const Icon = ({ children }) => (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** A faceted gem — the glyph core used for all six of these rows before they moved. */
|
||||
export const IconShard = () => (
|
||||
<Icon>
|
||||
<path d="M12 2l7 6-7 14-7-14z" />
|
||||
<path d="M5 8h14" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export default IconShard
|
||||
36
client/src/lib/format.js
Normal file
36
client/src/lib/format.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// A vendored copy of the one helper this module uses from core's
|
||||
// `client/src/lib/format.js`.
|
||||
//
|
||||
// **Vendored rather than added to the kit, and trimmed rather than copied
|
||||
// whole.** The kit is curated and closed (MODULE_API.md §3.4): every member
|
||||
// added to it is a minor version bump core can never take back, and a date
|
||||
// formatter is not the kind of thing a module should be unable to write. Copying
|
||||
// all six of core's helpers to get one would leave five with no consumer here
|
||||
// and a standing question about which copy is authoritative.
|
||||
//
|
||||
// The vendoring line, from the server half of the extraction (slice 1): **pure
|
||||
// leaf helpers may be copied, security controls may not.** This is a pure leaf.
|
||||
// Core's HTML sanitiser sits two files away and stays exactly where it is.
|
||||
//
|
||||
// The two copies will drift, and that is correct — core's is core's to change.
|
||||
// Nothing here reads a shared format.
|
||||
|
||||
function parse(value) {
|
||||
if (!value) return null
|
||||
const d = new Date(value)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
/** "3m ago". Coarse on purpose: the live feed's timestamps are approximate. */
|
||||
export function ago(value) {
|
||||
const d = parse(value)
|
||||
if (!d) return ''
|
||||
const secs = Math.max(1, Math.floor((Date.now() - d.getTime()) / 1000))
|
||||
if (secs < 60) return `${secs}s ago`
|
||||
const mins = Math.floor(secs / 60)
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs < 24) return `${hrs}h ago`
|
||||
const days = Math.floor(hrs / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
128
client/src/lib/shardEvents.js
Normal file
128
client/src/lib/shardEvents.js
Normal file
@@ -0,0 +1,128 @@
|
||||
// Shared formatting for shard events — used by the public Shard page, the
|
||||
// Activity feed, and the admin live feed. One place decides how each kind reads
|
||||
// and which category/badge it belongs to.
|
||||
|
||||
function nameOf(who) {
|
||||
if (!who) return 'Someone'
|
||||
if (typeof who === 'string') return who
|
||||
return who.name || who.acct || 'Someone'
|
||||
}
|
||||
|
||||
const n = (v) => Number(v || 0).toLocaleString()
|
||||
|
||||
// A one-line human description of each event kind, keyed by kind. Each formatter
|
||||
// takes the payload and returns a string. Conditional suffixes are pulled into
|
||||
// locals so no template literal is nested inside another.
|
||||
const DESCRIBERS = {
|
||||
'vendor.sale': (p) => {
|
||||
const qty = p.amount > 1 ? ` ×${p.amount}` : ''
|
||||
return `${p.itemType || 'An item'}${qty} sold for ${n(p.price)}gp`
|
||||
},
|
||||
'player.death': (p) => {
|
||||
const by = p.killer ? ` by ${nameOf(p.killer)}` : ''
|
||||
return `${nameOf(p.who)} was slain${by}`
|
||||
},
|
||||
'player.murdered': (p) => {
|
||||
const by = p.murderer ? ` by ${nameOf(p.murderer)}` : ''
|
||||
return `${nameOf(p.victim)} was murdered${by}`
|
||||
},
|
||||
'mob.killed': (p) => `${nameOf(p.killer)} killed ${nameOf(p.killed)}`,
|
||||
'skill.gain': (p) => {
|
||||
const base = p.base != null ? ` (${p.base})` : ''
|
||||
return `${nameOf(p.who)} gained ${p.skill}${base}`
|
||||
},
|
||||
'fame.change': (p) => `${nameOf(p.who)}’s fame changed to ${n(p.new)}`,
|
||||
'karma.change': (p) => `${nameOf(p.who)}’s karma changed to ${n(p.new)}`,
|
||||
'quest.complete': (p) => `${nameOf(p.who)} completed “${p.quest}”`,
|
||||
'house.decay': (p) => {
|
||||
const region = p.region ? ` — ${p.region}` : ''
|
||||
return `${p.name || 'A house'} is now ${p.to || p.stage}${region}`
|
||||
},
|
||||
'mob.login': (p) => `${nameOf(p.who)} entered the world`,
|
||||
'mob.logout': (p) => `${nameOf(p.who)} left the world`,
|
||||
'economy.supply': (p) => `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts`,
|
||||
'server.hello': (p) => `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles`,
|
||||
'server.shutdown': () => 'Shard shut down',
|
||||
'server.crashed': (p) => {
|
||||
const err = p.error ? `: ${p.error}` : ''
|
||||
return `Shard crashed${err}`
|
||||
},
|
||||
'champ.update': (p) => {
|
||||
const where = p.name || p.type || 'A champion spawn'
|
||||
if (p.status === 'active' && p.bossUp) {
|
||||
const boss = p.boss ? ` (${p.boss})` : ''
|
||||
return `${where}: boss is up${boss}`
|
||||
}
|
||||
if (p.status === 'active') {
|
||||
const level = p.level != null ? ` — level ${p.level}` : ''
|
||||
return `${where} is active${level}`
|
||||
}
|
||||
if (p.status === 'cooldown') return `${where} is on cooldown`
|
||||
return `${where} is ${p.status || 'idle'}`
|
||||
},
|
||||
'champ.remove': () => `A champion spawn ended`,
|
||||
// Support (help-page) queue + in-game moderation (admin channel only)
|
||||
'page.new': (p) => `New ${p.type || 'help'} page from ${nameOf(p.sender)}`,
|
||||
'page.updated': (p) => {
|
||||
const claimed = p.handled ? ' (claimed)' : ''
|
||||
return `Help page from ${nameOf(p.sender)} updated${claimed}`
|
||||
},
|
||||
'page.closed': (p) => `Help page ${p.pageId || ''} closed`,
|
||||
'admin.audit': (p) => {
|
||||
const on = p.target ? ` on ${p.target}` : ''
|
||||
const origin = p.origin ? ` [${p.origin}]` : ''
|
||||
return `${p.actor || 'Staff'} ${p.action || 'acted'}${on}${origin}`
|
||||
},
|
||||
// Staff / sensitive (admin channel only)
|
||||
'audit.set': (p) =>
|
||||
`${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})`,
|
||||
'audit.command': (p) => {
|
||||
const args = p.args ? ` ${p.args}` : ''
|
||||
return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${args}`
|
||||
},
|
||||
'cheat.fastwalk': (p) => {
|
||||
const ip = p.ip ? ` (${p.ip})` : ''
|
||||
return `Fast-walk flagged: ${nameOf(p.who)}${ip}`
|
||||
},
|
||||
'account.login.attempt': (p) => {
|
||||
const ip = p.ip ? ` from ${p.ip}` : ''
|
||||
return `Login attempt: ${p.acct}${ip}`
|
||||
},
|
||||
'gold.change': (p) => {
|
||||
const sign = p.delta >= 0 ? '+' : ''
|
||||
return `${p.acct}: gold ${sign}${n(p.delta)} → ${n(p.new)}`
|
||||
},
|
||||
}
|
||||
|
||||
// A one-line human description of an event. Accepts either a stored event
|
||||
// (with .payload) or a raw live frame (fields at top level).
|
||||
export function describe(ev) {
|
||||
const fmt = DESCRIBERS[ev.kind]
|
||||
return fmt ? fmt(ev.payload || ev) : ev.kind
|
||||
}
|
||||
|
||||
// Category grouping for the filter tabs.
|
||||
// Vendor sales are intentionally NOT a public category — they are owner-private
|
||||
// (a linked player sees their own under the portal). The admin live feed still
|
||||
// describes vendor.sale via describe() below.
|
||||
export const CATEGORIES = [
|
||||
{ id: 'all', label: 'All', kinds: null },
|
||||
{ id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] },
|
||||
{ id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] },
|
||||
{ id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] },
|
||||
]
|
||||
|
||||
const CATEGORY_OF = (() => {
|
||||
const m = {}
|
||||
for (const c of CATEGORIES) if (c.kinds) for (const k of c.kinds) m[k] = c.id
|
||||
return m
|
||||
})()
|
||||
|
||||
export function categoryOf(kind) {
|
||||
return CATEGORY_OF[kind] || 'other'
|
||||
}
|
||||
|
||||
// Short badge label for a kind (the part after the dot, title-cased-ish).
|
||||
export function kindLabel(kind) {
|
||||
return String(kind || '').replace(/[._]/g, ' ')
|
||||
}
|
||||
95
client/src/lib/useShardFeatures.js
Normal file
95
client/src/lib/useShardFeatures.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import api from '../api.js'
|
||||
|
||||
// Which shard surfaces the current viewer may reach, from
|
||||
// GET /public/shard/features. Admins configure this per feature (Admin → Shard
|
||||
// Visibility), so the nav can't be a static list any more.
|
||||
//
|
||||
// This is PRESENTATION only. The gate is server-side: a disabled feature 404s
|
||||
// and an out-of-rung one 403s whether or not the link is rendered. So while the
|
||||
// answer is still in flight we return `null` and callers show their default set
|
||||
// — better a link that briefly 403s than a nav that flickers in on every load.
|
||||
//
|
||||
// Cached module-level: the answer is per-viewer but stable for a session, and
|
||||
// every consumer would otherwise refetch it on mount.
|
||||
let cached = null
|
||||
let inFlight = null
|
||||
|
||||
export function resetShardFeatures() {
|
||||
cached = null
|
||||
inFlight = null
|
||||
}
|
||||
|
||||
export function useShardFeatures() {
|
||||
const [features, setFeatures] = useState(cached)
|
||||
|
||||
useEffect(() => {
|
||||
if (cached) return undefined
|
||||
let alive = true
|
||||
inFlight =
|
||||
inFlight ||
|
||||
api.shard
|
||||
.features()
|
||||
.then((data) => {
|
||||
cached = {
|
||||
level: data.level,
|
||||
set: new Set(data.features || []),
|
||||
// Not a visibility flag and deliberately carried alongside them: it
|
||||
// is the same per-viewer, once-a-session answer from the same
|
||||
// endpoint, and GameAccounts asking for it separately would be a
|
||||
// second round-trip for a field already on the wire.
|
||||
gameAccountSignup: Boolean(data.gameAccountSignup),
|
||||
}
|
||||
return cached
|
||||
})
|
||||
.catch(() => {
|
||||
// A failed lookup must not blank the nav — fall back to "show
|
||||
// everything" and let the server do the gating.
|
||||
cached = null
|
||||
inFlight = null
|
||||
return null
|
||||
})
|
||||
inFlight.then((result) => {
|
||||
if (alive) setFeatures(result)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return features
|
||||
}
|
||||
|
||||
// Convenience: true when `name` is visible, or when we don't know yet.
|
||||
export function canSee(features, name) {
|
||||
return !features || features.set.has(name)
|
||||
}
|
||||
|
||||
// The same answer in the shape core's generic feature seam takes: a Set-like of
|
||||
// the flags this viewer may see, or null while we do not know yet
|
||||
// (core's modules/featureGate.js). This module registers it as the provider for
|
||||
// the `uo` namespace in entry.jsx, and the nine shard-gated rows in the public
|
||||
// header are ours to gate as of slice 3.
|
||||
//
|
||||
// It used to be core that registered this hook, under owner id `core`, so that
|
||||
// the seam was exercised from the day it was built. That prediction held exactly
|
||||
// — this slice deleted a registration and a file rather than rewriting a header.
|
||||
export function useShardFlags() {
|
||||
const features = useShardFeatures()
|
||||
return features ? features.set : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this site offer game-account creation right now?
|
||||
*
|
||||
* `null` while unknown, which callers must treat as "not yet" rather than "no":
|
||||
* the form it guards would 403 anyway, and flashing it in and out is worse than
|
||||
* arriving a beat late. Unlike the visibility flags above this one fails CLOSED
|
||||
* on a lookup error — showing a create-account form on a shard that refuses them
|
||||
* is a dead end the player cannot tell from a bug, whereas a hidden nav row has
|
||||
* another way round.
|
||||
*/
|
||||
export function useGameAccountSignup() {
|
||||
const features = useShardFeatures()
|
||||
return features ? features.gameAccountSignup : null
|
||||
}
|
||||
54
client/src/lib/useShardFeed.js
Normal file
54
client/src/lib/useShardFeed.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import api from '../api.js'
|
||||
|
||||
// Subscribe to the public shard live-event SSE stream and keep a rolling buffer
|
||||
// of the most recent events. The browser talks to our own /public/shard/stream
|
||||
// route (plain HTTP EventSource) — never the sidecar's WebSocket — so the token
|
||||
// stays server-side and it works through any reverse proxy.
|
||||
//
|
||||
// EventSource auto-reconnects on drop, so there is no manual retry loop here; a
|
||||
// `connected` flag is exposed for a small live/offline indicator. `filter` (a
|
||||
// Set of kinds, optional) limits which events are buffered. `max` caps the
|
||||
// buffer length.
|
||||
export function useShardFeed({ url, filter, max = 40 } = {}) {
|
||||
const [events, setEvents] = useState([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
// Keep the latest filter in a ref so re-renders don't tear down the stream.
|
||||
const filterRef = useRef(filter)
|
||||
filterRef.current = filter
|
||||
const streamUrl = url || api.shardStreamUrl
|
||||
|
||||
useEffect(() => {
|
||||
// EventSource isn't available during SSR / very old browsers — degrade to
|
||||
// "no live feed" rather than throwing.
|
||||
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return undefined
|
||||
|
||||
const es = new EventSource(streamUrl, { withCredentials: true })
|
||||
|
||||
es.onopen = () => setConnected(true)
|
||||
es.onerror = () => setConnected(false) // EventSource will retry on its own
|
||||
|
||||
es.onmessage = (msg) => {
|
||||
let event
|
||||
try {
|
||||
event = JSON.parse(msg.data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!event || !event.kind) return
|
||||
const f = filterRef.current
|
||||
if (f && !f.has(event.kind)) return
|
||||
setEvents((prev) => {
|
||||
// Tag with a stable-ish local id for React keys (events carry t but can
|
||||
// collide within a ms) and cap the buffer.
|
||||
const next = [{ ...event, _id: `${event.kind}-${event.t}-${prev.length}` }, ...prev]
|
||||
return next.slice(0, max)
|
||||
})
|
||||
}
|
||||
|
||||
return () => es.close()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [max, streamUrl])
|
||||
|
||||
return { events, connected }
|
||||
}
|
||||
28
client/src/routes/admin/AdminCharacter.jsx
Normal file
28
client/src/routes/admin/AdminCharacter.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import CharacterSheet from '../../components/CharacterSheet.jsx'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, useAsync } from '../../core.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/uo/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>
|
||||
)
|
||||
}
|
||||
18
client/src/routes/admin/AdminCharacters.jsx
Normal file
18
client/src/routes/admin/AdminCharacters.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import CharacterStats from '../../components/CharacterStats.jsx'
|
||||
import GameAccounts from '../../components/GameAccounts.jsx'
|
||||
import VendorSales from '../../components/VendorSales.jsx'
|
||||
import api from '../../api.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/uo/characters/${serial}`} />
|
||||
<VendorSales fetchSales={api.admin.shard.sales} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
118
client/src/routes/admin/HousesAdmin.jsx
Normal file
118
client/src/routes/admin/HousesAdmin.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, useAsync } from '../../core.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>
|
||||
)
|
||||
}
|
||||
323
client/src/routes/admin/ShardAdmin.jsx
Normal file
323
client/src/routes/admin/ShardAdmin.jsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { describe, kindLabel } from '../../lib/shardEvents.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading } from '../../core.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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Game-account signup ─────────────────────────────────────────────────────
|
||||
//
|
||||
// This field lived in core's Site Settings until slice 3 of the extraction. It
|
||||
// moved here rather than being deleted or left behind, because its help text has
|
||||
// always described an agreement between this site and a ServUO shard — and half
|
||||
// of that agreement is configured in Bridge.cfg, which core has never heard of.
|
||||
//
|
||||
// The setting key and value are unchanged (`game_account_signup`), so an
|
||||
// instance that had this configured finds it here, set to what it was.
|
||||
const SIGNUP_MODES = [
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
function GameSignup() {
|
||||
const [mode, setMode] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api.admin.getSignupMode()
|
||||
.then((r) => active && setMode(r.mode))
|
||||
.catch(() => active && setError('Could not load the signup mode.'))
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
|
||||
async function save(next) {
|
||||
const previous = mode
|
||||
setMode(next); setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
await api.admin.saveSignupMode(next)
|
||||
setMsg('Saved.')
|
||||
} catch (err) {
|
||||
setMode(previous) // the select must not show a mode the server did not take
|
||||
setError(err.message || 'Could not save.')
|
||||
} 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)' }}>Game-account creation</h3>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||
Whether players can create a GAME account (for the game client) from the site. The game server’s 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 and after an invite is accepted.
|
||||
</p>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Mode</span>
|
||||
<select
|
||||
value={mode ?? ''}
|
||||
onChange={(e) => save(e.target.value)}
|
||||
disabled={busy || mode === null}
|
||||
className="input"
|
||||
style={{ maxWidth: 420 }}
|
||||
>
|
||||
{mode === null && <option value="">Loading…</option>}
|
||||
{SIGNUP_MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', minHeight: 20 }}>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
|
||||
<GameSignup />
|
||||
|
||||
<TownCrier />
|
||||
|
||||
<AdminLiveFeed />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
291
client/src/routes/admin/ShardOps.jsx
Normal file
291
client/src/routes/admin/ShardOps.jsx
Normal file
@@ -0,0 +1,291 @@
|
||||
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.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>
|
||||
)
|
||||
}
|
||||
325
client/src/routes/admin/ShardVisibility.jsx
Normal file
325
client/src/routes/admin/ShardVisibility.jsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading } from '../../core.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
|
||||
aren’t visible in game either, so publishing them would disclose something the shard
|
||||
itself doesn’t.
|
||||
</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>
|
||||
)
|
||||
}
|
||||
285
client/src/routes/admin/SpawnAtlas.jsx
Normal file
285
client/src/routes/admin/SpawnAtlas.jsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading } from '../../core.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 shard’s own ServUO files.
|
||||
It refreshes itself on every server start; everything here is for the times you don’t 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 shard’s 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>
|
||||
)
|
||||
}
|
||||
163
client/src/routes/admin/UserShardSections.jsx
Normal file
163
client/src/routes/admin/UserShardSections.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
// ── 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 { ago } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
import CharacterStats from '../../components/CharacterStats.jsx'
|
||||
import GameAccounts from '../../components/GameAccounts.jsx'
|
||||
import VendorSales from '../../components/VendorSales.jsx'
|
||||
import { useAsync } from '../../core.js'
|
||||
|
||||
// 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 user’s 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 & characters</SectionTitle>
|
||||
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/uo/characters/${serial}`} />
|
||||
<Standing scope={scope} />
|
||||
<OnlineNow scope={scope} />
|
||||
<Houses scope={scope} />
|
||||
<VendorSales fetchSales={scope.sales} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
28
client/src/routes/player/PlayerCharacter.jsx
Normal file
28
client/src/routes/player/PlayerCharacter.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import CharacterSheet from '../../components/CharacterSheet.jsx'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, useAsync } from '../../core.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/uo/characters" 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>
|
||||
)
|
||||
}
|
||||
58
client/src/routes/player/PlayerCharacters.jsx
Normal file
58
client/src/routes/player/PlayerCharacters.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import GameAccounts from '../../components/GameAccounts.jsx'
|
||||
import VendorSales from '../../components/VendorSales.jsx'
|
||||
import api from '../../api.js'
|
||||
import { useAsync } from '../../core.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/uo/characters/${serial}`} />
|
||||
<MyHouses />
|
||||
<VendorSales fetchSales={api.player.shard.sales} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
307
client/src/routes/public/Atlas.jsx
Normal file
307
client/src/routes/public/Atlas.jsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../../api.js'
|
||||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.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={`/uo/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, /uo/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>
|
||||
)
|
||||
}
|
||||
198
client/src/routes/public/AtlasCreature.jsx
Normal file
198
client/src/routes/public/AtlasCreature.jsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import api from '../../api.js'
|
||||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.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="/uo/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={`/uo/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>
|
||||
)
|
||||
}
|
||||
199
client/src/routes/public/ChampSpawns.jsx
Normal file
199
client/src/routes/public/ChampSpawns.jsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.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>
|
||||
)
|
||||
}
|
||||
184
client/src/routes/public/Governors.jsx
Normal file
184
client/src/routes/public/Governors.jsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { crestFor } from '../../data/cityCrests.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.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>
|
||||
)
|
||||
}
|
||||
166
client/src/routes/public/Guilds.jsx
Normal file
166
client/src/routes/public/Guilds.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.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>
|
||||
)
|
||||
}
|
||||
88
client/src/routes/public/Houses.jsx
Normal file
88
client/src/routes/public/Houses.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.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>
|
||||
)
|
||||
}
|
||||
236
client/src/routes/public/Leaderboards.jsx
Normal file
236
client/src/routes/public/Leaderboards.jsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync, useSite } from '../../core.js'
|
||||
|
||||
// Points / loyalty leaderboards (Protocol 3.0 §7). The shard carries ~25 separate
|
||||
// point currencies — Queen's Loyalty, Void Pool, Clean Up Britannia, the nine city
|
||||
// loyalties, the Doom/Khaldun/Kotl treasure systems — every one of them a standing
|
||||
// players build over months, and none of them visible anywhere but an in-game gump
|
||||
// until now.
|
||||
//
|
||||
// Loaded from /public/shard/points, then kept current from the live feed. Unlike
|
||||
// the ruleset (one frame = the whole thing), a points.board frame describes ONE
|
||||
// system, so live frames are merged over the fetched set by system key rather than
|
||||
// replacing it.
|
||||
const POINTS_KINDS = new Set(['points.board'])
|
||||
|
||||
// A board's display name may arrive as a literal (`nameString`), a cliloc id
|
||||
// (`nameNumber`), or both — Name is a ServUO TextDefinition. We have no cliloc
|
||||
// table on the site, so a cliloc-only board falls back to humanising its own
|
||||
// PointsType key, which is already close to a display name ("CleanUpBritannia" →
|
||||
// "Clean Up Britannia"). Better than showing a bare number.
|
||||
const humanise = (key) =>
|
||||
String(key || '')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
|
||||
const boardTitle = (b) => b.nameString || humanise(b.system)
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||
|
||||
// Merge live frames over the fetched boards. Newest frame per system wins; a
|
||||
// system that has never appeared in either is simply absent.
|
||||
function mergeBoards(fetched, events) {
|
||||
const bySystem = new Map()
|
||||
for (const b of Array.isArray(fetched) ? fetched : []) {
|
||||
if (b && b.system) bySystem.set(b.system, b)
|
||||
}
|
||||
// Events arrive newest-first, so walk backwards and let the newest land last.
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const ev = events[i]
|
||||
if (ev && ev.system) bySystem.set(ev.system, ev)
|
||||
}
|
||||
return [...bySystem.values()].sort((a, b) => boardTitle(a).localeCompare(boardTitle(b)))
|
||||
}
|
||||
|
||||
function Medal({ rank }) {
|
||||
// Gold / silver / bronze for the podium, plain for the rest.
|
||||
const tone = rank === 1 ? '#c9a24b' : rank === 2 ? '#b6bcc6' : rank === 3 ? '#b3805a' : 'var(--muted)'
|
||||
return (
|
||||
<span
|
||||
className="display"
|
||||
style={{
|
||||
flex: 'none', width: 26, textAlign: 'right', color: tone,
|
||||
fontSize: rank <= 3 ? '1rem' : '0.86rem',
|
||||
}}
|
||||
>
|
||||
{rank}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// One ranked player. `name` is absent rather than empty when an admin has gated
|
||||
// the leaderboards `name` field above this viewer's rung — the row still renders,
|
||||
// because the standing itself is the point.
|
||||
function Entry({ entry, best }) {
|
||||
const pct = best > 0 ? Math.max(2, Math.round((entry.points / best) * 100)) : 0
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||||
<Medal rank={entry.rank} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
color: entry.name ? 'var(--ink)' : 'var(--muted)',
|
||||
fontSize: '0.86rem', fontStyle: entry.name ? 'normal' : 'italic',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{entry.name || 'Name hidden'}
|
||||
</span>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
|
||||
{num(entry.points)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden', marginTop: 3 }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Board({ board }) {
|
||||
const { siteTitle } = useSite()
|
||||
const top = Array.isArray(board.top) ? board.top : []
|
||||
// Bars are relative to the board leader, not to maxPoints: most systems have no
|
||||
// cap (maxPoints 0), and where there is one the leader is often nowhere near it,
|
||||
// which would render every bar as a stub.
|
||||
const best = top.reduce((m, e) => Math.max(m, e.points || 0), 0)
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10 }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.02rem', color: 'var(--head)' }}>
|
||||
{boardTitle(board)}
|
||||
</h2>
|
||||
{Number.isFinite(board.players) && (
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem', flex: 'none' }}>
|
||||
{num(board.players)} ranked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{top.length === 0 ? (
|
||||
// A board nobody has scored on still gets a row, so the page reads as a set
|
||||
// of standings waiting to be filled rather than a stack of blanks. It is
|
||||
// deliberately NOT shaped like an Entry — no medal, no bar, an em dash where
|
||||
// a score goes — because a placeholder that looked like a real standing would
|
||||
// be a fabricated one. The first real entry replaces it.
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10, padding: '6px 0' }}>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
color: 'var(--muted)', fontSize: '0.86rem',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{siteTitle}
|
||||
</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.82rem', flex: 'none' }}>—</span>
|
||||
</div>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
|
||||
Nobody has earned points here yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{top.map((entry) => (
|
||||
<Entry key={`${board.system}-${entry.rank}-${entry.serial}`} entry={entry} best={best} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Number.isFinite(board.maxPoints) && board.maxPoints > 0 && (
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||
Maximum {num(board.maxPoints)} points
|
||||
</span>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Leaderboards() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.points())
|
||||
// Buffer generously: a single sweep can emit a frame for every system at once,
|
||||
// and a board dropped from the buffer would silently revert to its fetched copy.
|
||||
const { events, connected } = useShardFeed({ filter: POINTS_KINDS, max: 60 })
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const boards = useMemo(() => mergeBoards(data, events), [data, events])
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return boards
|
||||
// Match the board name, the raw system key, or any ranked player on it — the
|
||||
// last is what makes the filter useful ("where do I appear?").
|
||||
return boards.filter(
|
||||
(b) =>
|
||||
boardTitle(b).toLowerCase().includes(q) ||
|
||||
String(b.system).toLowerCase().includes(q) ||
|
||||
(b.top || []).some((e) => e.name && e.name.toLowerCase().includes(q)),
|
||||
)
|
||||
}, [boards, query])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader
|
||||
eyebrow="Live"
|
||||
title="Leaderboards"
|
||||
lead="Loyalty and points standings, straight from the shard — every currency the server tracks, updated as players climb."
|
||||
/>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem',
|
||||
color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the leaderboards right now." />}
|
||||
|
||||
{!loading && !error && boards.length === 0 && (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>
|
||||
The shard has not published any leaderboards yet.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!loading && !error && boards.length > 0 && (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Filter by board or player name…"
|
||||
aria-label="Filter leaderboards"
|
||||
style={{ maxWidth: 340, marginBottom: 14 }}
|
||||
/>
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<p className="sans dim">No board or ranked player matches “{query}”.</p>
|
||||
) : (
|
||||
<div className="grid-2" style={{ gap: 12, alignItems: 'start' }}>
|
||||
{shown.map((board) => (
|
||||
<Board key={board.system} board={board} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
322
client/src/routes/public/Market.jsx
Normal file
322
client/src/routes/public/Market.jsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../../api.js'
|
||||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.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={`/uo/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>
|
||||
)
|
||||
}
|
||||
99
client/src/routes/public/MarketVendor.jsx
Normal file
99
client/src/routes/public/MarketVendor.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import api from '../../api.js'
|
||||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.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="/uo/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="/uo/market" className="sans">← Back to the marketplace</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
338
client/src/routes/public/Rules.jsx
Normal file
338
client/src/routes/public/Rules.jsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.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>
|
||||
)
|
||||
}
|
||||
250
client/src/routes/public/Shard.jsx
Normal file
250
client/src/routes/public/Shard.jsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { describe } from '../../lib/shardEvents.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
import PlayersOnline from '../../components/PlayersOnline.jsx'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync, useAuth } from '../../core.js'
|
||||
|
||||
// Flavor line under the online/offline banner: online, configured-but-down, or
|
||||
// not configured yet.
|
||||
function statusMessage(online, enabled) {
|
||||
if (online) return 'The gate to Britannia stands open.'
|
||||
if (enabled) return 'The link to the game world is down — checking back automatically.'
|
||||
return 'Live shard data is not configured yet.'
|
||||
}
|
||||
|
||||
// ── Gold-supply sparkline ───────────────────────────────────────────────────
|
||||
function Sparkline({ series }) {
|
||||
if (!series || series.length < 2) return null
|
||||
const w = 320
|
||||
const h = 56
|
||||
const golds = series.map((s) => Number(s.gold) || 0)
|
||||
const min = Math.min(...golds)
|
||||
const max = Math.max(...golds)
|
||||
const span = max - min || 1
|
||||
const pts = series
|
||||
.map((s, i) => {
|
||||
const x = (i / (series.length - 1)) * w
|
||||
const y = h - ((Number(s.gold) || 0) - min) / span * h
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
})
|
||||
.join(' ')
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} preserveAspectRatio="none" aria-hidden="true">
|
||||
<polyline points={pts} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Stat tile (matches Status.jsx) ──────────────────────────────────────────
|
||||
function Stat({ value, label }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
|
||||
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 6 }}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Shard() {
|
||||
const { loading, error, data } = useAsync(() =>
|
||||
Promise.all([
|
||||
api.shard.status(),
|
||||
api.shard.idoc(),
|
||||
api.shard.economy(60),
|
||||
api.shard.online(),
|
||||
]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
|
||||
)
|
||||
const { events, connected } = useShardFeed({ max: 30 })
|
||||
const { user } = useAuth()
|
||||
// Staff in-game location is privileged: only admins/moderators see it. Players
|
||||
// and the public see that staff are online but not where. The server enforces
|
||||
// this too (it omits the location fields entirely for non-privileged callers).
|
||||
const canSeeLocation = user?.role === 'admin' || user?.role === 'moderator'
|
||||
|
||||
const status = data?.status
|
||||
const online = status?.pluginConnected
|
||||
const gold = status?.economy?.gold
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader eyebrow="Live" title="Shard" />
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load shard data right now." />}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<ConnectionBanner online={online} status={status} />
|
||||
|
||||
{/* Stat tiles */}
|
||||
<section className="grid-2" style={{ gap: 14, marginBottom: 24 }}>
|
||||
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
|
||||
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
||||
</section>
|
||||
|
||||
{/* Live players-online breakdown (total + region buckets) */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<PlayersOnline />
|
||||
</div>
|
||||
|
||||
<StaffOnline list={data.online} canSeeLocation={canSeeLocation} />
|
||||
|
||||
{/* Economy sparkline */}
|
||||
{data.economy && data.economy.length > 1 && (
|
||||
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||
Gold supply over time
|
||||
</div>
|
||||
<Sparkline series={data.economy} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
{/* Latest IDOC */}
|
||||
<FeedList
|
||||
title="Houses in danger (IDOC)"
|
||||
empty="No houses are collapsing right now."
|
||||
items={data.idoc.map((h) => {
|
||||
const region = h.region ? ` — ${h.region}` : ''
|
||||
return {
|
||||
id: h.serial,
|
||||
text: `${h.name || 'A house'}${region}`,
|
||||
when: h.updatedAt,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Live ticker */}
|
||||
<section className="panel" style={{ padding: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
||||
Live feed
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<Link to="/uo/shard/activity" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.78rem' }}>
|
||||
View all activity →
|
||||
</Link>
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||
Waiting for something to happen in the world…
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{events.map((ev) => (
|
||||
<li key={ev._id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(ev)}</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(ev.t)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Online/offline banner with the flavor line under it.
|
||||
function ConnectionBanner({ online, status }) {
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
padding: '24px 26px',
|
||||
border: `1px solid ${online ? 'rgba(95,185,138,0.45)' : '#5a4a2a'}`,
|
||||
borderRadius: 10,
|
||||
background: online
|
||||
? 'linear-gradient(180deg,rgba(22,46,34,0.5),rgba(16,26,20,0.4))'
|
||||
: 'linear-gradient(180deg,rgba(58,46,22,0.5),rgba(30,26,16,0.4))',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flex: 'none',
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
background: online ? 'var(--mode-live)' : 'var(--mode-maint)',
|
||||
boxShadow: `0 0 12px ${online ? 'rgba(95,185,138,0.7)' : 'rgba(230,194,106,0.7)'}`,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<strong className="display" style={{ display: 'block', fontSize: '1.2rem', color: online ? '#bfe6cf' : '#f0e3c4' }}>
|
||||
{online ? 'The shard is online' : 'The shard is offline'}
|
||||
</strong>
|
||||
<span className="sans" style={{ color: online ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
|
||||
{statusMessage(online, status?.enabled)}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Linked staff accounts currently online; in-game location is admin/mod-only.
|
||||
function StaffOnline({ list, canSeeLocation }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||
Staff online
|
||||
</div>
|
||||
{(!list || list.length === 0) ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No staff are online right now.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{list.map((p) => (
|
||||
<div key={p.serial} className="sans" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<span style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
|
||||
{p.name || p.serial}
|
||||
</span>
|
||||
{canSeeLocation && (
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>
|
||||
{p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedList({ title, items, empty }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: 20 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||
{title}
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>{empty}</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{items.map((it) => (
|
||||
<li key={it.id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.text}</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(it.when)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
78
client/src/routes/public/ShardActivity.jsx
Normal file
78
client/src/routes/public/ShardActivity.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
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.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.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="/uo/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>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,9 @@
|
||||
// itself, only harder to see, because it shows up as a hook dispatcher error in
|
||||
// a component that looks fine.
|
||||
|
||||
const jsxRuntime = window.__rg.jsxRuntime
|
||||
import { rg } from './rg.js'
|
||||
|
||||
const jsxRuntime = rg().jsxRuntime
|
||||
|
||||
export const { jsx, jsxs, jsxDEV, Fragment } = jsxRuntime
|
||||
|
||||
|
||||
4
client/src/shim/react-dom.js
vendored
4
client/src/shim/react-dom.js
vendored
@@ -5,7 +5,9 @@
|
||||
// react-dom, and one that resolved to a bundled copy would put a second
|
||||
// renderer in the page.
|
||||
|
||||
const reactDom = window.__rg.reactDom
|
||||
import { rg } from './rg.js'
|
||||
|
||||
const reactDom = rg().reactDom
|
||||
|
||||
export default reactDom.default ?? reactDom
|
||||
|
||||
|
||||
4
client/src/shim/react-router-dom.js
vendored
4
client/src/shim/react-router-dom.js
vendored
@@ -5,7 +5,9 @@
|
||||
// whose `useParams` returns nothing and whose `<Link>` navigates the browser
|
||||
// instead of the SPA, on a page that otherwise renders perfectly.
|
||||
|
||||
const router = window.__rg.router
|
||||
import { rg } from './rg.js'
|
||||
|
||||
const router = rg().router
|
||||
|
||||
export default router.default ?? router
|
||||
|
||||
|
||||
4
client/src/shim/react.js
vendored
4
client/src/shim/react.js
vendored
@@ -13,7 +13,9 @@
|
||||
// compiles to a named import, and a module with only a default export would fail
|
||||
// at link time in the browser with a message about the binding, not about this.
|
||||
|
||||
const react = window.__rg.react
|
||||
import { rg } from './rg.js'
|
||||
|
||||
const react = rg().react
|
||||
|
||||
export default react.default ?? react
|
||||
|
||||
|
||||
29
client/src/shim/rg.js
Normal file
29
client/src/shim/rg.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// The one place this module reads `window.__rg`, and the one place that says
|
||||
// something useful when it is not there.
|
||||
//
|
||||
// Every shim beside this file, and `src/core.js`, go through here. That is not
|
||||
// tidiness — it removes an ordering dependency that was genuinely fragile. ES
|
||||
// modules evaluate dependencies in the source order of their import statements,
|
||||
// so "put the friendly check in the file that is imported first" is a guarantee
|
||||
// that survives exactly until someone sorts the imports. Whichever module the
|
||||
// bundler happens to reach first, it reaches `window.__rg` through this.
|
||||
//
|
||||
// A missing global means core did not publish its shared dependencies before
|
||||
// this chunk evaluated: an injection or ordering fault in CORE (MODULE_API.md
|
||||
// §3.1), not a fault in this module. Without this, the first symptom is
|
||||
// "Cannot read properties of undefined (reading 'react')" thrown from a file
|
||||
// called react.js, which reads like the module bundled React wrong — the
|
||||
// opposite of what happened.
|
||||
export function rg() {
|
||||
const shared = window.__rg
|
||||
if (!shared) {
|
||||
throw new Error(
|
||||
'[module-uo] window.__rg is missing — core did not publish its shared dependencies before this ' +
|
||||
'chunk evaluated. That is an injection or ordering fault in core (MODULE_API.md §3.1), not a ' +
|
||||
'fault in this module.',
|
||||
)
|
||||
}
|
||||
return shared
|
||||
}
|
||||
|
||||
export default rg
|
||||
@@ -11,7 +11,8 @@ const uoLinkClient = require('../../utils/uoLinkClient')
|
||||
const shardLinks = require('../../model/shardLinks/shardLinks.model')
|
||||
const shardState = require('../../model/shardState/shardState.model')
|
||||
const shardClilocs = require('../../model/shardClilocs/shardClilocs.model')
|
||||
const { settings, activity } = require('../../core')
|
||||
const { activity } = require('../../core')
|
||||
const gameSignup = require('../../utils/gameSignup')
|
||||
const { salesForAccounts } = require('../../utils/shardSales')
|
||||
|
||||
const log = require('../../core').logger('player-shard')
|
||||
@@ -246,7 +247,7 @@ function mapCreateAccountError(res, result) {
|
||||
async function createGameAccount(req, res) {
|
||||
const { account, password } = req.body
|
||||
try {
|
||||
if (!(await settings.isGameAccountSignupEnabled())) {
|
||||
if (!(await gameSignup.isEnabled())) {
|
||||
return res.status(403).json({ message: 'Game-account signup is not available right now.' })
|
||||
}
|
||||
const result = await uoLinkClient.createAccount({
|
||||
|
||||
Reference in New Issue
Block a user