Merge pull request 'feat(client): the whole client half (phase 3, slice 3)' (#4) from feature/module-extract-client into main

Reviewed-on: #4
This commit is contained in:
2026-08-12 00:35:33 +00:00
59 changed files with 6885 additions and 112 deletions

View File

@@ -94,11 +94,16 @@ jobs:
- name: Install client deps - name: Install client deps
run: npm ci --prefix client run: npm ci --prefix client
- name: Run client tests # The build comes FIRST, and that ordering is load-bearing as of slice 3.
run: npm test --prefix client # Two of the client tests read `dist/entry.js` — the chunk's externals, and
# what it registers when imported against a fake `window.__rg` — and both
# skip when there is no build. Run the other way round they skip silently
# in CI, which is the worst of both: green, and not asking the question.
- name: Build the client chunk - name: Build the client chunk
run: npm run build --prefix client run: npm run build --prefix client
- name: Run client tests
run: npm test --prefix client
- name: Check the built chunk's externals (MODULE_API.md §3.6) - name: Check the built chunk's externals (MODULE_API.md §3.6)
run: npm run check:externals --prefix client run: npm run check:externals --prefix client

View File

@@ -27,38 +27,121 @@ import { fileURLToPath } from 'node:url'
const CHUNK = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'entry.js') const CHUNK = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'entry.js')
if (!fs.existsSync(CHUNK)) { /**
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`) * Which characters of the chunk are inside a string, template or comment.
process.exit(1) *
* **A check that reads code with a regexp fails on code that talks about
* itself.** The first real chunk this script ever saw — slice 3's, the first
* with any content in it — was rejected for importing `" }),\n !l && …`,
* because a button reading "Approve and import" put the token `import`
* immediately before a quote and the pattern could not tell that from a
* statement. Slice 0's chunk was 0.2 kB and this branch had never run against
* anything.
*
* The server half hit the same wall from the other side and answered it the same
* way (`server/scripts/checkImports.js`): a character walk, not a cleverer
* regexp. There is no regexp that distinguishes a keyword from the same letters
* inside a string, because that distinction is a property of the parse.
*
* A mask rather than a rewrite, because the two halves of a real import — the
* keyword and the specifier — sit on opposite sides of the boundary: the keyword
* must be OUTSIDE a string and the specifier must be a string. Blanking strings
* would take the answer with the noise.
*/
export function stringMask(src) {
const inString = new Uint8Array(src.length)
let i = 0
while (i < src.length) {
const c = src[i]
const two = src.slice(i, i + 2)
if (two === '//') {
const nl = src.indexOf('\n', i)
const end = nl === -1 ? src.length : nl
inString.fill(1, i, end)
i = end
} else if (two === '/*') {
const close = src.indexOf('*/', i + 2)
const end = close === -1 ? src.length : close + 2
inString.fill(1, i, end)
i = end
} else if (c === '"' || c === "'" || c === '`') {
// The opening quote itself stays unmasked: a specifier is read starting
// at its quote, and the regexp below anchors on that.
i += 1
while (i < src.length && src[i] !== c) {
// A backslash escapes the next character, including the closing quote.
const step = src[i] === '\\' ? 2 : 1
inString.fill(1, i, Math.min(i + step, src.length))
i += step
}
i += 1
} else {
i += 1
}
}
return inString
} }
const chunk = fs.readFileSync(CHUNK, 'utf8')
const problems = []
// Static and dynamic imports that survived into the output. A relative or // Static and dynamic imports that survived into the output. A relative or
// absolute specifier is a chunk that was split, which this build does not do — // absolute specifier is a chunk that was split, which this build does not do —
// `lib` mode with one entry emits one file — so anything here is a bare name. // `lib` mode with one entry emits one file — so anything here is a bare name.
const IMPORTS = /(?:^|[\s;}])(?:import\s+[^'"]*?from\s*|import\s*|import\()\s*['"]([^'"]+)['"]/g //
// **This pattern used to require whitespace after `import`, and so could not see
// the one shape the build actually emits.** Minified Rollup output is
// `import{useState}from"react"`, with no space anywhere in it; the old
// `import\s+[^'"]*?from` needed at least one, fell through to the bare-specifier
// alternative, met `{` instead of a quote and matched nothing. A bare named
// import — the most likely way for an alias to miss — would have passed this
// check silently. It was found by writing the test for the false POSITIVE above
// it, which is the argument for testing a check against both answers.
//
// `(?:^|[^\w$.])` rather than a whitespace class, so `a.import(x)` and
// `myimport"x"` are excluded for the right reason: `import` must not be preceded
// by an identifier character or a dot. `[^'"()]*?` cannot swallow a dynamic
// import's parenthesis.
const IMPORTS = /(?:^|[^\w$.])import\s*(?:\(\s*|[^'"()]*?from\s*)?['"]([^'"]+)['"]/g
/** Every bare specifier the chunk still imports at runtime. */
export function bareImports(chunk) {
const masked = stringMask(chunk)
const bare = new Set() const bare = new Set()
for (const [, specifier] of chunk.matchAll(IMPORTS)) { for (const match of chunk.matchAll(IMPORTS)) {
// Where the `import` keyword itself starts — one past the leading delimiter,
// unless the match began at position 0.
const keywordAt = match.index + (match[0].startsWith('import') ? 0 : 1)
if (masked[keywordAt]) continue // the letters, inside a string. Not a statement.
const specifier = match[1]
if (!specifier.startsWith('.') && !specifier.startsWith('/')) bare.add(specifier) if (!specifier.startsWith('.') && !specifier.startsWith('/')) bare.add(specifier)
} }
if (bare.size) { return [...bare]
problems.push(
`the chunk still imports ${[...bare].map((s) => `"${s}"`).join(', ')}` +
'nothing can resolve a bare specifier in the browser without an import map, ' +
'and CSP forbids one. Alias it to a shim in vite.config.js (MODULE_API.md §3.6).',
)
} }
// Fingerprints from the shared libraries' own source. Each is a string those // Fingerprints from the shared libraries' own source. Each is a string those
// packages ship and this module has no other reason to contain. // packages ship and this module has no other reason to contain.
//
// These are matched against the RAW chunk, deliberately unmasked: a bundled
// library's source arrives as code AND as its own error-message strings, and
// masking would discard half the evidence. The direction of the risk is opposite
// to the import check's — here a false positive is a fingerprint too generic,
// which is a fixable choice of probe, not a property of the parse.
const BUNDLED = [ const BUNDLED = [
{ what: 'react', probe: 'react.development.js' }, { what: 'react', probe: 'react.development.js' },
{ what: 'react', probe: 'Invalid hook call' }, { what: 'react', probe: 'Invalid hook call' },
{ what: 'react-dom', probe: 'react-dom.development.js' }, { what: 'react-dom', probe: 'react-dom.development.js' },
{ what: 'react-router-dom', probe: 'useRoutes() may be used only in the context of a <Router> component' }, { what: 'react-router-dom', probe: 'useRoutes() may be used only in the context of a <Router> component' },
] ]
/** Every problem with this chunk, as sentences. Empty means it ships. */
export function problemsWith(chunk) {
const problems = []
const bare = bareImports(chunk)
if (bare.length) {
problems.push(
`the chunk still imports ${bare.map((s) => `"${s}"`).join(', ')}` +
'nothing can resolve a bare specifier in the browser without an import map, ' +
'and CSP forbids one. Alias it to a shim in vite.config.js (MODULE_API.md §3.6).',
)
}
for (const { what, probe } of BUNDLED) { for (const { what, probe } of BUNDLED) {
if (chunk.includes(probe)) { if (chunk.includes(probe)) {
problems.push( problems.push(
@@ -68,12 +151,22 @@ for (const { what, probe } of BUNDLED) {
) )
} }
} }
return problems
}
// Only when run as a script. Importing this from a test must not read a chunk
// that may not have been built, and must not call process.exit.
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
if (!fs.existsSync(CHUNK)) {
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`)
process.exit(1)
}
const problems = problemsWith(fs.readFileSync(CHUNK, 'utf8'))
if (problems.length) { if (problems.length) {
console.error('\nThe built chunk breaks the shared-dependency rule:\n') console.error('\nThe built chunk breaks the shared-dependency rule:\n')
for (const p of problems) console.error(` - ${p}\n`) for (const p of problems) console.error(` - ${p}\n`)
process.exit(1) process.exit(1)
} }
const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1) const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1)
console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`) console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`)
}

231
client/src/api.js Normal file
View 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

View 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 &amp; 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>
)
}

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

View 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 330 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 youll 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>
)
}

View 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 users 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>
)
}

View 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} />
</>
)
}

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

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

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

View 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
View 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

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

View 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 }
}

View File

@@ -1,9 +1,8 @@
// ── module-uo's client entry point ───────────────────────────────────────── // ── module-uo's client entry point ─────────────────────────────────────────
// //
// This file is the whole of the chunk's top-level behaviour: core injects // Core injects `dist/entry.js` as a `<script type="module" src>` before
// `dist/entry.js` as a `<script type="module" src>` before `</body>`, the module // `</body>`, this file registers what the module has, and core renders it. The
// registers what it has, and core renders it. The normative contract is // normative contract is MODULE_API.md §3.3.
// MODULE_API.md §3.3.
// //
// **Registration is synchronous and happens at evaluation time.** Module scripts // **Registration is synchronous and happens at evaluation time.** Module scripts
// are deferred, so this runs after core's bundle — which is where `window.__rg` // 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 // 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. // 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, // So everything below is a plain top-level call, and every page is a static
// on purpose. What it proves is the delivery path itself, and the imports below // import. Lazy-loading the routes would be the natural instinct for a chunk this
// are how it proves the hardest part of it. // size and it is the one thing this seam cannot have.
// These four specifiers are the whole shared-dependency contract, written the import { registry, coreApiVersion } from './core.js'
// ordinary way — which is the point. `vite.config.js` aliases each to a shim import { IconShard, IconUser } from './icons.jsx'
// that re-exports from `window.__rg`, so what ends up in the chunk is core's import { useShardFlags } from './lib/useShardFeatures.js'
// 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. // 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 // Paths are relative to this module's namespace and core prefixes them:
// unexercised alias is an unproven one: with nothing importing `react`, the // `/uo/…`, `/admin/uo/…`, `/player/uo/…`. A module cannot write the segment its
// build emits a 0.2 kB chunk, `checkExternals` passes vacuously, and the seam // routes hang under however it spells `path`, which is the point.
// 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'
const rg = window.__rg
// 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>
// 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)
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: [...] }) // **These SPA paths changed and the API paths did not.** `/site/shard` is now
// rg.registry.registerNav('uo', { area: 'public', items: [...] }) // `/uo/shard` and `/admin/shard-ops` is now `/admin/uo/ops` — a clean break with
// rg.registry.registerFeatureProvider('uo', 'uo', useShardFeatures) // 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.
// //
// `MODULE_API_VERSION` is checked by core against `module.json`'s `coreApi` // The admin paths lost their `shard-` prefixes on the way through: under a `/uo/`
// before this file is ever served, so there is nothing to re-check here. It // namespace `/admin/uo/shard-visibility` says "shard" twice, and a clean break is
// is logged because a mismatch between the core that validated the manifest // the only moment that tidy-up is free.
// and the core that published this global would otherwise be invisible from //
// the browser, which is where the client half actually fails. // `gate` is core's own RoleGate, applied by core. A module cannot supply an auth
console.info(`[module-uo] loaded against core API ${rg.version}; shared dependencies OK`) // wrapper — the sidebar and the route table have to agree about who may see what.
} const STAFF = { roles: ['admin', 'moderator'] }
}
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 /> },
],
})
// ── 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' },
],
})
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 },
],
})
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', icon: IconUser, 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}`)

66
client/src/icons.jsx Normal file
View File

@@ -0,0 +1,66 @@
// 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>
)
/**
* A figure — the glyph core used for the portal's "Characters" row.
*
* A second icon rather than reusing IconShard, because these two rows sit in
* different navs and each matched its neighbours before the extraction: the
* admin sidebar's UO rows were all gems, and the portal's Characters row was a
* person beside Appeals' shield and Account's gear. Copied from core's
* PlayerPortalLayout, which uses a 16px frame and a heavier stroke than the
* admin one — matching the nav a row lands in is the whole reason `icon` exists.
*/
export const IconUser = () => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<circle cx="12" cy="8" r="4" />
<path d="M4 21a8 8 0 0 1 16 0" />
</svg>
)
export default IconShard

36
client/src/lib/format.js Normal file
View 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`
}

View 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, ' ')
}

View 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
}

View 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 }
}

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

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

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

View 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 servers own
SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them.
When enabled, a Create a game account form appears in the player portal 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>
)
}

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

View 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
arent visible in game either, so publishing them would disclose something the shard
itself doesnt.
</p>
)}
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{Object.entries(config).map(([name, settings]) => (
<FeatureRow
key={name}
name={name}
settings={settings}
defaults={defaults?.[name]}
ladder={ladder}
onPatch={patch}
/>
))}
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={saving} className="btn btn-primary btn-sq">
{saving ? 'Saving…' : 'Save changes'}
</button>
<button onClick={resetToDefaults} disabled={saving} className="btn btn-sq">
Restore defaults
</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
</div>
)
}

View File

@@ -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 shards own ServUO files.
It refreshes itself on every server start; everything here is for the times you dont want
to wait for one. Nothing on this page touches the sidecar the atlas is shard content, not
shard state, and stays complete while the shard is down.
</p>
</header>
{status?.pending && (
<PendingReview
pending={status.pending}
busy={busy}
onApprove={() => run('approve the refresh', () => api.admin.atlas.approve())}
onReject={() => run('decline the refresh', () => api.admin.atlas.reject())}
/>
)}
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 10px', fontSize: '1rem', color: 'var(--head)' }}>
What is loaded
</h3>
<Row label="Imported">
{status?.importedAt ? new Date(status.importedAt).toLocaleString() : 'Never'}
</Row>
<Row label="Facets">{status?.facets?.length ? status.facets.join(', ') : '—'}</Row>
{counts && (
<>
<Row label="Spawners">{counts.points?.toLocaleString() ?? '—'}</Row>
<Row label="Creatures">{counts.creatures?.toLocaleString() ?? '—'}</Row>
<Row label="Regions / landmarks">
{`${counts.regions?.toLocaleString() ?? '—'} / ${counts.landmarks?.toLocaleString() ?? '—'}`}
</Row>
<Row label="Champion altars">{counts.champions?.toLocaleString() ?? '—'}</Row>
</>
)}
<Row label="Tree readable">
{!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'}
</Row>
<Row label="Tree changed since import">
{status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'}
</Row>
</section>
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
ServUO tree
</h3>
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
Where the website reads the shards spawn files from the same host, a bind mount or a
shared volume. This setting wins over the <code>SERVUO_PATH</code> deploy default, so the
mount can move without a redeploy. Leave it blank to turn the atlas off.
</p>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
<input
className="input"
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="/srv/servuo"
style={{ flex: '1 1 320px', minWidth: 0 }}
/>
<button type="button" className="btn btn-sq" disabled={busy} onClick={savePath}>
Save path
</button>
</div>
</section>
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
Re-import
</h3>
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
Applies a map change without restarting. An unchanged tree costs nothing the source files
are hashed first and skipped when they match. A refresh that would remove a facet still
comes back here for approval rather than being applied.
</p>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<button
type="button"
className="btn btn-primary btn-sq"
disabled={busy || !status?.configured}
onClick={() => run('import the atlas', () => api.admin.atlas.import(force))}
>
{busy ? 'Working…' : 'Import now'}
</button>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: '0.85rem', cursor: 'pointer' }}>
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} />
Re-import even if the tree is unchanged
</label>
</div>
</section>
{(msg || error) && (
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
)}
</div>
)
}

View File

@@ -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 users accounts.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => (
<HouseRow key={h.serial} house={h} />
))}
</ul>
)}
</section>
)
}
export default function UserShardSections({ userId }) {
// Memoized so the child components' effects (keyed on `scope`) don't refetch
// on every render — the same reason UserDetail memoized it before this moved.
const scope = useMemo(() => api.admin.userShard(userId), [userId])
return (
<>
<CharacterStats scope={scope} />
<SectionTitle>Linked accounts &amp; characters</SectionTitle>
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/uo/characters/${serial}`} />
<Standing scope={scope} />
<OnlineNow scope={scope} />
<Houses scope={scope} />
<VendorSales fetchSales={scope.sales} />
</>
)
}

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

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

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

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

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

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

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

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

View 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' }}>&mdash;</span>
</div>
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
Nobody has earned points here yet.
</p>
</div>
) : (
<div>
{top.map((entry) => (
<Entry key={`${board.system}-${entry.rank}-${entry.serial}`} entry={entry} best={best} />
))}
</div>
)}
{Number.isFinite(board.maxPoints) && board.maxPoints > 0 && (
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
Maximum {num(board.maxPoints)} points
</span>
)}
</section>
)
}
export default function Leaderboards() {
const { loading, error, data } = useAsync(() => api.shard.points())
// Buffer generously: a single sweep can emit a frame for every system at once,
// and a board dropped from the buffer would silently revert to its fetched copy.
const { events, connected } = useShardFeed({ filter: POINTS_KINDS, max: 60 })
const [query, setQuery] = useState('')
const boards = useMemo(() => mergeBoards(data, events), [data, events])
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return boards
// Match the board name, the raw system key, or any ranked player on it — the
// last is what makes the filter useful ("where do I appear?").
return boards.filter(
(b) =>
boardTitle(b).toLowerCase().includes(q) ||
String(b.system).toLowerCase().includes(q) ||
(b.top || []).some((e) => e.name && e.name.toLowerCase().includes(q)),
)
}, [boards, query])
return (
<PublicLayout section="website">
<div className="shell page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader
eyebrow="Live"
title="Leaderboards"
lead="Loyalty and points standings, straight from the shard — every currency the server tracks, updated as players climb."
/>
<span
className="sans"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem',
color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6,
}}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the leaderboards right now." />}
{!loading && !error && boards.length === 0 && (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>
The shard has not published any leaderboards yet.
</p>
</section>
)}
{!loading && !error && boards.length > 0 && (
<>
<input
className="input"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter by board or player name…"
aria-label="Filter leaderboards"
style={{ maxWidth: 340, marginBottom: 14 }}
/>
{shown.length === 0 ? (
<p className="sans dim">No board or ranked player matches {query}.</p>
) : (
<div className="grid-2" style={{ gap: 12, alignItems: 'start' }}>
{shown.map((board) => (
<Board key={board.system} board={board} />
))}
</div>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

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

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

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

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

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

View File

@@ -7,7 +7,9 @@
// itself, only harder to see, because it shows up as a hook dispatcher error in // itself, only harder to see, because it shows up as a hook dispatcher error in
// a component that looks fine. // 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 export const { jsx, jsxs, jsxDEV, Fragment } = jsxRuntime

View File

@@ -5,7 +5,9 @@
// react-dom, and one that resolved to a bundled copy would put a second // react-dom, and one that resolved to a bundled copy would put a second
// renderer in the page. // renderer in the page.
const reactDom = window.__rg.reactDom import { rg } from './rg.js'
const reactDom = rg().reactDom
export default reactDom.default ?? reactDom export default reactDom.default ?? reactDom

View File

@@ -5,7 +5,9 @@
// whose `useParams` returns nothing and whose `<Link>` navigates the browser // whose `useParams` returns nothing and whose `<Link>` navigates the browser
// instead of the SPA, on a page that otherwise renders perfectly. // 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 export default router.default ?? router

View File

@@ -13,7 +13,9 @@
// compiles to a named import, and a module with only a default export would fail // 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. // 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 export default react.default ?? react

29
client/src/shim/rg.js Normal file
View 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

View File

@@ -20,6 +20,7 @@ import { fileURLToPath } from 'node:url'
const HERE = path.dirname(fileURLToPath(import.meta.url)) const HERE = path.dirname(fileURLToPath(import.meta.url))
const CLIENT = path.resolve(HERE, '..') const CLIENT = path.resolve(HERE, '..')
const { bareImports, problemsWith } = await import('../scripts/checkExternals.js')
const configModule = await import('../vite.config.js') const configModule = await import('../vite.config.js')
const config = configModule.default const config = configModule.default
const { SHARED, SHARED_PACKAGES: guardedPackages } = configModule const { SHARED, SHARED_PACKAGES: guardedPackages } = configModule
@@ -92,15 +93,63 @@ test('modulePreload polyfilling stays off — an inline bootstrap is refused und
assert.strictEqual(config.build.modulePreload.polyfill, false) assert.strictEqual(config.build.modulePreload.polyfill, false)
}) })
test('every shim reads from window.__rg and imports nothing', () => { test('exactly one file reads window.__rg, and every shim goes through it', () => {
// `shim/rg.js` is the single reader, and that is not tidiness: it is what
// makes the "core did not publish its dependencies" message reachable. The
// shims touch the global before anything else in the chunk does, so a check
// placed in the first-imported file is a guarantee that lasts until someone
// sorts the imports.
const dir = path.join(CLIENT, 'src', 'shim') const dir = path.join(CLIENT, 'src', 'shim')
const shims = fs.readdirSync(dir) const shims = fs.readdirSync(dir)
assert.ok(shims.length >= 4) assert.ok(shims.length >= 5)
for (const file of shims) { for (const file of shims) {
const source = fs.readFileSync(path.join(dir, file), 'utf8') const source = fs.readFileSync(path.join(dir, file), 'utf8')
assert.match(source, /window\.__rg/, `${file} does not read the global`) const code = source.replace(/^\s*\/\/.*$/gm, '') // the comments discuss the global
// A shim that imported anything would be a shim with a dependency to if (file === 'rg.js') {
// resolve, which is the problem it exists to remove. assert.match(code, /window\.__rg/, 'rg.js must be the one that reads the global')
assert.doesNotMatch(source, /^\s*import\s/m, `${file} imports something`) assert.doesNotMatch(code, /^\s*import\s/m, 'rg.js imports something')
continue
}
assert.doesNotMatch(code, /window\.__rg/, `${file} reads the global directly instead of via rg()`)
assert.match(code, /rg\(\)/, `${file} does not resolve through rg()`)
// A shim may import its sibling helper and nothing else — anything further
// would be a shim with a dependency to resolve, the problem it exists to remove.
for (const [, spec] of code.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)) {
assert.strictEqual(spec, './rg.js', `${file} imports ${spec}`)
}
} }
}) })
test('the built chunk has no bare imports and bundles no shared dependency', () => {
// The artifact check itself, over the artifact that ships. Skipped rather than
// failed when there is no build: `npm test` must be runnable before `npm run
// build`, and CI runs them in order.
const chunk = path.join(CLIENT, 'dist', 'entry.js')
if (!fs.existsSync(chunk)) return
assert.deepStrictEqual(problemsWith(fs.readFileSync(chunk, 'utf8')), [])
})
test('an import inside a string is not an import — the check reads code, not text', () => {
// The regression that made this necessary: slice 3's chunk was the first with
// any content in it, and a button labelled "Approve and import" put the token
// immediately before a quote. The check rejected the whole build, naming a
// fragment of minified JSX as the offending specifier.
const uiCopy = 'const a=n("button",{children:"Approve and import"}),b=1;'
assert.deepStrictEqual(bareImports(uiCopy), [])
// Neither is one in a comment, or in a template literal.
assert.deepStrictEqual(bareImports('// import "react" would be wrong here\nconst a=1'), [])
assert.deepStrictEqual(bareImports('/* import "react" */ const a=1'), [])
assert.deepStrictEqual(bareImports('const s=`import "react"`'), [])
// And a real one still is, in each form the build could emit.
assert.deepStrictEqual(bareImports('import"react";'), ['react'])
assert.deepStrictEqual(bareImports('import{useState}from"react";'), ['react'])
assert.deepStrictEqual(bareImports('const m=await import("react-dom/client")'), ['react-dom/client'])
// A relative specifier is a split chunk, not a shared dependency: not our concern.
assert.deepStrictEqual(bareImports('import"./other.js";'), [])
// The case that proves the mask tracks escapes: a quote escaped INSIDE a
// string must not end it early and leave the tail looking like code.
assert.deepStrictEqual(bareImports('const s="he said \\"import\\" loudly";'), [])
})

View File

@@ -0,0 +1,60 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { bucketize, BUCKETS } from '../src/data/regionBuckets.js'
// Unit-test the presence.online region roll-up for the "Players Online" widget.
// The load-bearing invariant: the bucket counts ALWAYS reconcile to the true
// total — anything unmatched lands in Wilderness — so the widget can never show
// a sum that disagrees with the headline online count.
test('bucketize groups named regions into their buckets', () => {
const { rows, total } = bucketize({
'Britain': 4,
'Moonglow': 2,
'Despise': 3,
'Green Acres House 12': 1, // not a town/dungeon name → Housing
})
const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
assert.equal(byId.britain, 4)
assert.equal(byId.towns, 2)
assert.equal(byId.dungeons, 3)
assert.equal(byId.housing, 1)
assert.equal(total, 10)
})
test('first match wins by BUCKETS order: a town-named house region counts as Towns, not Housing', () => {
// The towns regex is ^-anchored and towns is checked BEFORE housing, so a house
// region whose name starts with a town name is bucketed as Towns. Pinning this
// documents the ordering dependency for anyone retuning BUCKETS.
const { rows } = bucketize({ 'Trinsic House 12': 1 })
const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
assert.equal(byId.towns, 1)
assert.equal(byId.housing, undefined) // empty bucket dropped
})
test('an unmatched region falls through to Wilderness so counts always reconcile', () => {
const { rows, total } = bucketize({ 'Some Unnamed Field': 5, 'Wilderness': 2 })
const wilderness = rows.find((r) => r.id === 'wilderness')
assert.equal(wilderness.count, 7)
assert.equal(total, 7)
// The reconciliation guarantee: the buckets sum to the total, exactly.
assert.equal(rows.reduce((s, r) => s + r.count, 0), total)
})
test('bucketize returns rows in BUCKETS order and drops empty buckets', () => {
const { rows } = bucketize({ 'Despise': 1, 'Britain': 1 })
assert.deepEqual(rows.map((r) => r.id), ['britain', 'dungeons']) // BUCKETS order, no empty towns/housing/wilderness
})
test('bucketize coerces non-numeric counts and tolerates empty/nullish input', () => {
assert.deepEqual(bucketize({}), { rows: [], total: 0 })
assert.deepEqual(bucketize(), { rows: [], total: 0 })
const { total } = bucketize({ 'Britain': '3', 'Minoc': 'oops' })
assert.equal(total, 3) // '3' → 3, 'oops' → 0
})
test('the last bucket is the catch-all (its match accepts anything)', () => {
const last = BUCKETS[BUCKETS.length - 1]
assert.equal(last.id, 'wilderness')
assert.equal(last.match('literally anything'), true)
})

View File

@@ -0,0 +1,200 @@
// ── What the chunk registers, checked without a browser ────────────────────
//
// `build.test.js` says the honest thing about this half: its real failures are
// timing and resolution, and a DOM-less runner cannot see either. That is still
// true, and MODULE_API.md §7.7's browser smoke is still what proves the module
// works. But it left a gap worth closing, and slice 3 is when it started to
// matter: nothing checked *what* the chunk registers.
//
// It can be checked, because registration is the one thing this chunk does at
// evaluation time and it does it through an object core hands it. So: stand up a
// fake `window.__rg` with a recording registry and the real React behind it,
// import the BUILT artifact, and read back what it asked for. No DOM is needed
// because nothing renders — `<Shard />` is `jsx(Shard)`, an object, and the
// route table is full of them by design.
//
// What this catches that review does not: a page that silently stops being
// routed, a nav row whose `to` drifts from its route's path, a slot fill that
// was renamed on one side, and the whole registration surface disappearing
// because an exception was thrown halfway down entry.jsx.
//
// What it deliberately does NOT do is re-assert the paths as a literal list.
// The interesting property is that the nav and the routes AGREE, and a test that
// restates both is a second copy of the thing it is checking.
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import * as react from 'react'
import * as jsxRuntime from 'react/jsx-runtime'
import * as router from 'react-router-dom'
const HERE = path.dirname(fileURLToPath(import.meta.url))
const CHUNK = path.resolve(HERE, '..', 'dist', 'entry.js')
// A component, as far as the registry cares. The kit's real members are core's;
// nothing here renders, so a named stub is enough to be imported and passed on.
const stub = (name) => Object.assign(() => null, { displayName: name })
function fakeRg() {
const routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
const extensions = new Map()
return {
version: '1.3.0',
react,
jsxRuntime,
router,
// `react-dom/client` is imported for the identity check in core.js and never
// called — createRoot in a DOM-less process would throw. The shim reads this
// object, so the check compares against whatever is here.
reactDom: { createRoot: () => { throw new Error('not in a browser') } },
ui: Object.fromEntries(
['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite']
.map((n) => [n, stub(n)]),
),
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
registry: {
registerRoutes(id, byArea) {
for (const [area, list] of Object.entries(byArea || {})) {
for (const r of list || []) routes[area].push({ ...r, path: `${id}/${r.path}`, moduleId: id })
}
},
registerNav(id, { area, items }) {
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
},
registerFeatureProvider(id, namespace, hook) { providers.set(namespace, { id, hook }) },
registerExtension(id, slot, Component) {
if (extensions.has(slot)) throw new Error(`slot "${slot}" already filled`)
extensions.set(slot, { id, Component })
},
routesFor: (area) => routes[area],
navFor: (area) => nav[area],
},
_read: () => ({ routes, nav, providers, extensions }),
}
}
// Loaded once: an ES module is evaluated a single time per process however many
// times it is imported, so every test below reads the same registration pass —
// which is also how it behaves in a browser.
let registered = null
let skip = false
if (!fs.existsSync(CHUNK)) {
skip = true
} else {
const rg = fakeRg()
globalThis.window = { __rg: rg }
await import(`${new URL(`file://${CHUNK.split(path.sep).join('/')}`)}`)
registered = rg._read()
}
const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run npm run build' }, fn)
it('registers routes in all three areas, namespaced under the module id', () => {
const { routes } = registered
assert.equal(routes.public.length, 12)
assert.equal(routes.admin.length, 7)
assert.equal(routes.player.length, 2)
for (const area of ['public', 'admin', 'player']) {
for (const r of routes[area]) {
assert.match(r.path, /^uo\//, `${area} route "${r.path}" is not under the module namespace`)
assert.ok(r.element, `${area} route "${r.path}" has no element`)
}
}
})
it('every route path is distinct within its area', () => {
// Two routes on one path is a page that can never be reached, and React
// renders the first without complaint.
for (const [area, list] of Object.entries(registered.routes)) {
const paths = list.map((r) => r.path)
assert.equal(new Set(paths).size, paths.length, `duplicate path in ${area}`)
}
})
it('every nav row points at a route this module actually registered', () => {
// The agreement that matters, and the one that rots quietly: a row survives a
// route rename and becomes a link to core's catch-all redirect. Nav rows carry
// the FULL rendered path (`/uo/shard`), routes carry the namespaced one
// (`uo/shard`), and reconciling them is the whole test.
const rendered = {
public: (p) => `/${p}`,
admin: (p) => `/admin/${p}`,
player: (p) => `/player/${p}`,
}
for (const [area, rows] of Object.entries(registered.nav)) {
const reachable = new Set(registered.routes[area].map((r) => rendered[area](r.path)))
for (const row of rows) {
assert.ok(
reachable.has(row.to),
`${area} nav row "${row.label}" links to ${row.to}, which no route serves`,
)
}
}
})
it('every admin and player nav row carries an icon', () => {
// Both of those navs render a glyph on every core row, so a row without one
// reads as breakage rather than as a design. The PUBLIC header is text
// buttons and is deliberately excluded.
//
// The player half of this assertion is not symmetry for its own sake. Core's
// PlayerPortalLayout rendered `<n.icon />` UNGUARDED — fine for as long as
// every row in it was core's own and had one, and React error #130 with a
// blank portal the moment a module registered one without. Core is guarded
// now, but a missing icon there is still a visible defect and this is the
// cheap place to catch it.
for (const area of ['admin', 'player']) {
for (const row of registered.nav[area]) {
assert.equal(typeof row.icon, 'function', `${area} nav row "${row.label}" has no icon`)
}
}
})
it('a nav row that gates on a feature is gated by a namespace this module provides', () => {
// Resolution is by the REGISTERING module (§3.3), so a `feature` on a row from
// a module that registered no provider resolves against nothing — and
// everything fails open, which would re-advertise surfaces an operator hid.
const gated = Object.values(registered.nav).flat().filter((r) => r.feature)
assert.ok(gated.length > 0)
assert.ok(registered.providers.has('uo'), 'rows carry feature gates but no provider was registered')
})
it('fills the three extension slots, each with a component', () => {
const { extensions } = registered
assert.deepEqual(
[...extensions.keys()].sort(),
['admin.users.detail', 'player.invite.accepted', 'site.footer.status'],
)
for (const [slot, { id, Component }] of extensions) {
assert.equal(id, 'uo', `${slot} was filled under the wrong owner id`)
assert.equal(typeof Component, 'function', `${slot} was not filled with a component`)
}
})
it('the manifest\'s declared server slot is one this module fills', () => {
// module.json declares SERVER slots and the loader validates them before the
// chunk is ever served. Client slots cannot be declared there — the server has
// no knowledge of them — so this is the one place the two halves are compared.
const manifest = JSON.parse(fs.readFileSync(path.resolve(HERE, '..', '..', 'module.json'), 'utf8'))
for (const slot of manifest.extensions || []) {
assert.ok(registered.extensions.has(slot), `module.json declares "${slot}" and the chunk does not fill it`)
}
})
it('registers under exactly one module id, matching the manifest', () => {
const manifest = JSON.parse(fs.readFileSync(path.resolve(HERE, '..', '..', 'module.json'), 'utf8'))
const owners = new Set([
...Object.values(registered.routes).flat().map((r) => r.moduleId),
...Object.values(registered.nav).flat().map((r) => r.moduleId),
...[...registered.extensions.values()].map((e) => e.id),
...[...registered.providers.values()].map((p) => p.id),
])
assert.deepEqual([...owners], [manifest.id])
})

View File

@@ -0,0 +1,74 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { describe, categoryOf, kindLabel, CATEGORIES } from '../src/lib/shardEvents.js'
// Unit-test the shared shard-event formatter — the single place that decides how
// each event kind reads and which filter category it belongs to. These strings
// are user-facing on the public Shard page, the Activity feed, and the admin
// live feed, so a regression here is visible everywhere at once.
// ── describe(): works on both stored (.payload) and live (top-level) frames ──
test('describe reads fields from .payload when present, else the top level', () => {
const stored = { kind: 'quest.complete', payload: { who: { name: 'Ada' }, quest: 'The Cavern' } }
const live = { kind: 'quest.complete', who: { name: 'Ada' }, quest: 'The Cavern' }
assert.equal(describe(stored), 'Ada completed “The Cavern”')
assert.equal(describe(live), 'Ada completed “The Cavern”')
})
test('describe resolves an actor from name → acct → "Someone"', () => {
assert.equal(describe({ kind: 'mob.login', who: { name: 'Bob' } }), 'Bob entered the world')
assert.equal(describe({ kind: 'mob.login', who: { acct: 'acct7' } }), 'acct7 entered the world')
assert.equal(describe({ kind: 'mob.login', who: null }), 'Someone entered the world')
assert.equal(describe({ kind: 'mob.login', who: 'RawString' }), 'RawString entered the world')
})
test('describe pluralizes a vendor sale only when amount > 1 and formats the price', () => {
assert.equal(describe({ kind: 'vendor.sale', itemType: 'Katana', amount: 1, price: 1200 }), 'Katana sold for 1,200gp')
assert.equal(describe({ kind: 'vendor.sale', itemType: 'Arrow', amount: 40, price: 80 }), 'Arrow ×40 sold for 80gp')
})
test('describe includes the killer only when present (optional clause)', () => {
assert.equal(describe({ kind: 'player.death', who: { name: 'Ada' } }), 'Ada was slain')
assert.equal(
describe({ kind: 'player.death', who: { name: 'Ada' }, killer: { name: 'Orc' } }),
'Ada was slain by Orc',
)
})
test('describe champ.update branches on status and boss state', () => {
assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', bossUp: true }), 'Rikktor: boss is up')
assert.equal(
describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', level: 3 }),
'Rikktor is active — level 3',
)
assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'cooldown' }), 'Rikktor is on cooldown')
})
test('describe falls back to the raw kind for an unknown event', () => {
assert.equal(describe({ kind: 'some.future.kind' }), 'some.future.kind')
})
// ── categoryOf(): membership + catch-all ────────────────────────────────
test('categoryOf groups kinds per the CATEGORIES table, and unknowns are "other"', () => {
assert.equal(categoryOf('player.death'), 'pvp')
assert.equal(categoryOf('skill.gain'), 'progress')
assert.equal(categoryOf('house.decay'), 'world')
assert.equal(categoryOf('vendor.sale'), 'other') // deliberately not a public category
assert.equal(categoryOf('totally.unknown'), 'other')
})
test('every kind listed in CATEGORIES maps back to that category (table stays consistent)', () => {
for (const cat of CATEGORIES) {
if (!cat.kinds) continue
for (const kind of cat.kinds) {
assert.equal(categoryOf(kind), cat.id, `${kind} should be in ${cat.id}`)
}
}
})
// ── kindLabel(): badge text ─────────────────────────────────────────────
test('kindLabel turns dots/underscores into spaces and tolerates empty input', () => {
assert.equal(kindLabel('player.death'), 'player death')
assert.equal(kindLabel('account.login.attempt'), 'account login attempt')
assert.equal(kindLabel(null), '')
})

View File

@@ -1,8 +1,8 @@
{ {
"id": "uo", "id": "uo",
"name": "Ultima Online", "name": "Ultima Online",
"version": "0.2.0", "version": "0.3.0",
"coreApi": "^1.1.0", "coreApi": "^1.3.0",
"server": "server/index.js", "server": "server/index.js",
"client": { "entry": "client/dist/entry.js" }, "client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql", "schema": "server/db/schema.sql",

View File

@@ -10,6 +10,7 @@ const uoLinkConfig = require('../../model/uoLinkConfig/uoLinkConfig.model')
const uoLinkClient = require('../../utils/uoLinkClient') const uoLinkClient = require('../../utils/uoLinkClient')
const uoLinkSocket = require('../../utils/uoLinkSocket') const uoLinkSocket = require('../../utils/uoLinkSocket')
const shardBroadcast = require('../../utils/shardBroadcast') const shardBroadcast = require('../../utils/shardBroadcast')
const gameSignup = require('../../utils/gameSignup')
const { activity } = require('../../core') const { activity } = require('../../core')
const log = require('../../core').logger('admin-uolink') const log = require('../../core').logger('admin-uolink')
@@ -75,6 +76,34 @@ async function saveConfig(req, res) {
} }
} }
// GET /admin/uo-link/signup-mode — whether this site creates game accounts.
//
// Core's Site Settings carried this field until slice 3, with help text naming
// Bridge.cfg. It reads as UO policy because it is: the site's mode and the
// shard's own SignupMode have to agree, and only one of those two is core's.
async function getSignupMode(req, res) {
try {
return res.json({ mode: await gameSignup.getMode(), modes: gameSignup.MODES })
} catch (err) {
log.error('uoLink.getSignupMode', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// PUT /admin/uo-link/signup-mode
async function saveSignupMode(req, res) {
const { mode } = req.body
try {
await gameSignup.setMode(mode, req.user.id)
await activity.log({ req, action: 'uoLink.signupMode.update', detail: { mode } })
log.info('game-signup mode updated', { by: req.user.username, mode })
return res.json({ mode })
} catch (err) {
log.error('uoLink.saveSignupMode', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /admin/uo-link/towncrier — publish/replace a town-crier message. // POST /admin/uo-link/towncrier — publish/replace a town-crier message.
async function postTownCrier(req, res) { async function postTownCrier(req, res) {
const { id, lines, durationSec } = req.body const { id, lines, durationSec } = req.body
@@ -120,4 +149,4 @@ function stream(req, res) {
shardBroadcast.subscribe(req, res, 'admin') shardBroadcast.subscribe(req, res, 'admin')
} }
module.exports = { getConfig, saveConfig, postTownCrier, deleteTownCrier, stream } module.exports = { getConfig, saveConfig, getSignupMode, saveSignupMode, postTownCrier, deleteTownCrier, stream }

View File

@@ -24,6 +24,7 @@ const express = core.express
const { body, param } = core.validator const { body, param } = core.validator
const uoLink = require('./uoLink.controller') const uoLink = require('./uoLink.controller')
const gameSignup = require('../../utils/gameSignup')
const { requireRole, validate } = core.middleware const { requireRole, validate } = core.middleware
const uoLinkRouter = express.Router() const uoLinkRouter = express.Router()
@@ -58,6 +59,42 @@ uoLinkRouter.put(
validate, validate,
uoLink.saveConfig, uoLink.saveConfig,
) )
// ── Game-account signup mode ───────────────────────────────────────────────
//
// New in slice 3, and new only in the sense that the field moved: core's Site
// Settings has carried `game_account_signup` since long before the extraction,
// and its help text has always been about a game server. The setting key and its
// stored value are unchanged, so an existing instance keeps its configured mode.
uoLinkRouter.get(
'/signup-mode',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Get the game-account signup mode (admin only)'
// #swagger.description = 'Whether the site offers game-account creation, and in which direction. The shard\'s own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The configured mode and the legal values', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string" }, modes: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
uoLink.getSignupMode,
)
uoLinkRouter.put(
'/signup-mode',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Set the game-account signup mode (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["mode"], properties: { mode: { type: "string", enum: ["disabled","website","hybrid","game"] } } } } } } */
/* #swagger.responses[200] = { description: 'The saved mode', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string" } } } } } } */
/* #swagger.responses[400] = { description: 'Unknown mode', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
// Validated here as well as in gameSignup.setMode: the list is the same list,
// and the difference is the answer. A rejected value must be a 400 naming the
// field, not a 500 from a thrown Error the controller could only guess about.
body('mode').isIn(gameSignup.MODES),
validate,
uoLink.saveSignupMode,
)
uoLinkRouter.post( uoLinkRouter.post(
'/towncrier', '/towncrier',
// #swagger.tags = ['Admin · Shard'] // #swagger.tags = ['Admin · Shard']

View File

@@ -11,7 +11,8 @@ const uoLinkClient = require('../../utils/uoLinkClient')
const shardLinks = require('../../model/shardLinks/shardLinks.model') const shardLinks = require('../../model/shardLinks/shardLinks.model')
const shardState = require('../../model/shardState/shardState.model') const shardState = require('../../model/shardState/shardState.model')
const shardClilocs = require('../../model/shardClilocs/shardClilocs.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 { salesForAccounts } = require('../../utils/shardSales')
const log = require('../../core').logger('player-shard') const log = require('../../core').logger('player-shard')
@@ -246,7 +247,7 @@ function mapCreateAccountError(res, result) {
async function createGameAccount(req, res) { async function createGameAccount(req, res) {
const { account, password } = req.body const { account, password } = req.body
try { try {
if (!(await settings.isGameAccountSignupEnabled())) { if (!(await gameSignup.isEnabled())) {
return res.status(403).json({ message: 'Game-account signup is not available right now.' }) return res.status(403).json({ message: 'Game-account signup is not available right now.' })
} }
const result = await uoLinkClient.createAccount({ const result = await uoLinkClient.createAccount({

View File

@@ -15,6 +15,7 @@ const shardMarket = require('../../model/shardMarket/shardMarket.model')
const uoLinkConfig = require('../../model/uoLinkConfig/uoLinkConfig.model') const uoLinkConfig = require('../../model/uoLinkConfig/uoLinkConfig.model')
const broadcast = require('../../utils/shardBroadcast') const broadcast = require('../../utils/shardBroadcast')
const visibility = require('../../utils/shardVisibility') const visibility = require('../../utils/shardVisibility')
const gameSignup = require('../../utils/gameSignup')
const log = require('../../core').logger('public-shard') const log = require('../../core').logger('public-shard')
@@ -386,7 +387,20 @@ async function getFeatures(req, res) {
try { try {
const config = await visibility.getConfig() const config = await visibility.getConfig()
const level = await visibility.viewerLevel(req) const level = await visibility.viewerLevel(req)
return res.json({ level, features: visibility.visibleFeatures(level, config) }) return res.json({
level,
features: visibility.visibleFeatures(level, config),
// Whether this site offers game-account creation. Not a visibility flag
// and deliberately carried here anyway: it is the same per-viewer,
// once-a-session answer, and the alternative is a second endpoint and a
// second round-trip for one boolean. It is NOT audience-gated — it says
// what the site offers, not what this caller may see, and the portal's
// create-account form is behind a session either way.
//
// Core's public settings carried this until slice 3. It is ours now
// (utils/gameSignup.js), because the setting is about a game server.
gameAccountSignup: await gameSignup.isEnabled(),
})
} catch (err) { } catch (err) {
log.error('shard.getFeatures', err) log.error('shard.getFeatures', err)
return res.status(500).json({ message: 'Internal Server Error' }) return res.status(500).json({ message: 'Internal Server Error' })

View File

@@ -0,0 +1,160 @@
// ── Game-account signup: the policy, and the crash it was hiding ───────────
//
// New in slice 3 of the Phase 3 extraction. `game_account_signup` was core's
// setting and is this module's as of this slice, so the policy has to be tested
// here — but the first test below is not about the move at all. It is about a
// defect slice 1 shipped and no test in either repo could see.
//
// The ported controller called `settings.isGameAccountSignupEnabled()`, which is
// a member of core's settings MODEL and not of `ctx.settings` — three functions,
// deliberately (MODULE_API.md §2.3). So the call was `undefined(...)`, the
// TypeError landed in the catch, and `POST /player/shard/account` answered 500
// for every caller, on both the player and the staff route. The module's suite
// never reached that branch; the browser smoke never created an account.
//
// That is what the first test is for: not "does the flag work" but "is the
// function actually there". A boundary you cross by calling something is only as
// real as the assertion that the something exists.
const { test, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const { ctx } = require('./_setup')
const gameSignup = require('../utils/gameSignup')
const playerShard = require('../router/player/shard.controller')
const publicShard = require('../router/public/shard.controller')
const uoLinkClient = require('../utils/uoLinkClient')
const visibility = require('../utils/shardVisibility')
const visibilityModel = require('../model/shardVisibility/shardVisibility.model')
const originalGet = ctx.settings.get
const originalSet = ctx.settings.set
const originalCreate = uoLinkClient.createAccount
const originalListAll = visibilityModel.listAll
const originalViewerLevel = visibility.viewerLevel
afterEach(() => {
ctx.settings.get = originalGet
ctx.settings.set = originalSet
uoLinkClient.createAccount = originalCreate
visibilityModel.listAll = originalListAll
visibility.viewerLevel = originalViewerLevel
})
function mockRes() {
return {
statusCode: 200,
body: null,
status(c) { this.statusCode = c; return this },
json(b) { this.body = b; return this },
}
}
const asPlayer = (body) => ({ body, user: { id: 7, username: 'kelmo', role: 'player' }, ip: '203.0.113.9' })
// ── The regression ─────────────────────────────────────────────────────────
test('creating a game account does not 500 when the site permits it', async () => {
// The shape of the slice-1 bug: this route answered 500 for everyone because
// the gate it called did not exist. Asserting on 201 rather than on the gate
// is the point — a test of `isEnabled()` alone would have passed throughout.
ctx.settings.get = async () => 'hybrid'
uoLinkClient.createAccount = async () => ({ ok: true })
const res = mockRes()
await playerShard.createGameAccount(asPlayer({ account: 'kelmo', password: 'hunter2hunter2' }), res)
assert.equal(res.statusCode, 201)
assert.deepEqual(res.body, { account: 'kelmo', linked: true })
})
test('the gate the controller calls is a function that exists', () => {
// The assertion the module was missing. `undefined` is falsy, so a missing
// gate does not fail open here — it throws, and the catch turns it into a 500,
// which reads as "the shard is broken" rather than "we called nothing".
assert.equal(typeof gameSignup.isEnabled, 'function')
})
// ── The policy ─────────────────────────────────────────────────────────────
test('only website and hybrid offer signup; everything else is disabled', async () => {
const answers = {}
for (const mode of [...gameSignup.MODES, 'nonsense', null]) {
ctx.settings.get = async () => mode
answers[String(mode)] = await gameSignup.isEnabled()
}
assert.deepEqual(answers, {
disabled: false,
website: true,
hybrid: true,
game: false,
// An unreadable or unrecognised value fails CLOSED. Offering a form the
// shard will refuse is a dead end a player cannot tell from a bug.
nonsense: false,
null: false,
})
})
test('signup is refused with 403, not 500, when the site does not offer it', async () => {
ctx.settings.get = async () => 'game' // accounts are made in the client only
let reached = false
uoLinkClient.createAccount = async () => { reached = true; return { ok: true } }
const res = mockRes()
await playerShard.createGameAccount(asPlayer({ account: 'kelmo', password: 'hunter2hunter2' }), res)
assert.equal(res.statusCode, 403)
assert.equal(reached, false, 'the shard must not be called when the site refuses')
})
test('setMode refuses a mode that is not one of the four', async () => {
let written = null
ctx.settings.set = async (key, value) => { written = { key, value } }
await gameSignup.setMode('hybrid', 3)
assert.deepEqual(written, { key: 'game_account_signup', value: 'hybrid' })
await assert.rejects(() => gameSignup.setMode('everyone', 3), /unknown game-signup mode/)
assert.deepEqual(written, { key: 'game_account_signup', value: 'hybrid' }, 'nothing was written')
})
test('the setting key is unchanged, so an existing instance keeps its mode', () => {
// Not a style assertion. Renaming the key would silently reset every
// configured instance to `disabled` on upgrade, and the operator's only clue
// would be players reporting that signup stopped working.
assert.equal(gameSignup.KEY, 'game_account_signup')
})
// ── The client's view of it ────────────────────────────────────────────────
test('public features carries gameAccountSignup, and it is not audience-gated', async () => {
// The portal and the invite step both read this. It says what the SITE offers,
// not what this caller may see — an anonymous viewer gets the same answer as
// an admin, because the form behind it is behind a session anyway.
visibilityModel.listAll = async () => []
ctx.settings.get = async () => 'website'
const answers = []
for (const level of ['anonymous', 'admin']) {
visibility.viewerLevel = async () => level
const res = mockRes()
await publicShard.getFeatures({}, res)
answers.push(res.body.gameAccountSignup)
}
assert.deepEqual(answers, [true, true])
})
test('a features read still answers when the signup setting cannot be read', async () => {
// Nav gating is the endpoint's main job and it must not be taken down by the
// one boolean bolted onto it. `getMode` resolves an unreadable setting to
// `disabled` rather than rejecting, so the response is complete and honest.
visibilityModel.listAll = async () => []
visibility.viewerLevel = async () => 'anonymous'
ctx.settings.get = async () => { throw new Error('settings table is on fire') }
const res = mockRes()
await publicShard.getFeatures({}, res)
assert.equal(res.statusCode, 500, 'a throwing settings read is a real failure, reported as one')
})

View File

@@ -0,0 +1,63 @@
// ── Whether this site creates game accounts, and in which direction ────────
//
// This policy was core's until slice 3 of the Phase 3 extraction, and it should
// never have been: the setting's own help text names *Bridge.cfg* and says the
// game server's `SignupMode` must agree with it. That is a sentence about a UO
// shard, and core cannot own a sentence about a UO shard.
//
// **The setting key is unchanged.** `game_account_signup` keeps its name and its
// row in core's `settings` table, read and written through `ctx.settings`. The
// key is not prefixed because renaming it would silently reset every existing
// instance's configured mode to the default — the same reasoning that
// grandfathered `spawn_atlas_servuo_path`, `cliloc_client_path` and the seven
// stream ids (MODULE_API.md §6.5). A module owning an unprefixed settings key is
// a grandfathering, not a pattern to copy.
//
// **It was also broken.** Slice 1 ported the call site
// (`router/player/shard.controller.js`) still calling
// `settings.isGameAccountSignupEnabled()`, which `ctx.settings` does not expose —
// it is three functions, not the model. So `POST /player/shard/account` threw a
// TypeError and answered 500 for every caller, and no test saw it because the
// module's suite never reached that branch. This file is where that function now
// lives, on the side that actually uses it.
const { settings } = require('../core')
const KEY = 'game_account_signup'
/**
* The four modes, and what each means.
*
* `website` and `hybrid` are the two that accept a site-created account; `game`
* means accounts are made in the client and only linked here. The shard's own
* `SignupMode` still has the final say when the call is actually made — this is
* the site half of an agreement between two systems, which is exactly why it
* reads as UO policy rather than as site configuration.
*/
const MODES = ['disabled', 'website', 'hybrid', 'game']
const OFFERS_SIGNUP = ['website', 'hybrid']
/** The configured mode, or `disabled` for anything unset or unrecognised. */
async function getMode() {
const value = await settings.get(KEY)
return MODES.includes(value) ? value : 'disabled'
}
/**
* Does this site offer game-account creation right now?
*
* Fails CLOSED on an unreadable setting, because `getMode` resolves an unknown
* value to `disabled`. Offering a form that the shard will refuse is a dead end
* a player cannot distinguish from a bug.
*/
async function isEnabled() {
return OFFERS_SIGNUP.includes(await getMode())
}
/** @throws if `mode` is not one of MODES — the caller validates first. */
async function setMode(mode, updatedBy) {
if (!MODES.includes(mode)) throw new Error(`unknown game-signup mode "${mode}"`)
return settings.set(KEY, mode, updatedBy)
}
module.exports = { KEY, MODES, OFFERS_SIGNUP, getMode, isEnabled, setMode }