feat(client): the whole client half (phase 3, slice 3) #4
@@ -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
|
||||||
|
|||||||
@@ -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
231
client/src/api.js
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
// ── This module's own API bindings ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Core hands out the request PRIMITIVE and nothing above it (MODULE_API.md
|
||||||
|
// §3.5): same-origin `/api/v1`, cookies included, JSON in and out, `ApiError` on
|
||||||
|
// a non-2xx. The paths are ours, because the routes at the other end are ours —
|
||||||
|
// `server/router/**` in this repo serves every one of them.
|
||||||
|
//
|
||||||
|
// This file is the client half of the pair that moved in slice 1, and the two
|
||||||
|
// halves are checked against each other by nothing but review, so the ordering
|
||||||
|
// below mirrors the router tree deliberately: public, then admin, then player.
|
||||||
|
//
|
||||||
|
// **The URLs are unchanged from the ones core used to call.** MODULE_SYSTEM.md
|
||||||
|
// §1.2 freezes the API surface across the extraction — the shipped Android app
|
||||||
|
// calls `/api/v1/admin/shard/kick` and six of its neighbours — so what moved is
|
||||||
|
// which repo declares them, never what they are. Only the SPA route paths
|
||||||
|
// changed (`/uo/*`, `/admin/uo/*`, `/player/uo/*`), and those are not API URLs.
|
||||||
|
|
||||||
|
import rg from './core.js'
|
||||||
|
|
||||||
|
const { request: req, BASE } = rg.api
|
||||||
|
|
||||||
|
/** Prefix a non-empty query string with "?" — core's `withQs`, which is not in the kit. */
|
||||||
|
const withQs = (s) => (s ? `?${s}` : '')
|
||||||
|
|
||||||
|
// ── public: live shard data (uo-link) ──────────────────────────────────────
|
||||||
|
// Token-free, same-origin reads backed by the ingested feed plus a cached live
|
||||||
|
// character round-trip.
|
||||||
|
export const shard = {
|
||||||
|
status: () => req('/public/shard/status'),
|
||||||
|
feed: (opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.kind) qs.set('kind', opts.kind)
|
||||||
|
if (opts.limit) qs.set('limit', opts.limit)
|
||||||
|
return req(`/public/shard/feed${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
economy: (limit) => req(`/public/shard/economy${withQs(limit ? `limit=${limit}` : '')}`),
|
||||||
|
online: () => req('/public/shard/online'),
|
||||||
|
idoc: () => req('/public/shard/idoc'),
|
||||||
|
champs: () => req('/public/shard/champs'),
|
||||||
|
// Protocol 2.0 boards.
|
||||||
|
guilds: () => req('/public/shard/guilds'),
|
||||||
|
governors: () => req('/public/shard/governors'),
|
||||||
|
governorHistory: (city, limit) =>
|
||||||
|
req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(limit ? `limit=${limit}` : '')}`),
|
||||||
|
presence: () => req('/public/shard/presence'),
|
||||||
|
houses: () => req('/public/shard/houses'),
|
||||||
|
// Protocol 3.0: the shard's published ruleset. Resolves to null when the shard
|
||||||
|
// has never published one — a real answer, not an error.
|
||||||
|
ruleset: () => req('/public/shard/ruleset'),
|
||||||
|
// Protocol 3.0: points/loyalty leaderboards, one board per point system.
|
||||||
|
// `pointsBoard` 404s for a system the shard has never published.
|
||||||
|
points: () => req('/public/shard/points'),
|
||||||
|
pointsBoard: (system) => req(`/public/shard/points/${encodeURIComponent(system)}`),
|
||||||
|
// Protocol 3.0: the player-vendor marketplace. Rate-limited server-side, so
|
||||||
|
// the page debounces its search box rather than firing per keystroke.
|
||||||
|
market: (opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.q) qs.set('q', opts.q)
|
||||||
|
if (opts.minPrice != null && opts.minPrice !== '') qs.set('minPrice', opts.minPrice)
|
||||||
|
if (opts.maxPrice != null && opts.maxPrice !== '') qs.set('maxPrice', opts.maxPrice)
|
||||||
|
if (opts.itemId != null && opts.itemId !== '') qs.set('itemId', opts.itemId)
|
||||||
|
if (opts.map) qs.set('map', opts.map)
|
||||||
|
if (opts.region) qs.set('region', opts.region)
|
||||||
|
if (opts.sort) qs.set('sort', opts.sort)
|
||||||
|
if (opts.limit) qs.set('limit', opts.limit)
|
||||||
|
if (opts.offset) qs.set('offset', opts.offset)
|
||||||
|
return req(`/public/shard/market${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
marketMeta: () => req('/public/shard/market/meta'),
|
||||||
|
marketVendor: (serial, opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.limit) qs.set('limit', opts.limit)
|
||||||
|
if (opts.offset) qs.set('offset', opts.offset)
|
||||||
|
return req(`/public/shard/market/vendors/${encodeURIComponent(serial)}${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
// Which shard surfaces this caller may reach, plus the audience rung they
|
||||||
|
// resolved to. Drives nav so we never render a link that would 403 — and, as
|
||||||
|
// of slice 3, also carries `gameAccountSignup`: whether this site offers
|
||||||
|
// game-account creation at all (see server/router/public/shard.controller.js).
|
||||||
|
features: () => req('/public/shard/features'),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── public: the spawn atlas (Protocol 3.0 Part C) ──────────────────────────
|
||||||
|
// Static shard CONTENT, parsed from the shard's own ServUO tree — deliberately
|
||||||
|
// not under /shard, because nothing here depends on the sidecar and the pages
|
||||||
|
// stay populated while the shard is offline.
|
||||||
|
export const atlas = {
|
||||||
|
creatures: (opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.q) qs.set('q', opts.q)
|
||||||
|
if (opts.facet) qs.set('facet', opts.facet)
|
||||||
|
if (opts.limit) qs.set('limit', opts.limit)
|
||||||
|
if (opts.offset) qs.set('offset', opts.offset)
|
||||||
|
return req(`/public/atlas/creatures${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
creature: (slug, opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.facet) qs.set('facet', opts.facet)
|
||||||
|
if (opts.points) qs.set('points', opts.points)
|
||||||
|
return req(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
regions: (opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.facet) qs.set('facet', opts.facet)
|
||||||
|
if (opts.q) qs.set('q', opts.q)
|
||||||
|
return req(`/public/atlas/regions${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
landmarks: (opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.facet) qs.set('facet', opts.facet)
|
||||||
|
if (opts.q) qs.set('q', opts.q)
|
||||||
|
return req(`/public/atlas/landmarks${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
// The CONFIGURED altar roster, not the live board — see `shard.champs()` for
|
||||||
|
// "which spawn is on level 3 right now".
|
||||||
|
champions: (facet) =>
|
||||||
|
req(`/public/atlas/champions${withQs(facet ? `facet=${encodeURIComponent(facet)}` : '')}`),
|
||||||
|
meta: () => req('/public/atlas/meta'),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── admin ──────────────────────────────────────────────────────────────────
|
||||||
|
export const admin = {
|
||||||
|
// The account/character/house reads a staff member makes across the whole shard.
|
||||||
|
shard: {
|
||||||
|
link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }),
|
||||||
|
accounts: () => req('/admin/shard/accounts'),
|
||||||
|
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
||||||
|
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
||||||
|
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
||||||
|
sales: () => req('/admin/shard/sales'),
|
||||||
|
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
|
||||||
|
createAccount: (account, password) =>
|
||||||
|
req('/admin/shard/account', { method: 'POST', body: { account, password } }),
|
||||||
|
},
|
||||||
|
|
||||||
|
// The sidecar's own configuration and the town crier it drives.
|
||||||
|
getUoLinkConfig: () => req('/admin/uo-link/config'),
|
||||||
|
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
|
||||||
|
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
|
||||||
|
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Whether this site offers game-account creation, and in which direction.
|
||||||
|
// Core's Site Settings used to carry this; it is ours as of slice 3, because
|
||||||
|
// "the game server's own SignupMode must agree" is not a sentence core can own.
|
||||||
|
getSignupMode: () => req('/admin/uo-link/signup-mode'),
|
||||||
|
saveSignupMode: (mode) => req('/admin/uo-link/signup-mode', { method: 'PUT', body: { mode } }),
|
||||||
|
|
||||||
|
// Per-feature shard visibility: who may see which shard surface, and which
|
||||||
|
// sensitive fields within it. Admin only — it decides what ANONYMOUS visitors
|
||||||
|
// get. acct/webId are admin-only always and the API rejects any attempt to
|
||||||
|
// configure them.
|
||||||
|
getShardVisibility: () => req('/admin/shard/visibility'),
|
||||||
|
saveShardVisibility: (features) => req('/admin/shard/visibility', { method: 'PUT', body: { features } }),
|
||||||
|
|
||||||
|
// The atlas re-derives itself from the ServUO tree on every boot; these are for
|
||||||
|
// applying a map change without a restart, and for the approve/reject decision
|
||||||
|
// on a refresh that would remove a facet.
|
||||||
|
atlas: {
|
||||||
|
status: () => req('/admin/shard/atlas'),
|
||||||
|
import: (force = false) => req('/admin/shard/atlas/import', { method: 'POST', body: { force } }),
|
||||||
|
approve: () => req('/admin/shard/atlas/approve', { method: 'POST', body: {} }),
|
||||||
|
reject: () => req('/admin/shard/atlas/reject', { method: 'POST', body: {} }),
|
||||||
|
setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }),
|
||||||
|
},
|
||||||
|
|
||||||
|
// In-game staff operations: write plane + support queue (admin/moderator).
|
||||||
|
// `actor` is stamped server-side from the session — never sent from here.
|
||||||
|
shardOps: {
|
||||||
|
kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
|
||||||
|
ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
|
||||||
|
unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
|
||||||
|
broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
|
||||||
|
pages: () => req('/admin/shard/pages'),
|
||||||
|
respondPage: (id, data) => req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
|
||||||
|
closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
|
||||||
|
audit: (limit) => req(`/admin/shard/audit${withQs(limit ? `limit=${limit}` : '')}`),
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One user's shard presence, for the `admin.users.detail` extension slot.
|
||||||
|
*
|
||||||
|
* A factory rather than a flat namespace because every call is scoped to the
|
||||||
|
* user whose page this is. The three that are NOT — roster, vendors, char —
|
||||||
|
* are keyed by an account or a serial the scoped calls just returned, and they
|
||||||
|
* are the same routes `admin.shard` uses; they are repeated here so the slot's
|
||||||
|
* components take one `scope` object and never reach for a second one.
|
||||||
|
*/
|
||||||
|
userShard: (id) => ({
|
||||||
|
accounts: () => req(`/admin/users/${id}/shard/accounts`),
|
||||||
|
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
||||||
|
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
||||||
|
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
||||||
|
sales: () => req(`/admin/users/${id}/shard/sales`),
|
||||||
|
houses: () => req(`/admin/users/${id}/shard/houses`),
|
||||||
|
online: () => req(`/admin/users/${id}/shard/online`),
|
||||||
|
standing: () => req(`/admin/users/${id}/shard/standing`),
|
||||||
|
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── player self-service ────────────────────────────────────────────────────
|
||||||
|
// Mirrors `admin.shard`, self-scoped: the server derives the caller from the
|
||||||
|
// session and never takes an account id from the client.
|
||||||
|
export const player = {
|
||||||
|
shard: {
|
||||||
|
link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }),
|
||||||
|
accounts: () => req('/player/shard/accounts'),
|
||||||
|
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
|
||||||
|
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
|
||||||
|
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
|
||||||
|
sales: () => req('/player/shard/sales'),
|
||||||
|
houses: () => req('/player/shard/houses'), // the caller's own houses
|
||||||
|
createAccount: (account, password) =>
|
||||||
|
req('/player/shard/account', { method: 'POST', body: { account, password } }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSE endpoints ──────────────────────────────────────────────────────────
|
||||||
|
// Full paths including `/api/v1`, because `request` is fetch-only and an
|
||||||
|
// EventSource builds its own URL. `BASE` is core's — it owns where the API is
|
||||||
|
// mounted, and a module hardcoding `/api/v1` would be asserting something about
|
||||||
|
// core that core has not promised (MODULE_API.md §3.5).
|
||||||
|
//
|
||||||
|
// The admin stream carries every kind, including audit and cheat detection, and
|
||||||
|
// needs the staff session cookie.
|
||||||
|
export const shardStreamUrl = `${BASE}/public/shard/stream`
|
||||||
|
export const adminShardStreamUrl = `${BASE}/admin/uo-link/stream`
|
||||||
|
|
||||||
|
export const api = { shard, atlas, admin, player, shardStreamUrl, adminShardStreamUrl }
|
||||||
|
|
||||||
|
export default api
|
||||||
293
client/src/components/CharacterSheet.jsx
Normal file
293
client/src/components/CharacterSheet.jsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
// Reusable character-sheet renderer for the char.profile shape returned by
|
||||||
|
// /public/shard/char/:serial. Presentational only — the parent handles loading
|
||||||
|
// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles).
|
||||||
|
//
|
||||||
|
// `moderation` opts in the in-game kick/ban controls for the character's account;
|
||||||
|
// they self-gate to staff (ShardAccountActions), so passing it from a page a
|
||||||
|
// player can reach is safe.
|
||||||
|
|
||||||
|
import ShardAccountActions from './ShardAccountActions.jsx'
|
||||||
|
|
||||||
|
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
|
||||||
|
|
||||||
|
// What to call an equipped item.
|
||||||
|
//
|
||||||
|
// Items on the wire carry a `LabelNumber`, not a name, so this used to be able
|
||||||
|
// to show nothing but the layer and `id 12345`. The server now resolves the
|
||||||
|
// cliloc against its own table and attaches `clilocName` (see
|
||||||
|
// docs/website/CLILOCS.md); a shard with no cliloc file configured sends none,
|
||||||
|
// and the layer fallback below is exactly what the sheet did before.
|
||||||
|
//
|
||||||
|
// A player-given `name` outranks the resolved type name — "Bob's lucky axe"
|
||||||
|
// should not be relabelled "hatchet" — and the server applies the same
|
||||||
|
// precedence, so this only re-states it for a profile that arrived with both.
|
||||||
|
const itemName = (it) => it.name || it.clilocName || it.layer || 'Item'
|
||||||
|
|
||||||
|
// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already
|
||||||
|
// computed display strings; reward entries may be a cliloc NUMBER-as-string or a
|
||||||
|
// literal string.
|
||||||
|
//
|
||||||
|
// `rewardResolved` is the server's parallel array with the numeric entries turned
|
||||||
|
// into words (null where the cliloc table had nothing, or is not configured at
|
||||||
|
// all). Prefer it, and keep the literal-only path as the fallback for a profile
|
||||||
|
// served before the cliloc table existed — a numeric entry with no resolution is
|
||||||
|
// still skipped rather than shown as a raw number.
|
||||||
|
function displayTitles(titles) {
|
||||||
|
if (!titles) return []
|
||||||
|
const out = []
|
||||||
|
if (titles.fameKarma) out.push(titles.fameKarma)
|
||||||
|
if (titles.skill) out.push(titles.skill)
|
||||||
|
const raw = Array.isArray(titles.reward) ? titles.reward : []
|
||||||
|
const resolved = Array.isArray(titles.rewardResolved) ? titles.rewardResolved : null
|
||||||
|
const reward = raw.map((r, i) => resolved?.[i] ?? (/^\d+$/.test(String(r)) ? null : String(r)))
|
||||||
|
const sel = typeof titles.selected === 'number' ? titles.selected : -1
|
||||||
|
// Prefer the selected reward title; fall back to the first one that resolved.
|
||||||
|
// The `??` matters: a selected title whose cliloc did not resolve must fall
|
||||||
|
// through to the fallback rather than suppress the chip entirely.
|
||||||
|
const candidate = (sel >= 0 && sel < reward.length ? reward[sel] : null) ?? reward.find(Boolean)
|
||||||
|
if (candidate) out.push(String(candidate))
|
||||||
|
return [...new Set(out.filter(Boolean))]
|
||||||
|
}
|
||||||
|
|
||||||
|
// The char.profile `points` block (Protocol 3.0 §7.3): one entry per point system
|
||||||
|
// the character actually holds a score in. Systems at zero are omitted by the
|
||||||
|
// shard, so an empty list means "this character has earned nothing anywhere",
|
||||||
|
// which is a normal state for a new character and renders as nothing at all.
|
||||||
|
//
|
||||||
|
// `nameString` may be null when the system's name is a cliloc; fall back to
|
||||||
|
// humanising the PointsType key, exactly as the leaderboards page does. `rank` is
|
||||||
|
// absent unless the shard runs with Bridge.cfg PointsProfileRank=true — absent and
|
||||||
|
// "unranked" are different, so the chip only appears when it was actually sent.
|
||||||
|
const humanisePoints = (key) =>
|
||||||
|
String(key || '')
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/^./, (c) => c.toUpperCase())
|
||||||
|
|
||||||
|
function PointsRow({ entry }) {
|
||||||
|
const label = entry.nameString || humanisePoints(entry.system)
|
||||||
|
const max = Number.isFinite(entry.maxPoints) && entry.maxPoints > 0 ? entry.maxPoints : 0
|
||||||
|
const pct = max ? Math.min(100, Math.round((entry.points / max) * 100)) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3, gap: 10 }}>
|
||||||
|
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>
|
||||||
|
{label}
|
||||||
|
{Number.isFinite(entry.rank) && (
|
||||||
|
<span className="dim" style={{ fontSize: '0.74rem' }}> · #{entry.rank}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
|
||||||
|
{(entry.points ?? 0).toLocaleString()}
|
||||||
|
{max > 0 && <span className="dim"> / {max.toLocaleString()}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Only systems with a real cap get a bar; an uncapped score has nothing to
|
||||||
|
be a fraction of, and a full-width bar would imply completion. */}
|
||||||
|
{max > 0 && (
|
||||||
|
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TitleChip({ children, tone = 'var(--muted)' }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.72rem', padding: '3px 9px', borderRadius: 999,
|
||||||
|
border: `1px solid ${tone}55`, color: tone, whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatTile({ value, label }) {
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
|
||||||
|
<div className="display" style={{ fontSize: '1.35rem', color: 'var(--head)' }}>{value}</div>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 4 }}>{label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Vital({ label, cur, max }) {
|
||||||
|
const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: '12px 14px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
||||||
|
<span className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>{label}</span>
|
||||||
|
<span className="display" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>{cur ?? '—'}<span className="dim" style={{ fontSize: '0.8rem' }}> / {max ?? '—'}</span></span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 6, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CharacterSheet({ char, moderation = false }) {
|
||||||
|
if (!char) return null
|
||||||
|
const stats = char.stats || {}
|
||||||
|
const resist = stats.resist || {}
|
||||||
|
// Skills the character actually has, best first.
|
||||||
|
const skills = (char.skills || [])
|
||||||
|
.filter((s) => (s.value || s.base || 0) > 0)
|
||||||
|
.sort((a, b) => (b.value || 0) - (a.value || 0))
|
||||||
|
const equipment = char.equipment || []
|
||||||
|
// Best standing first, so the character's strongest loyalty leads. Guarded for
|
||||||
|
// an older shard plugin that sends no `points` block at all.
|
||||||
|
const points = (Array.isArray(char.points) ? char.points : [])
|
||||||
|
.filter((p) => p && (p.points || 0) > 0)
|
||||||
|
.sort((a, b) => (b.points || 0) - (a.points || 0))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||||
|
{/* Identity */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>{char.name || 'Unknown'}</h2>
|
||||||
|
{char.title && <span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>{char.title}</span>}
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 999,
|
||||||
|
border: '1px solid var(--line)', fontSize: '0.74rem',
|
||||||
|
color: char.online ? '#7fd0a4' : 'var(--muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: char.online ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{char.online ? 'Online' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Titles + standing (guild led / governorship) — all optional */}
|
||||||
|
{(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: -8 }}>
|
||||||
|
{char.governorOf && char.governorOf.map((city) => (
|
||||||
|
<TitleChip key={`gov-${city}`} tone="#c9a24b">Governor of {city}</TitleChip>
|
||||||
|
))}
|
||||||
|
{char.guild && (
|
||||||
|
<TitleChip tone="var(--accent)">
|
||||||
|
Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name}
|
||||||
|
</TitleChip>
|
||||||
|
)}
|
||||||
|
{displayTitles(char.titles).map((t) => <TitleChip key={t}>{t}</TitleChip>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Staff moderation for this character's account (self-gates to staff). */}
|
||||||
|
{moderation && char.acct && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.76rem' }}>Account <strong style={{ color: 'var(--ink)' }}>{char.acct}</strong></span>
|
||||||
|
<ShardAccountActions account={char.acct} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Core stats */}
|
||||||
|
<section>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>
|
||||||
|
<div className="grid-3" style={{ gap: 12 }}>
|
||||||
|
<StatTile value={stats.str ?? '—'} label="Strength" />
|
||||||
|
<StatTile value={stats.dex ?? '—'} label="Dexterity" />
|
||||||
|
<StatTile value={stats.int ?? '—'} label="Intelligence" />
|
||||||
|
</div>
|
||||||
|
<div className="grid-3" style={{ gap: 12, marginTop: 12 }}>
|
||||||
|
<Vital label="Hits" cur={stats.hits} max={stats.hitsMax} />
|
||||||
|
<Vital label="Mana" cur={stats.mana} max={stats.manaMax} />
|
||||||
|
<Vital label="Stamina" cur={stats.stam} max={stats.stamMax} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Resistances */}
|
||||||
|
{Object.keys(resist).length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Resistances</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
{['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => (
|
||||||
|
<div key={k} className="panel" style={{ padding: '10px 16px', textAlign: 'center', minWidth: 84 }}>
|
||||||
|
<div className="display" style={{ color: 'var(--head)', fontSize: '1.1rem' }}>{resist[k] ?? 0}</div>
|
||||||
|
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.66rem', textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: 2 }}>{RESIST_LABELS[k]}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skills */}
|
||||||
|
{skills.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Skills <span className="dim">({skills.length})</span></div>
|
||||||
|
<div className="grid-2" style={{ gap: '8px 18px' }}>
|
||||||
|
{skills.map((s) => {
|
||||||
|
const cap = s.cap || 100
|
||||||
|
const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100))
|
||||||
|
return (
|
||||||
|
<div key={s.n}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 }}>
|
||||||
|
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>{s.n}</span>
|
||||||
|
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem' }}>{s.value}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loyalty & points — one entry per system this character has scored in */}
|
||||||
|
{points.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>
|
||||||
|
Loyalty & points <span className="dim">({points.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid-2" style={{ gap: '8px 18px' }}>
|
||||||
|
{points.map((p) => (
|
||||||
|
<PointsRow key={p.system} entry={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Equipment */}
|
||||||
|
{equipment.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{equipment.map((it) => {
|
||||||
|
const label = itemName(it)
|
||||||
|
const layer = it.layer || 'Item'
|
||||||
|
// The layer only earns its own line once the headline is a real
|
||||||
|
// name; when it IS the headline, repeating it is just noise.
|
||||||
|
const detail = [label === layer ? null : layer, `id ${it.itemId}`, it.hue ? `hue ${it.hue}` : null]
|
||||||
|
return (
|
||||||
|
<div key={it.serial} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||||
|
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: 5, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.05)' }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{label}</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.74rem' }}>{detail.filter(Boolean).join(' · ')}</div>
|
||||||
|
</div>
|
||||||
|
{it.mods && Object.keys(it.mods).length > 0 && (
|
||||||
|
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
|
||||||
|
{Object.entries(it.mods).map(([k, v]) => (
|
||||||
|
<span key={k} className="pill" style={{ fontSize: '0.7rem', padding: '2px 8px' }}>{k} {v}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
client/src/components/CharacterStats.jsx
Normal file
79
client/src/components/CharacterStats.jsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
// A small stat-tile row for a "My Characters" page: total characters, how many
|
||||||
|
// are online right now, and how many game accounts are linked. `scope` is the
|
||||||
|
// shard api object (admin or player self-service). Renders nothing until an
|
||||||
|
// account is linked, so the empty/link-prompt state below it stands alone.
|
||||||
|
//
|
||||||
|
// It fetches the same rosters GameAccounts loads; for a personal page that's at
|
||||||
|
// most a couple of extra live round-trips, and keeps this presentational bit
|
||||||
|
// decoupled from GameAccounts' per-account roster loading.
|
||||||
|
|
||||||
|
function Tile({ value, label }) {
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
|
||||||
|
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: 8 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fold the settled roster results into totals. `complete` is false when any
|
||||||
|
// account's roster failed (a partial result — shown as a dash rather than a
|
||||||
|
// misleadingly low count).
|
||||||
|
function summarizeRosters(rosters) {
|
||||||
|
let chars = 0
|
||||||
|
let online = 0
|
||||||
|
let complete = true
|
||||||
|
for (const r of rosters) {
|
||||||
|
if (r.status !== 'fulfilled') {
|
||||||
|
complete = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const cs = r.value.chars || []
|
||||||
|
chars += cs.length
|
||||||
|
online += cs.filter((c) => c.online).length
|
||||||
|
}
|
||||||
|
return { chars, online, complete }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CharacterStats({ scope }) {
|
||||||
|
const [stats, setStats] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const accounts = await scope.accounts()
|
||||||
|
const linked = accounts.length
|
||||||
|
if (linked === 0) {
|
||||||
|
if (!cancelled) setStats({ linked: 0 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Roster is a live round-trip and can be unavailable (503); tolerate a
|
||||||
|
// partial result so a restarting shard doesn't blank the whole row.
|
||||||
|
const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account)))
|
||||||
|
if (!cancelled) setStats({ linked, ...summarizeRosters(rosters) })
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setStats({ error: true })
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [scope])
|
||||||
|
|
||||||
|
// Hidden until we know an account is linked (or while first loading).
|
||||||
|
if (!stats || stats.error || stats.linked === 0) return null
|
||||||
|
|
||||||
|
// Counts depend on live rosters; show a dash if none came back.
|
||||||
|
const count = (n) => (stats.complete || stats.chars > 0 ? n : '—')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="grid-3" style={{ gap: 14, marginBottom: 26 }}>
|
||||||
|
<Tile value={count(stats.chars)} label="Characters" />
|
||||||
|
<Tile value={count(stats.online)} label="Online now" />
|
||||||
|
<Tile value={stats.linked} label={stats.linked === 1 ? 'Linked account' : 'Linked accounts'} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
client/src/components/CreateGameAccountForm.jsx
Normal file
69
client/src/components/CreateGameAccountForm.jsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
// Reusable "create a game account" form (its own username + password — the game
|
||||||
|
// client credentials, distinct from the website login). Calls `submit(account,
|
||||||
|
// password)` which should POST /player/shard/account; on success calls onCreated.
|
||||||
|
// Used by the player portal (self-serve) and the invite-accept page alike.
|
||||||
|
export default function CreateGameAccountForm({ submit, onCreated, compact = false }) {
|
||||||
|
const [account, setAccount] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function onSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setMsg(''); setError('')
|
||||||
|
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/.test(account)) {
|
||||||
|
return setError('Account name must be 3–30 letters, numbers, . _ or -.')
|
||||||
|
}
|
||||||
|
if (password.length < 8) return setError('Password must be at least 8 characters.')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await submit(account, password)
|
||||||
|
setMsg(`Game account “${account}” created and linked.`)
|
||||||
|
setAccount(''); setPassword('')
|
||||||
|
if (onCreated) await onCreated()
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 409) setError('That account name is already taken.')
|
||||||
|
else if (err.status === 429) setError('The account limit for your network has been reached.')
|
||||||
|
else if (err.status === 403) setError('Game-account signup is not available right now.')
|
||||||
|
else if (err.status === 503) setError('The game server is unavailable — try again shortly.')
|
||||||
|
else setError(err.message || 'Could not create the account right now.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
{!compact && (
|
||||||
|
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
Choose the username and password you’ll type into the game client. These are your
|
||||||
|
<strong style={{ color: 'var(--head)' }}> game</strong> credentials — separate from your website login.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||||
|
<span className="field-label">Game account name</span>
|
||||||
|
<input
|
||||||
|
type="text" autoComplete="off" value={account}
|
||||||
|
onChange={(e) => setAccount(e.target.value)} className="input" placeholder="e.g. darrow"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||||
|
<span className="field-label">Game password</span>
|
||||||
|
<input
|
||||||
|
type="password" autoComplete="new-password" value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)} className="input"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
||||||
|
{msg && <p className="sans" style={{ margin: '0 0 12px', color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</p>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Creating…' : 'Create game account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
228
client/src/components/GameAccounts.jsx
Normal file
228
client/src/components/GameAccounts.jsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import ShardAccountActions from './ShardAccountActions.jsx'
|
||||||
|
import CreateGameAccountForm from './CreateGameAccountForm.jsx'
|
||||||
|
import api from '../api.js'
|
||||||
|
import { useGameAccountSignup } from '../lib/useShardFeatures.js'
|
||||||
|
import { ErrorState, Loading } from '../core.js'
|
||||||
|
|
||||||
|
// Shared game-account linking + character roster, used by both the player portal
|
||||||
|
// (/player) and the staff account page (/admin/account). `scope` is the api
|
||||||
|
// object with { link, accounts, roster } (player or admin self-service); `charTo`
|
||||||
|
// maps a serial to the route for that character's sheet. `readOnly` drops the
|
||||||
|
// link forms and self-voice copy for the admin case where staff view *another*
|
||||||
|
// user's accounts (no `scope.link`) at /admin/users/:id.
|
||||||
|
|
||||||
|
function LinkForm({ scope, onLinked, compact }) {
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function submit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setMsg(''); setError('')
|
||||||
|
if (!code.trim()) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const { account } = await scope.link(code.trim())
|
||||||
|
setMsg(`Linked ${account}.`)
|
||||||
|
setCode('')
|
||||||
|
await onLinked()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not link that code.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: compact ? 0 : 6 }}>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
{!compact && <span className="field-label">Link code</span>}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||||
|
className="input"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="AB12CD"
|
||||||
|
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Linking…' : 'Link account'}
|
||||||
|
</button>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountRoster({ scope, account, charTo }) {
|
||||||
|
const [roster, setRoster] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [unavailable, setUnavailable] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(''); setUnavailable(false)
|
||||||
|
try {
|
||||||
|
setRoster(await scope.roster(account))
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 503) setUnavailable(true)
|
||||||
|
else setError(err.message || 'Could not load this account.')
|
||||||
|
}
|
||||||
|
}, [scope, account])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
if (unavailable) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="sans" style={{ margin: '0 0 8px', color: '#e0b070', fontSize: '0.85rem' }}>The game server is restarting — try again shortly.</p>
|
||||||
|
<button className="pill" onClick={load}>Retry</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (error) return <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
||||||
|
if (!roster) return <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>Loading…</p>
|
||||||
|
|
||||||
|
const chars = roster.chars || []
|
||||||
|
if (chars.length === 0) return <p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>No characters on this account.</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid-2" style={{ gap: 12 }}>
|
||||||
|
{chars.map((c) => (
|
||||||
|
<Link
|
||||||
|
key={c.serial}
|
||||||
|
to={charTo(c.serial)}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', border: '1px solid var(--line)', borderRadius: 10, textDecoration: 'none', background: 'rgba(255,255,255,0.02)' }}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '1rem', textTransform: 'uppercase' }}>
|
||||||
|
{(c.name || '?').charAt(0)}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="display" style={{ color: 'var(--head)', fontSize: '1.02rem' }}>{c.name}</div>
|
||||||
|
<div className="sans" style={{ fontSize: '0.76rem', color: c.online ? '#7fd0a4' : 'var(--muted)' }}>{c.online ? 'Online' : 'Offline'}</div>
|
||||||
|
</div>
|
||||||
|
<span className="sans dim" style={{ fontSize: '1.1rem' }}>›</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact per-account "Unlink" button for the admin (readOnly) view. Confirms,
|
||||||
|
// then calls onUnlink(account) and reloads. Errors surface inline.
|
||||||
|
function UnlinkButton({ account, onUnlink }) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
async function go() {
|
||||||
|
if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return
|
||||||
|
setBusy(true); setError('')
|
||||||
|
try {
|
||||||
|
await onUnlink(account)
|
||||||
|
} catch (err) {
|
||||||
|
const byStatus = { 403: 'Protected account — refused.', 404: 'Not linked.' }
|
||||||
|
setError(byStatus[err.status] || err.message || 'Could not unlink.')
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<button type="button" onClick={go} disabled={busy} className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }}>
|
||||||
|
{busy ? 'Unlinking…' : 'Unlink'}
|
||||||
|
</button>
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.76rem' }}>{error}</span>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) {
|
||||||
|
const [accounts, setAccounts] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
// Whether the site currently offers game-account creation. Only relevant for
|
||||||
|
// the self-service (non-readOnly) view with a createAccount scope.
|
||||||
|
//
|
||||||
|
// From OUR public features endpoint as of slice 3, not core's public settings:
|
||||||
|
// the flag derives from the `uo.game_account_signup` setting, which this module
|
||||||
|
// owns, because "the game server's own SignupMode must agree" is not a sentence
|
||||||
|
// core can own. Same cached call the nav gates use, so this costs no round-trip.
|
||||||
|
const signupOk = useGameAccountSignup()
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setAccounts(await scope.accounts())
|
||||||
|
} catch {
|
||||||
|
setError(readOnly ? 'Could not load this user’s game accounts.' : 'Could not load your game accounts.')
|
||||||
|
}
|
||||||
|
}, [scope, readOnly])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const canCreate = !readOnly && Boolean(scope.createAccount) && signupOk === true
|
||||||
|
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
if (!accounts) return <Loading />
|
||||||
|
|
||||||
|
// No linked accounts. In read-only (admin viewing another user) this is just an
|
||||||
|
// empty state; otherwise it's the link-your-account prompt.
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
if (readOnly) {
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: 22 }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||||
|
This user has not linked a game account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<div className="panel" style={{ padding: 22 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
|
||||||
|
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
Already play? In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
|
||||||
|
one-time code, then enter it below to see your characters, stats, skills and vendors here.
|
||||||
|
</p>
|
||||||
|
<LinkForm scope={scope} onLinked={load} />
|
||||||
|
</div>
|
||||||
|
{canCreate && (
|
||||||
|
<div className="panel" style={{ padding: 22 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Create a new game account</div>
|
||||||
|
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linked — characters grouped by account.
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<section key={a.account}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
||||||
|
{a.account}
|
||||||
|
</div>
|
||||||
|
{onUnlink && <UnlinkButton account={a.account} onUnlink={async (acct) => { await onUnlink(acct); await load() }} />}
|
||||||
|
</div>
|
||||||
|
{moderation && <ShardAccountActions account={a.account} style={{ marginBottom: 12 }} />}
|
||||||
|
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
{!readOnly && (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
|
||||||
|
<LinkForm scope={scope} onLinked={load} compact />
|
||||||
|
{canCreate && (
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 10 }}>Create another game account</div>
|
||||||
|
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} compact />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
client/src/components/InviteGameAccountStep.jsx
Normal file
51
client/src/components/InviteGameAccountStep.jsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import api from '../api.js'
|
||||||
|
import { useGameAccountSignup } from '../lib/useShardFeatures.js'
|
||||||
|
import CreateGameAccountForm from './CreateGameAccountForm.jsx'
|
||||||
|
|
||||||
|
// ── This module's fill for the `player.invite.accepted` slot ───────────────
|
||||||
|
//
|
||||||
|
// Core's invite-acceptance page (`routes/player/AcceptInvite.jsx`) used to render
|
||||||
|
// this step itself: it read `gameAccountSignup` out of core's public settings and
|
||||||
|
// posted to `api.player.shard.createAccount`. Both of those are ours, and the
|
||||||
|
// page they sat on is not — an invite is a core concept and staff get invited
|
||||||
|
// too. So slice 3 declared a third extension slot rather than moving the page or
|
||||||
|
// leaving core importing a module component. MODULE_API.md §3.7.
|
||||||
|
//
|
||||||
|
// **The whole decision about whether there is a step at all is on this side.**
|
||||||
|
// Core renders the shell and a "skip" control whenever the slot is filled, and
|
||||||
|
// hands us `onDone`. If this shard does not offer website-created game accounts
|
||||||
|
// there is nothing to do here, so we call `onDone` and the invitee goes straight
|
||||||
|
// to the portal — which is exactly what core's own code did when the flag was
|
||||||
|
// off, only now the flag is not core's to read.
|
||||||
|
//
|
||||||
|
// The spinner while the answer is in flight is the honest cost of that split: the
|
||||||
|
// invitee sees core's chrome for one cached request before this either renders or
|
||||||
|
// stands aside. Rendering the form optimistically and retracting it would be
|
||||||
|
// worse, and asking core to wait on a module before painting would put a module's
|
||||||
|
// latency in front of a core page.
|
||||||
|
export default function InviteGameAccountStep({ onDone }) {
|
||||||
|
const signupOk = useGameAccountSignup()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (signupOk === false) onDone()
|
||||||
|
}, [signupOk, onDone])
|
||||||
|
|
||||||
|
// `null` is "not yet", not "no" — see useGameAccountSignup.
|
||||||
|
if (signupOk !== true) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}>
|
||||||
|
<span className="spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||||
|
Your account is ready. Create a game account now to play, or skip and do it later from your portal.
|
||||||
|
</p>
|
||||||
|
<CreateGameAccountForm submit={api.player.shard.createAccount} onCreated={onDone} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
client/src/components/PlayersOnline.jsx
Normal file
84
client/src/components/PlayersOnline.jsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useShardFeed } from '../lib/useShardFeed.js'
|
||||||
|
import { bucketize } from '../data/regionBuckets.js'
|
||||||
|
import api from '../api.js'
|
||||||
|
import { useAsync } from '../core.js'
|
||||||
|
|
||||||
|
// Compact live "Players Online" widget. Loads the presence.online aggregate once,
|
||||||
|
// then keeps the total + region breakdown current from the presence.online SSE
|
||||||
|
// kind. The raw byRegion map is rolled up into display buckets (see
|
||||||
|
// data/regionBuckets.js). NOT a page — drop it into any panel/column.
|
||||||
|
const PRESENCE_KINDS = new Set(['presence.online'])
|
||||||
|
|
||||||
|
export default function PlayersOnline() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.presence())
|
||||||
|
const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 4 })
|
||||||
|
|
||||||
|
// The freshest snapshot wins: the newest buffered presence.online event, else
|
||||||
|
// the initial fetch.
|
||||||
|
const snapshot = events[0] || data
|
||||||
|
|
||||||
|
const { total, rows } = useMemo(() => {
|
||||||
|
const count = Number(snapshot?.count) || 0
|
||||||
|
const { rows: bucketRows } = bucketize(snapshot?.byRegion)
|
||||||
|
return { total: count, rows: bucketRows }
|
||||||
|
}, [snapshot])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel" style={{ padding: 20 }}>
|
||||||
|
<div
|
||||||
|
className="sans"
|
||||||
|
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Players online
|
||||||
|
</span>
|
||||||
|
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)', lineHeight: 1 }}>
|
||||||
|
{loading ? '—' : total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="sans dim" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
|
||||||
|
Population is unavailable right now.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>
|
||||||
|
{total > 0 ? 'Locations are settling…' : 'The realm is quiet.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
rows.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{r.label}</span>
|
||||||
|
{/* tabular figures keep the right-aligned counts in a clean column */}
|
||||||
|
<span className="dim" style={{ fontVariantNumeric: 'tabular-nums' }}>{r.count}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
88
client/src/components/ShardAccountActions.jsx
Normal file
88
client/src/components/ShardAccountActions.jsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import api from '../api.js'
|
||||||
|
import { useAuth } from '../core.js'
|
||||||
|
|
||||||
|
// Compact in-game moderation controls (kick / ban / unban) scoped to a single
|
||||||
|
// game account. Reused wherever a linked account or character is shown to staff:
|
||||||
|
// the admin user-detail account list and the character sheet. Self-gates on role
|
||||||
|
// (admin/moderator) so it is safe to render inside components that players also
|
||||||
|
// see — a player never gets the controls, and the API enforces the same gate.
|
||||||
|
//
|
||||||
|
// `actor` is stamped server-side from the session; nothing here sends it. Kick is
|
||||||
|
// reversible (they reconnect) so it acts immediately; Ban reveals an inline
|
||||||
|
// confirm with an optional duration + reason before it fires.
|
||||||
|
export default function ShardAccountActions({ account, style }) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [busy, setBusy] = useState('')
|
||||||
|
const [ok, setOk] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [banOpen, setBanOpen] = useState(false)
|
||||||
|
const [durationSec, setDurationSec] = useState('')
|
||||||
|
const [reason, setReason] = useState('')
|
||||||
|
|
||||||
|
// Only staff who can actually use the write plane see the controls.
|
||||||
|
if (!user || !['admin', 'moderator'].includes(user.role) || !account) return null
|
||||||
|
|
||||||
|
async function run(label, fn, done) {
|
||||||
|
setBusy(label); setOk(''); setErr('')
|
||||||
|
try {
|
||||||
|
const r = await fn()
|
||||||
|
setOk(done(r))
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || 'Action failed.')
|
||||||
|
} finally {
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kick = () =>
|
||||||
|
run('kick', () => api.admin.shardOps.kick({ account }), (r) => {
|
||||||
|
const n = r && r.sessions != null ? r.sessions : null
|
||||||
|
const plural = n === 1 ? '' : 's'
|
||||||
|
const sessions = n != null ? ` (${n} session${plural})` : ''
|
||||||
|
return `Kicked${sessions}.`
|
||||||
|
})
|
||||||
|
const unban = () => run('unban', () => api.admin.shardOps.unban(account), () => 'Unbanned.')
|
||||||
|
const ban = () =>
|
||||||
|
run('ban', () =>
|
||||||
|
api.admin.shardOps.ban({
|
||||||
|
account,
|
||||||
|
durationSec: durationSec === '' ? undefined : Number(durationSec),
|
||||||
|
reason: reason.trim() || undefined,
|
||||||
|
}),
|
||||||
|
() => {
|
||||||
|
setBanOpen(false)
|
||||||
|
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
|
||||||
|
return `Banned${when}.`
|
||||||
|
})
|
||||||
|
|
||||||
|
const btn = { fontSize: '0.72rem', padding: '4px 10px' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sans" style={{ display: 'flex', flexDirection: 'column', gap: 8, ...style }}>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
|
||||||
|
<button onClick={kick} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'kick' ? '…' : 'Kick'}</button>
|
||||||
|
<button onClick={() => { setBanOpen((v) => !v); setOk(''); setErr('') }} disabled={!!busy} className="btn btn-sq" style={{ ...btn, borderColor: '#d98b84', color: '#d98b84' }}>Ban…</button>
|
||||||
|
<button onClick={unban} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'unban' ? '…' : 'Unban'}</button>
|
||||||
|
{ok && <span style={{ color: '#7fd0a4', fontSize: '0.8rem' }}>{ok}</span>}
|
||||||
|
{err && <span style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{banOpen && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 8, padding: '10px 12px', border: '1px solid var(--line)', borderRadius: 8, background: 'rgba(217,139,132,0.06)' }}>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Duration (sec, blank = permanent)</span>
|
||||||
|
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" style={{ maxWidth: 150 }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', flex: 1, minWidth: 160 }}>
|
||||||
|
<span className="field-label">Reason (optional)</span>
|
||||||
|
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
|
||||||
|
</label>
|
||||||
|
<button onClick={ban} disabled={busy === 'ban'} className="btn btn-primary btn-sq" style={{ borderColor: '#d98b84', background: '#d98b84', ...btn }}>
|
||||||
|
{busy === 'ban' ? 'Banning…' : `Confirm ban ${account}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
client/src/components/ShardStatusLink.jsx
Normal file
24
client/src/components/ShardStatusLink.jsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// ── Core's fill for the `site.footer.status` extension slot ────────────────
|
||||||
|
//
|
||||||
|
// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1; the contract is
|
||||||
|
// MODULE_API.md §3.7.
|
||||||
|
//
|
||||||
|
// This is the whole of what used to be four lines inline in SiteFooter.jsx, and
|
||||||
|
// it is a file now for one reason: `/uo/shard` is a UO page, so the link goes
|
||||||
|
// when the client half goes, and core should be deleting a registration rather
|
||||||
|
// than editing its footer under extraction pressure.
|
||||||
|
//
|
||||||
|
// Note what core kept and what it handed over. Core owns the position in the row
|
||||||
|
// and the separator around it, and passes `linkStyle` so the row stays visually
|
||||||
|
// one row. The label, the destination, and the decision to render at all are
|
||||||
|
// this file's — which is exactly the division a module inherits.
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function ShardStatusLink({ linkStyle }) {
|
||||||
|
return (
|
||||||
|
<Link to="/uo/shard" style={linkStyle}>
|
||||||
|
Shard Status
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
client/src/components/VendorSales.jsx
Normal file
41
client/src/components/VendorSales.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ago } from '../lib/format.js'
|
||||||
|
|
||||||
|
// Owner-private recent player-vendor sales. `fetchSales` is the scope method
|
||||||
|
// (api.player.shard.sales / api.admin.shard.sales) — the server only returns
|
||||||
|
// sales for accounts linked to the caller.
|
||||||
|
export default function VendorSales({ fetchSales }) {
|
||||||
|
const [sales, setSales] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
fetchSales()
|
||||||
|
.then((rows) => active && setSales(rows))
|
||||||
|
.catch(() => active && setError('Could not load your vendor sales.'))
|
||||||
|
return () => { active = false }
|
||||||
|
}, [fetchSales])
|
||||||
|
|
||||||
|
if (error) return null
|
||||||
|
if (!sales) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 12 }}>Recent vendor sales</div>
|
||||||
|
{sales.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No vendor sales recorded yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{sales.map((s) => (
|
||||||
|
<li key={`${s.t}-${s.itemType}-${s.price}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} — {Number(s.price || 0).toLocaleString()}gp
|
||||||
|
</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(s.t)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
client/src/core.js
Normal file
72
client/src/core.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// ── What core hands this module, on the client side ────────────────────────
|
||||||
|
//
|
||||||
|
// The client twin of `server/core.js`, and deliberately much simpler than it.
|
||||||
|
// Every ported page imports its layout, its state components and its hooks from
|
||||||
|
// here, so the boundary is one file and `client/scripts/checkExternals.js` has
|
||||||
|
// one place to look. The normative contract is MODULE_API.md §3.2 and §3.4.
|
||||||
|
//
|
||||||
|
// **Why this is a plain read and the server's is a lazy accessor.** On the
|
||||||
|
// server, `ctx` arrives at `register(ctx)` — after every `require` has already
|
||||||
|
// run — so `server/core.js` has to defer resolution to call time or a router
|
||||||
|
// would capture `undefined` at file scope. There is no such gap here.
|
||||||
|
// `window.__rg` is published by core's own bundle (client/src/modules/shared.js),
|
||||||
|
// and every module chunk is a deferred script the server injects *after* that
|
||||||
|
// bundle's tag, so by the time the first line of this file executes the global
|
||||||
|
// is already there. Reading it once, at module scope, is safe — and it means a
|
||||||
|
// ported component keeps the ordinary `import { PageHeader } from '…'` shape
|
||||||
|
// rather than being wrapped in an accessor that would cost it its identity.
|
||||||
|
//
|
||||||
|
// The absent-global case is handled by `shim/rg.js`, which every shim beside it
|
||||||
|
// also goes through — the shims touch the global before this file does, so a
|
||||||
|
// check here would be unreachable.
|
||||||
|
|
||||||
|
import { createElement } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { rg as shared } from './shim/rg.js'
|
||||||
|
|
||||||
|
const rg = shared()
|
||||||
|
|
||||||
|
// ── The shared-dependency self-check ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Slice 0 carried this in entry.jsx, back when nothing else imported React and
|
||||||
|
// an unexercised alias was an unproven one. The aliases are thoroughly exercised
|
||||||
|
// now — thirty-five files import React and ten import the router — so what is
|
||||||
|
// left for a runtime check to do is narrower, and worth keeping for exactly that
|
||||||
|
// reason: the two BUILD guards (`assertSharedNotBundled` at resolution time,
|
||||||
|
// `checkExternals.js` on the artifact) both reason about the chunk in isolation,
|
||||||
|
// and neither can see the one failure that only exists once the chunk meets a
|
||||||
|
// core: a `window.__rg` whose React is not the React that rendered the page.
|
||||||
|
//
|
||||||
|
// Identity is the only question worth asking. A second React satisfies every
|
||||||
|
// type check, renders its first element happily, and then throws about an invalid
|
||||||
|
// hook call somewhere unrelated.
|
||||||
|
if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.createRoot || Link !== rg.router.Link) {
|
||||||
|
console.error(
|
||||||
|
'[module-uo] the bindings this chunk imported are not the ones core published — it has bundled ' +
|
||||||
|
'its own copy of a shared dependency. Check the aliases in vite.config.js (MODULE_API.md §3.6).',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The curated kit (§3.4). Seven members, closed: anything else this module needs
|
||||||
|
// it bundles itself, which is why `components/` next door exists at all.
|
||||||
|
export const {
|
||||||
|
PublicLayout,
|
||||||
|
PageHeader,
|
||||||
|
Loading,
|
||||||
|
ErrorState,
|
||||||
|
EmptyState,
|
||||||
|
useAsync,
|
||||||
|
useAuth,
|
||||||
|
useSite,
|
||||||
|
} = rg.ui
|
||||||
|
|
||||||
|
// The registry, for entry.jsx. Everything else here is read by pages.
|
||||||
|
export const registry = rg.registry
|
||||||
|
|
||||||
|
// The core API version this module was loaded against. Logged by entry.jsx —
|
||||||
|
// `module.json`'s `coreApi` range is checked by the loader before this file is
|
||||||
|
// ever served, so there is nothing to re-check, only something to report.
|
||||||
|
export const coreApiVersion = rg.version
|
||||||
|
|
||||||
|
export default rg
|
||||||
31
client/src/data/cityCrests.js
Normal file
31
client/src/data/cityCrests.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Placeholder heraldry for the eight City-Loyalty cities. Each entry is a simple
|
||||||
|
// emoji sigil + a ring colour — enough to make the Governors board and the
|
||||||
|
// governor badge read as distinct "crests" today, swappable for real artwork
|
||||||
|
// later WITHOUT touching any component: drop an `img` (an imported asset URL or a
|
||||||
|
// public path) onto an entry and update CityCrest to prefer it.
|
||||||
|
//
|
||||||
|
// Keyed by the exact `city` string the sidecar sends (see INTEGRATION.md §4:
|
||||||
|
// Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia).
|
||||||
|
|
||||||
|
export const CITY_CRESTS = {
|
||||||
|
Britain: { sigil: '⚜', color: '#c9a24b', label: 'Britain' },
|
||||||
|
Moonglow: { sigil: '🔮', color: '#7f8fd0', label: 'Moonglow' },
|
||||||
|
Minoc: { sigil: '⚒', color: '#b0763f', label: 'Minoc' },
|
||||||
|
Trinsic: { sigil: '⚓', color: '#5f9bd0', label: 'Trinsic' },
|
||||||
|
Yew: { sigil: '🌳', color: '#5fb98a', label: 'Yew' },
|
||||||
|
Jhelom: { sigil: '⚔', color: '#c76f6f', label: 'Jhelom' },
|
||||||
|
SkaraBrae: { sigil: '🐎', color: '#9a8bbf', label: 'Skara Brae' },
|
||||||
|
NewMagincia: { sigil: '🕊', color: '#cfc3a0', label: 'New Magincia' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK = { sigil: '🏰', color: '#8c96a5', label: '' }
|
||||||
|
|
||||||
|
// Look up a crest by the raw city key, tolerating spacing variants
|
||||||
|
// ("Skara Brae" / "New Magincia"). `label` falls back to the given name.
|
||||||
|
export function crestFor(city) {
|
||||||
|
if (!city) return FALLBACK
|
||||||
|
const key = String(city).replace(/\s+/g, '')
|
||||||
|
const crest = CITY_CRESTS[city] || CITY_CRESTS[key]
|
||||||
|
if (crest) return crest
|
||||||
|
return { ...FALLBACK, label: String(city) }
|
||||||
|
}
|
||||||
72
client/src/data/regionBuckets.js
Normal file
72
client/src/data/regionBuckets.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// Roll the sidecar's raw presence.online `byRegion` map (many named ServUO
|
||||||
|
// regions) up into a handful of labelled display buckets for the "Players Online"
|
||||||
|
// widget. This is the ONE place to retune the grouping — edit BUCKETS (order +
|
||||||
|
// membership) and the widget follows. Anything not matched lands in "Wilderness"
|
||||||
|
// so the bucket counts always reconcile to the true total.
|
||||||
|
|
||||||
|
// Named cities/towns, matched as a prefix on the (space/apostrophe-stripped)
|
||||||
|
// region name so "skara brae", "serpent's hold", etc. all resolve. Kept as a
|
||||||
|
// list rather than one giant alternation regex (simpler to read and retune).
|
||||||
|
const TOWN_PREFIXES = [
|
||||||
|
'moonglow', 'minoc', 'trinsic', 'jhelom', 'yew', 'skarabrae', 'magincia',
|
||||||
|
'newmagincia', 'vesper', 'nujelm', 'cove', 'ocllo', 'serpenthold', 'serpentshold',
|
||||||
|
'wind', 'delucia', 'papua',
|
||||||
|
]
|
||||||
|
const normalizeRegion = (r) => String(r).toLowerCase().replace(/['’\s]/g, '')
|
||||||
|
|
||||||
|
// Ordered list of buckets. `label` shows in the widget; `match(region)` decides
|
||||||
|
// membership. First matching bucket wins; the last bucket is the catch-all.
|
||||||
|
export const BUCKETS = [
|
||||||
|
{
|
||||||
|
id: 'britain',
|
||||||
|
label: 'Britain',
|
||||||
|
// Passthrough for the capital + its immediate surrounds.
|
||||||
|
match: (r) => /^britain/i.test(r),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'towns',
|
||||||
|
label: 'Towns',
|
||||||
|
// The other named cities/towns.
|
||||||
|
match: (r) => {
|
||||||
|
const norm = normalizeRegion(r)
|
||||||
|
return TOWN_PREFIXES.some((t) => norm.startsWith(t))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dungeons',
|
||||||
|
label: 'Dungeons',
|
||||||
|
match: (r) =>
|
||||||
|
/(despise|destard|deceit|shame|hythloth|covetous|wrong|terathan|fire|ice|orc cave|dungeon|abyss|doom|khaldun|wrong|blackthorn|exodus|labyrinth|underworld)/i.test(
|
||||||
|
r,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'housing',
|
||||||
|
label: 'Housing',
|
||||||
|
// House regions expose themselves as named house/townhouse regions.
|
||||||
|
match: (r) => /(house|townhouse|homestead|tent)/i.test(r),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wilderness',
|
||||||
|
label: 'Wilderness',
|
||||||
|
// Catch-all: the unnamed "Wilderness" region + anything unmatched above.
|
||||||
|
match: () => true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Given a raw { region: count } map, return [{ id, label, count }] in BUCKETS
|
||||||
|
// order, dropping empty buckets, with the summed total also returned.
|
||||||
|
export function bucketize(byRegion = {}) {
|
||||||
|
const totals = new Map(BUCKETS.map((b) => [b.id, 0]))
|
||||||
|
let total = 0
|
||||||
|
for (const [region, n] of Object.entries(byRegion || {})) {
|
||||||
|
const count = Number(n) || 0
|
||||||
|
total += count
|
||||||
|
const bucket = BUCKETS.find((b) => b.match(String(region))) || BUCKETS[BUCKETS.length - 1]
|
||||||
|
totals.set(bucket.id, totals.get(bucket.id) + count)
|
||||||
|
}
|
||||||
|
const rows = BUCKETS.map((b) => ({ id: b.id, label: b.label, count: totals.get(b.id) })).filter(
|
||||||
|
(r) => r.count > 0,
|
||||||
|
)
|
||||||
|
return { rows, total }
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
// ── module-uo's client entry point ─────────────────────────────────────────
|
// ── 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
66
client/src/icons.jsx
Normal 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
36
client/src/lib/format.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// A vendored copy of the one helper this module uses from core's
|
||||||
|
// `client/src/lib/format.js`.
|
||||||
|
//
|
||||||
|
// **Vendored rather than added to the kit, and trimmed rather than copied
|
||||||
|
// whole.** The kit is curated and closed (MODULE_API.md §3.4): every member
|
||||||
|
// added to it is a minor version bump core can never take back, and a date
|
||||||
|
// formatter is not the kind of thing a module should be unable to write. Copying
|
||||||
|
// all six of core's helpers to get one would leave five with no consumer here
|
||||||
|
// and a standing question about which copy is authoritative.
|
||||||
|
//
|
||||||
|
// The vendoring line, from the server half of the extraction (slice 1): **pure
|
||||||
|
// leaf helpers may be copied, security controls may not.** This is a pure leaf.
|
||||||
|
// Core's HTML sanitiser sits two files away and stays exactly where it is.
|
||||||
|
//
|
||||||
|
// The two copies will drift, and that is correct — core's is core's to change.
|
||||||
|
// Nothing here reads a shared format.
|
||||||
|
|
||||||
|
function parse(value) {
|
||||||
|
if (!value) return null
|
||||||
|
const d = new Date(value)
|
||||||
|
return isNaN(d.getTime()) ? null : d
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "3m ago". Coarse on purpose: the live feed's timestamps are approximate. */
|
||||||
|
export function ago(value) {
|
||||||
|
const d = parse(value)
|
||||||
|
if (!d) return ''
|
||||||
|
const secs = Math.max(1, Math.floor((Date.now() - d.getTime()) / 1000))
|
||||||
|
if (secs < 60) return `${secs}s ago`
|
||||||
|
const mins = Math.floor(secs / 60)
|
||||||
|
if (mins < 60) return `${mins}m ago`
|
||||||
|
const hrs = Math.floor(mins / 60)
|
||||||
|
if (hrs < 24) return `${hrs}h ago`
|
||||||
|
const days = Math.floor(hrs / 24)
|
||||||
|
return `${days}d ago`
|
||||||
|
}
|
||||||
128
client/src/lib/shardEvents.js
Normal file
128
client/src/lib/shardEvents.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// Shared formatting for shard events — used by the public Shard page, the
|
||||||
|
// Activity feed, and the admin live feed. One place decides how each kind reads
|
||||||
|
// and which category/badge it belongs to.
|
||||||
|
|
||||||
|
function nameOf(who) {
|
||||||
|
if (!who) return 'Someone'
|
||||||
|
if (typeof who === 'string') return who
|
||||||
|
return who.name || who.acct || 'Someone'
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = (v) => Number(v || 0).toLocaleString()
|
||||||
|
|
||||||
|
// A one-line human description of each event kind, keyed by kind. Each formatter
|
||||||
|
// takes the payload and returns a string. Conditional suffixes are pulled into
|
||||||
|
// locals so no template literal is nested inside another.
|
||||||
|
const DESCRIBERS = {
|
||||||
|
'vendor.sale': (p) => {
|
||||||
|
const qty = p.amount > 1 ? ` ×${p.amount}` : ''
|
||||||
|
return `${p.itemType || 'An item'}${qty} sold for ${n(p.price)}gp`
|
||||||
|
},
|
||||||
|
'player.death': (p) => {
|
||||||
|
const by = p.killer ? ` by ${nameOf(p.killer)}` : ''
|
||||||
|
return `${nameOf(p.who)} was slain${by}`
|
||||||
|
},
|
||||||
|
'player.murdered': (p) => {
|
||||||
|
const by = p.murderer ? ` by ${nameOf(p.murderer)}` : ''
|
||||||
|
return `${nameOf(p.victim)} was murdered${by}`
|
||||||
|
},
|
||||||
|
'mob.killed': (p) => `${nameOf(p.killer)} killed ${nameOf(p.killed)}`,
|
||||||
|
'skill.gain': (p) => {
|
||||||
|
const base = p.base != null ? ` (${p.base})` : ''
|
||||||
|
return `${nameOf(p.who)} gained ${p.skill}${base}`
|
||||||
|
},
|
||||||
|
'fame.change': (p) => `${nameOf(p.who)}’s fame changed to ${n(p.new)}`,
|
||||||
|
'karma.change': (p) => `${nameOf(p.who)}’s karma changed to ${n(p.new)}`,
|
||||||
|
'quest.complete': (p) => `${nameOf(p.who)} completed “${p.quest}”`,
|
||||||
|
'house.decay': (p) => {
|
||||||
|
const region = p.region ? ` — ${p.region}` : ''
|
||||||
|
return `${p.name || 'A house'} is now ${p.to || p.stage}${region}`
|
||||||
|
},
|
||||||
|
'mob.login': (p) => `${nameOf(p.who)} entered the world`,
|
||||||
|
'mob.logout': (p) => `${nameOf(p.who)} left the world`,
|
||||||
|
'economy.supply': (p) => `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts`,
|
||||||
|
'server.hello': (p) => `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles`,
|
||||||
|
'server.shutdown': () => 'Shard shut down',
|
||||||
|
'server.crashed': (p) => {
|
||||||
|
const err = p.error ? `: ${p.error}` : ''
|
||||||
|
return `Shard crashed${err}`
|
||||||
|
},
|
||||||
|
'champ.update': (p) => {
|
||||||
|
const where = p.name || p.type || 'A champion spawn'
|
||||||
|
if (p.status === 'active' && p.bossUp) {
|
||||||
|
const boss = p.boss ? ` (${p.boss})` : ''
|
||||||
|
return `${where}: boss is up${boss}`
|
||||||
|
}
|
||||||
|
if (p.status === 'active') {
|
||||||
|
const level = p.level != null ? ` — level ${p.level}` : ''
|
||||||
|
return `${where} is active${level}`
|
||||||
|
}
|
||||||
|
if (p.status === 'cooldown') return `${where} is on cooldown`
|
||||||
|
return `${where} is ${p.status || 'idle'}`
|
||||||
|
},
|
||||||
|
'champ.remove': () => `A champion spawn ended`,
|
||||||
|
// Support (help-page) queue + in-game moderation (admin channel only)
|
||||||
|
'page.new': (p) => `New ${p.type || 'help'} page from ${nameOf(p.sender)}`,
|
||||||
|
'page.updated': (p) => {
|
||||||
|
const claimed = p.handled ? ' (claimed)' : ''
|
||||||
|
return `Help page from ${nameOf(p.sender)} updated${claimed}`
|
||||||
|
},
|
||||||
|
'page.closed': (p) => `Help page ${p.pageId || ''} closed`,
|
||||||
|
'admin.audit': (p) => {
|
||||||
|
const on = p.target ? ` on ${p.target}` : ''
|
||||||
|
const origin = p.origin ? ` [${p.origin}]` : ''
|
||||||
|
return `${p.actor || 'Staff'} ${p.action || 'acted'}${on}${origin}`
|
||||||
|
},
|
||||||
|
// Staff / sensitive (admin channel only)
|
||||||
|
'audit.set': (p) =>
|
||||||
|
`${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})`,
|
||||||
|
'audit.command': (p) => {
|
||||||
|
const args = p.args ? ` ${p.args}` : ''
|
||||||
|
return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${args}`
|
||||||
|
},
|
||||||
|
'cheat.fastwalk': (p) => {
|
||||||
|
const ip = p.ip ? ` (${p.ip})` : ''
|
||||||
|
return `Fast-walk flagged: ${nameOf(p.who)}${ip}`
|
||||||
|
},
|
||||||
|
'account.login.attempt': (p) => {
|
||||||
|
const ip = p.ip ? ` from ${p.ip}` : ''
|
||||||
|
return `Login attempt: ${p.acct}${ip}`
|
||||||
|
},
|
||||||
|
'gold.change': (p) => {
|
||||||
|
const sign = p.delta >= 0 ? '+' : ''
|
||||||
|
return `${p.acct}: gold ${sign}${n(p.delta)} → ${n(p.new)}`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// A one-line human description of an event. Accepts either a stored event
|
||||||
|
// (with .payload) or a raw live frame (fields at top level).
|
||||||
|
export function describe(ev) {
|
||||||
|
const fmt = DESCRIBERS[ev.kind]
|
||||||
|
return fmt ? fmt(ev.payload || ev) : ev.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category grouping for the filter tabs.
|
||||||
|
// Vendor sales are intentionally NOT a public category — they are owner-private
|
||||||
|
// (a linked player sees their own under the portal). The admin live feed still
|
||||||
|
// describes vendor.sale via describe() below.
|
||||||
|
export const CATEGORIES = [
|
||||||
|
{ id: 'all', label: 'All', kinds: null },
|
||||||
|
{ id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] },
|
||||||
|
{ id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] },
|
||||||
|
{ id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CATEGORY_OF = (() => {
|
||||||
|
const m = {}
|
||||||
|
for (const c of CATEGORIES) if (c.kinds) for (const k of c.kinds) m[k] = c.id
|
||||||
|
return m
|
||||||
|
})()
|
||||||
|
|
||||||
|
export function categoryOf(kind) {
|
||||||
|
return CATEGORY_OF[kind] || 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short badge label for a kind (the part after the dot, title-cased-ish).
|
||||||
|
export function kindLabel(kind) {
|
||||||
|
return String(kind || '').replace(/[._]/g, ' ')
|
||||||
|
}
|
||||||
95
client/src/lib/useShardFeatures.js
Normal file
95
client/src/lib/useShardFeatures.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import api from '../api.js'
|
||||||
|
|
||||||
|
// Which shard surfaces the current viewer may reach, from
|
||||||
|
// GET /public/shard/features. Admins configure this per feature (Admin → Shard
|
||||||
|
// Visibility), so the nav can't be a static list any more.
|
||||||
|
//
|
||||||
|
// This is PRESENTATION only. The gate is server-side: a disabled feature 404s
|
||||||
|
// and an out-of-rung one 403s whether or not the link is rendered. So while the
|
||||||
|
// answer is still in flight we return `null` and callers show their default set
|
||||||
|
// — better a link that briefly 403s than a nav that flickers in on every load.
|
||||||
|
//
|
||||||
|
// Cached module-level: the answer is per-viewer but stable for a session, and
|
||||||
|
// every consumer would otherwise refetch it on mount.
|
||||||
|
let cached = null
|
||||||
|
let inFlight = null
|
||||||
|
|
||||||
|
export function resetShardFeatures() {
|
||||||
|
cached = null
|
||||||
|
inFlight = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShardFeatures() {
|
||||||
|
const [features, setFeatures] = useState(cached)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cached) return undefined
|
||||||
|
let alive = true
|
||||||
|
inFlight =
|
||||||
|
inFlight ||
|
||||||
|
api.shard
|
||||||
|
.features()
|
||||||
|
.then((data) => {
|
||||||
|
cached = {
|
||||||
|
level: data.level,
|
||||||
|
set: new Set(data.features || []),
|
||||||
|
// Not a visibility flag and deliberately carried alongside them: it
|
||||||
|
// is the same per-viewer, once-a-session answer from the same
|
||||||
|
// endpoint, and GameAccounts asking for it separately would be a
|
||||||
|
// second round-trip for a field already on the wire.
|
||||||
|
gameAccountSignup: Boolean(data.gameAccountSignup),
|
||||||
|
}
|
||||||
|
return cached
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// A failed lookup must not blank the nav — fall back to "show
|
||||||
|
// everything" and let the server do the gating.
|
||||||
|
cached = null
|
||||||
|
inFlight = null
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
inFlight.then((result) => {
|
||||||
|
if (alive) setFeatures(result)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return features
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience: true when `name` is visible, or when we don't know yet.
|
||||||
|
export function canSee(features, name) {
|
||||||
|
return !features || features.set.has(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same answer in the shape core's generic feature seam takes: a Set-like of
|
||||||
|
// the flags this viewer may see, or null while we do not know yet
|
||||||
|
// (core's modules/featureGate.js). This module registers it as the provider for
|
||||||
|
// the `uo` namespace in entry.jsx, and the nine shard-gated rows in the public
|
||||||
|
// header are ours to gate as of slice 3.
|
||||||
|
//
|
||||||
|
// It used to be core that registered this hook, under owner id `core`, so that
|
||||||
|
// the seam was exercised from the day it was built. That prediction held exactly
|
||||||
|
// — this slice deleted a registration and a file rather than rewriting a header.
|
||||||
|
export function useShardFlags() {
|
||||||
|
const features = useShardFeatures()
|
||||||
|
return features ? features.set : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does this site offer game-account creation right now?
|
||||||
|
*
|
||||||
|
* `null` while unknown, which callers must treat as "not yet" rather than "no":
|
||||||
|
* the form it guards would 403 anyway, and flashing it in and out is worse than
|
||||||
|
* arriving a beat late. Unlike the visibility flags above this one fails CLOSED
|
||||||
|
* on a lookup error — showing a create-account form on a shard that refuses them
|
||||||
|
* is a dead end the player cannot tell from a bug, whereas a hidden nav row has
|
||||||
|
* another way round.
|
||||||
|
*/
|
||||||
|
export function useGameAccountSignup() {
|
||||||
|
const features = useShardFeatures()
|
||||||
|
return features ? features.gameAccountSignup : null
|
||||||
|
}
|
||||||
54
client/src/lib/useShardFeed.js
Normal file
54
client/src/lib/useShardFeed.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import api from '../api.js'
|
||||||
|
|
||||||
|
// Subscribe to the public shard live-event SSE stream and keep a rolling buffer
|
||||||
|
// of the most recent events. The browser talks to our own /public/shard/stream
|
||||||
|
// route (plain HTTP EventSource) — never the sidecar's WebSocket — so the token
|
||||||
|
// stays server-side and it works through any reverse proxy.
|
||||||
|
//
|
||||||
|
// EventSource auto-reconnects on drop, so there is no manual retry loop here; a
|
||||||
|
// `connected` flag is exposed for a small live/offline indicator. `filter` (a
|
||||||
|
// Set of kinds, optional) limits which events are buffered. `max` caps the
|
||||||
|
// buffer length.
|
||||||
|
export function useShardFeed({ url, filter, max = 40 } = {}) {
|
||||||
|
const [events, setEvents] = useState([])
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
// Keep the latest filter in a ref so re-renders don't tear down the stream.
|
||||||
|
const filterRef = useRef(filter)
|
||||||
|
filterRef.current = filter
|
||||||
|
const streamUrl = url || api.shardStreamUrl
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// EventSource isn't available during SSR / very old browsers — degrade to
|
||||||
|
// "no live feed" rather than throwing.
|
||||||
|
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return undefined
|
||||||
|
|
||||||
|
const es = new EventSource(streamUrl, { withCredentials: true })
|
||||||
|
|
||||||
|
es.onopen = () => setConnected(true)
|
||||||
|
es.onerror = () => setConnected(false) // EventSource will retry on its own
|
||||||
|
|
||||||
|
es.onmessage = (msg) => {
|
||||||
|
let event
|
||||||
|
try {
|
||||||
|
event = JSON.parse(msg.data)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!event || !event.kind) return
|
||||||
|
const f = filterRef.current
|
||||||
|
if (f && !f.has(event.kind)) return
|
||||||
|
setEvents((prev) => {
|
||||||
|
// Tag with a stable-ish local id for React keys (events carry t but can
|
||||||
|
// collide within a ms) and cap the buffer.
|
||||||
|
const next = [{ ...event, _id: `${event.kind}-${event.t}-${prev.length}` }, ...prev]
|
||||||
|
return next.slice(0, max)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => es.close()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [max, streamUrl])
|
||||||
|
|
||||||
|
return { events, connected }
|
||||||
|
}
|
||||||
28
client/src/routes/admin/AdminCharacter.jsx
Normal file
28
client/src/routes/admin/AdminCharacter.jsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import CharacterSheet from '../../components/CharacterSheet.jsx'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||||
|
|
||||||
|
// A staff member's own character sheet inside the admin shell. Owner-checked —
|
||||||
|
// the endpoint only returns a sheet for a character on the caller's linked account.
|
||||||
|
export default function AdminCharacter() {
|
||||||
|
const { serial } = useParams()
|
||||||
|
const { loading, error, data } = useAsync(() => api.admin.shard.char(serial), [serial])
|
||||||
|
const restarting = error && error.status === 503
|
||||||
|
const forbidden = error && error.status === 403
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 760 }}>
|
||||||
|
<p style={{ margin: '0 0 18px' }}>
|
||||||
|
<Link to="/admin/uo/characters" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
|
||||||
|
← Back to my characters
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
{loading && <Loading />}
|
||||||
|
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
||||||
|
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
|
||||||
|
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
|
||||||
|
{!loading && !error && data && <CharacterSheet char={data} moderation />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
client/src/routes/admin/AdminCharacters.jsx
Normal file
18
client/src/routes/admin/AdminCharacters.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import CharacterStats from '../../components/CharacterStats.jsx'
|
||||||
|
import GameAccounts from '../../components/GameAccounts.jsx'
|
||||||
|
import VendorSales from '../../components/VendorSales.jsx'
|
||||||
|
import api from '../../api.js'
|
||||||
|
|
||||||
|
// Staff link their OWN in-game account and view their characters — the same
|
||||||
|
// shared component players use, pointed at the staff self-service endpoints.
|
||||||
|
// Sits inside the Admin shell, which supplies the "My Characters" page header;
|
||||||
|
// stat tiles bring it to parity with the Player Portal's Characters page.
|
||||||
|
export default function AdminCharacters() {
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 760 }}>
|
||||||
|
<CharacterStats scope={api.admin.shard} />
|
||||||
|
<GameAccounts scope={api.admin.shard} charTo={(serial) => `/admin/uo/characters/${serial}`} />
|
||||||
|
<VendorSales fetchSales={api.admin.shard.sales} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
client/src/routes/admin/HousesAdmin.jsx
Normal file
118
client/src/routes/admin/HousesAdmin.jsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||||
|
|
||||||
|
// Staff-only FULL house registry (admin + moderator). Owner, price, co-owners and
|
||||||
|
// decay — everything the public board hides. Loaded from /admin/shard/houses, kept
|
||||||
|
// live from the admin SSE channel (house.update / house.remove).
|
||||||
|
const HOUSE_KINDS = new Set(['house.update', 'house.remove', 'house.decay'])
|
||||||
|
|
||||||
|
const DECAY_TONE = {
|
||||||
|
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
|
||||||
|
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
|
||||||
|
}
|
||||||
|
|
||||||
|
function DecayBadge({ decay, isIdoc }) {
|
||||||
|
const label = isIdoc ? 'IDOC' : decay
|
||||||
|
if (!label) return null
|
||||||
|
const tone = DECAY_TONE[label] || 'var(--muted)'
|
||||||
|
return (
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 8px' }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownerLabel(h) {
|
||||||
|
return h.ownerName || h.ownerAcct || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function HouseRow({ h }) {
|
||||||
|
const owner = ownerLabel(h)
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||||
|
<strong className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{h.name || 'An unnamed house'}
|
||||||
|
</strong>
|
||||||
|
<DecayBadge decay={h.decay} isIdoc={h.isIdoc} />
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
|
||||||
|
{owner ? <>Owned by <span style={{ color: 'var(--ink)' }}>{owner}</span></> : 'No owner'}
|
||||||
|
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||||
|
{h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{h.price != null && (
|
||||||
|
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: '0.92rem', color: 'var(--head)', fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()}</div>
|
||||||
|
<div className="dim" style={{ fontSize: '0.64rem', letterSpacing: '0.04em', textTransform: 'uppercase' }}>placement value</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HousesAdmin() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.admin.shard.houses())
|
||||||
|
// Full registry deltas ride the admin SSE channel (never the public one).
|
||||||
|
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, filter: HOUSE_KINDS, max: 80 })
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
|
||||||
|
const board = useMemo(() => {
|
||||||
|
const map = new Map()
|
||||||
|
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const ev = events[i]
|
||||||
|
if (!ev.serial) continue
|
||||||
|
if (ev.kind === 'house.update') {
|
||||||
|
map.set(ev.serial, { ...ev, ownerName: ev.owner?.name ?? ev.ownerName, ownerAcct: ev.owner?.acct ?? ev.ownerAcct })
|
||||||
|
} else if (ev.kind === 'house.remove') {
|
||||||
|
map.delete(ev.serial)
|
||||||
|
} else if (ev.kind === 'house.decay') {
|
||||||
|
const cur = map.get(ev.serial) || { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y }
|
||||||
|
map.set(ev.serial, { ...cur, isIdoc: String(ev.to).toUpperCase() === 'IDOC' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...map.values()]
|
||||||
|
}, [data, events])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const needle = q.trim().toLowerCase()
|
||||||
|
const rows = needle
|
||||||
|
? board.filter((h) => [h.name, h.region, h.map, ownerLabel(h)].some((v) => v && String(v).toLowerCase().includes(needle)))
|
||||||
|
: board
|
||||||
|
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||||
|
}, [board, q])
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState message="Could not load the house registry." />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
|
||||||
|
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.82rem', margin: 0 }}>
|
||||||
|
{board.length.toLocaleString()} houses
|
||||||
|
<span className="dim" style={{ marginLeft: 10, color: connected ? '#7fd0a4' : 'var(--muted)' }}>{connected ? '● live' : '○ offline'}</span>
|
||||||
|
</p>
|
||||||
|
<input className="input sans" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by owner, region…" style={{ flex: 'none', width: 230, maxWidth: '55%', fontSize: '0.84rem' }} />
|
||||||
|
</div>
|
||||||
|
{board.length === 0 ? (
|
||||||
|
<div className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0 }}>No houses are being tracked right now.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{filtered.map((h) => <HouseRow key={h.serial} h={h} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{board.length > 0 && filtered.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No houses match “{q}”.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
323
client/src/routes/admin/ShardAdmin.jsx
Normal file
323
client/src/routes/admin/ShardAdmin.jsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import { describe, kindLabel } from '../../lib/shardEvents.js'
|
||||||
|
import { ago } from '../../lib/format.js'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import { ErrorState, Loading } from '../../core.js'
|
||||||
|
|
||||||
|
// Full live feed from the admin SSE channel — every kind, incl. staff audit,
|
||||||
|
// cheat detection and login attempts that the public channel never carries.
|
||||||
|
function AdminLiveFeed() {
|
||||||
|
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, max: 60 })
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Live feed (all events)</h3>
|
||||||
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Waiting for shard events…</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 360, overflowY: 'auto' }}>
|
||||||
|
{events.map((e) => (
|
||||||
|
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.6rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>{kindLabel(e.kind)}</span>
|
||||||
|
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
|
||||||
|
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// uo-link sidecar control panel. The auth token is write-only over this API —
|
||||||
|
// stored encrypted, never returned — same convention as the Discord bot token.
|
||||||
|
// Saving (re)starts the WS ingest client, so Enabled/URL/token changes take
|
||||||
|
// effect immediately with no redeploy.
|
||||||
|
|
||||||
|
function Toggle({ checked, onChange, label }) {
|
||||||
|
return (
|
||||||
|
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR = {
|
||||||
|
connected: '#7fd0a4',
|
||||||
|
reconnecting: '#e0b070',
|
||||||
|
error: '#d98b84',
|
||||||
|
disconnected: 'var(--muted)',
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPanel({ config }) {
|
||||||
|
const color = STATUS_COLOR[config.status] || 'var(--muted)'
|
||||||
|
const ingest = config.ingest || {}
|
||||||
|
const health = config.health || {}
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
|
||||||
|
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
|
||||||
|
{config.status || 'disconnected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{config.statusDetail && (
|
||||||
|
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
|
||||||
|
)}
|
||||||
|
<div className="sans dim" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 16px', fontSize: '0.78rem', marginTop: 2 }}>
|
||||||
|
<span>Shard link: <strong style={{ color: 'var(--ink)' }}>{config.pluginConnected ? 'up' : 'down'}</strong></span>
|
||||||
|
<span>WS ingest: <strong style={{ color: 'var(--ink)' }}>{ingest.connected ? 'connected' : 'offline'}</strong></span>
|
||||||
|
<span>Reconnects: <strong style={{ color: 'var(--ink)' }}>{ingest.reconnects ?? 0}</strong></span>
|
||||||
|
<span>SSE clients: <strong style={{ color: 'var(--ink)' }}>{(config.sse?.publicClients ?? 0) + (config.sse?.adminClients ?? 0)}</strong></span>
|
||||||
|
{config.lastEventAt && <span style={{ gridColumn: '1 / -1' }}>Last event: {new Date(config.lastEventAt).toLocaleString()}</span>}
|
||||||
|
{health.uptime && <span style={{ gridColumn: '1 / -1' }}>Sidecar uptime: {health.uptime}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Game-account signup ─────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// This field lived in core's Site Settings until slice 3 of the extraction. It
|
||||||
|
// moved here rather than being deleted or left behind, because its help text has
|
||||||
|
// always described an agreement between this site and a ServUO shard — and half
|
||||||
|
// of that agreement is configured in Bridge.cfg, which core has never heard of.
|
||||||
|
//
|
||||||
|
// The setting key and value are unchanged (`game_account_signup`), so an
|
||||||
|
// instance that had this configured finds it here, set to what it was.
|
||||||
|
const SIGNUP_MODES = [
|
||||||
|
{ value: 'disabled', label: 'Disabled — link an existing account only' },
|
||||||
|
{ value: 'website', label: 'Website — the site creates game accounts' },
|
||||||
|
{ value: 'hybrid', label: 'Hybrid — site or in-game (recommended)' },
|
||||||
|
{ value: 'game', label: 'Game only — created in the game client, not the site' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function GameSignup() {
|
||||||
|
const [mode, setMode] = useState(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
api.admin.getSignupMode()
|
||||||
|
.then((r) => active && setMode(r.mode))
|
||||||
|
.catch(() => active && setError('Could not load the signup mode.'))
|
||||||
|
return () => { active = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function save(next) {
|
||||||
|
const previous = mode
|
||||||
|
setMode(next); setBusy(true); setMsg(''); setError('')
|
||||||
|
try {
|
||||||
|
await api.admin.saveSignupMode(next)
|
||||||
|
setMsg('Saved.')
|
||||||
|
} catch (err) {
|
||||||
|
setMode(previous) // the select must not show a mode the server did not take
|
||||||
|
setError(err.message || 'Could not save.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Game-account creation</h3>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||||
|
Whether players can create a GAME account (for the game client) from the site. The game server’s own
|
||||||
|
SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them.
|
||||||
|
When enabled, a “Create a game account” form appears in the player portal and after an invite is accepted.
|
||||||
|
</p>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Mode</span>
|
||||||
|
<select
|
||||||
|
value={mode ?? ''}
|
||||||
|
onChange={(e) => save(e.target.value)}
|
||||||
|
disabled={busy || mode === null}
|
||||||
|
className="input"
|
||||||
|
style={{ maxWidth: 420 }}
|
||||||
|
>
|
||||||
|
{mode === null && <option value="">Loading…</option>}
|
||||||
|
{SIGNUP_MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', minHeight: 20 }}>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Town crier ──────────────────────────────────────────────────────────────
|
||||||
|
function TownCrier() {
|
||||||
|
const [id, setId] = useState('')
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [durationSec, setDurationSec] = useState(3600)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function post() {
|
||||||
|
setBusy(true); setMsg(''); setError('')
|
||||||
|
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||||
|
if (!id.trim() || lines.length === 0) {
|
||||||
|
setBusy(false)
|
||||||
|
return setError('An id and at least one line are required.')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.admin.postTownCrier({ id: id.trim(), lines, durationSec: Number(durationSec) || undefined })
|
||||||
|
setMsg(`Posted “${id.trim()}”.`)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not post.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function remove() {
|
||||||
|
if (!id.trim()) return setError('Enter the id to remove.')
|
||||||
|
setBusy(true); setMsg(''); setError('')
|
||||||
|
try {
|
||||||
|
await api.admin.deleteTownCrier(id.trim())
|
||||||
|
setMsg(`Removed “${id.trim()}”.`)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not remove.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Town crier</h3>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||||
|
Broadcast a message that every in-game town crier announces until it expires. Re-posting the same id replaces it.
|
||||||
|
</p>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Message id</span>
|
||||||
|
<input type="text" value={id} onChange={(e) => setId(e.target.value)} className="input" placeholder="news-42" autoComplete="off" style={{ maxWidth: 220 }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Lines (one per line)</span>
|
||||||
|
<textarea value={text} onChange={(e) => setText(e.target.value)} className="input" rows={3} placeholder={'Hear ye!\nMarket tax is now 5%.'} style={{ resize: 'vertical' }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Duration (seconds)</span>
|
||||||
|
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={1} max={86400} style={{ maxWidth: 160 }} />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
<button onClick={post} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Working…' : 'Post message'}</button>
|
||||||
|
<button onClick={remove} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>Remove by id</button>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShardAdmin() {
|
||||||
|
const [config, setConfig] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [baseUrl, setBaseUrl] = useState('')
|
||||||
|
const [wsUrl, setWsUrl] = useState('')
|
||||||
|
const [token, setToken] = useState('')
|
||||||
|
const [protocol, setProtocol] = useState(3)
|
||||||
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [saveError, setSaveError] = useState('')
|
||||||
|
const pollRef = useRef(null)
|
||||||
|
const initializedRef = useRef(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const c = await api.admin.getUoLinkConfig()
|
||||||
|
setConfig(c)
|
||||||
|
// Seed the editable fields once; later polls only refresh the status panel
|
||||||
|
// so they never clobber what the admin is mid-typing.
|
||||||
|
if (!initializedRef.current) {
|
||||||
|
setBaseUrl(c.baseUrl || '')
|
||||||
|
setWsUrl(c.wsUrl || '')
|
||||||
|
setProtocol(c.protocol || 3)
|
||||||
|
setEnabled(c.enabled)
|
||||||
|
initializedRef.current = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Could not load uo-link config.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
pollRef.current = setInterval(load, 5000)
|
||||||
|
return () => clearInterval(pollRef.current)
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setBusy(true); setMsg(''); setSaveError('')
|
||||||
|
try {
|
||||||
|
const body = { baseUrl, wsUrl, protocol: Number(protocol), enabled }
|
||||||
|
if (token) body.token = token
|
||||||
|
const saved = await api.admin.saveUoLinkConfig(body)
|
||||||
|
setConfig(saved)
|
||||||
|
setToken('')
|
||||||
|
setMsg('Saved.')
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err.message || 'Could not save.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
if (!config) return <Loading />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Shard (uo-link)</h2>
|
||||||
|
|
||||||
|
<StatusPanel config={config} />
|
||||||
|
|
||||||
|
<Toggle checked={enabled} onChange={setEnabled} label="Enable the shard integration" />
|
||||||
|
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Base URL (REST)</span>
|
||||||
|
<input type="text" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} className="input" autoComplete="off" placeholder="http://127.0.0.1:8080" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">WebSocket URL (feed)</span>
|
||||||
|
<input type="text" value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} className="input" autoComplete="off" placeholder="ws://127.0.0.1:8080/ws" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Auth token</span>
|
||||||
|
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} className="input" autoComplete="new-password" placeholder={config.hasToken ? '•••••••• configured — leave blank to keep' : 'Shared secret from sidecar.toml'} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', maxWidth: 140 }}>
|
||||||
|
<span className="field-label">Protocol</span>
|
||||||
|
<input type="number" value={protocol} onChange={(e) => setProtocol(e.target.value)} className="input" min={1} max={99} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
|
||||||
|
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Saving…' : 'Save changes'}</button>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{saveError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{saveError}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GameSignup />
|
||||||
|
|
||||||
|
<TownCrier />
|
||||||
|
|
||||||
|
<AdminLiveFeed />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
291
client/src/routes/admin/ShardOps.jsx
Normal file
291
client/src/routes/admin/ShardOps.jsx
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import { describe } from '../../lib/shardEvents.js'
|
||||||
|
import { ago } from '../../lib/format.js'
|
||||||
|
import api from '../../api.js'
|
||||||
|
|
||||||
|
// In-game staff operations: the uo-link write plane (broadcast / kick / ban /
|
||||||
|
// unban) and the help-page support queue, plus a live audit log. Open to admins
|
||||||
|
// and moderators. The acting staff member (`actor`) is attached server-side from
|
||||||
|
// the session — nothing here sends it — so every action is attributable.
|
||||||
|
|
||||||
|
function Flash({ ok, err }) {
|
||||||
|
if (ok) return <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{ok}</span>
|
||||||
|
if (err) return <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Broadcast ────────────────────────────────────────────────────────────────
|
||||||
|
function Broadcast() {
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [hue, setHue] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [ok, setOk] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
if (!text.trim()) return setErr('Enter a message.')
|
||||||
|
setBusy(true); setOk(''); setErr('')
|
||||||
|
try {
|
||||||
|
await api.admin.shardOps.broadcast({ text: text.trim(), hue: hue === '' ? undefined : Number(hue) })
|
||||||
|
setOk('Broadcast sent.')
|
||||||
|
setText('')
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || 'Could not broadcast.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Broadcast</h3>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
||||||
|
A system message shown to everyone online right now.
|
||||||
|
</p>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Message</span>
|
||||||
|
<input type="text" value={text} onChange={(e) => setText(e.target.value)} className="input" maxLength={300} placeholder="Server restart in 5 minutes" autoComplete="off" />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', maxWidth: 140 }}>
|
||||||
|
<span className="field-label">Hue (optional)</span>
|
||||||
|
<input type="number" value={hue} onChange={(e) => setHue(e.target.value)} className="input" min={0} max={3000} placeholder="53" />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
<button onClick={send} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Sending…' : 'Broadcast'}</button>
|
||||||
|
<Flash ok={ok} err={err} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Account actions (kick / ban / unban) ─────────────────────────────────────
|
||||||
|
function AccountActions() {
|
||||||
|
const [account, setAccount] = useState('')
|
||||||
|
const [durationSec, setDurationSec] = useState('')
|
||||||
|
const [reason, setReason] = useState('')
|
||||||
|
const [busy, setBusy] = useState('')
|
||||||
|
const [ok, setOk] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const acct = account.trim()
|
||||||
|
function guard() {
|
||||||
|
if (!acct) {
|
||||||
|
setErr('Enter an account name.')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(label, fn, done) {
|
||||||
|
if (!guard()) return
|
||||||
|
setBusy(label); setOk(''); setErr('')
|
||||||
|
try {
|
||||||
|
const r = await fn()
|
||||||
|
setOk(done(r))
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || 'Action failed.')
|
||||||
|
} finally {
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kick = () =>
|
||||||
|
run('kick', () => api.admin.shardOps.kick({ account: acct }), (r) => {
|
||||||
|
const n = r?.sessions != null ? r.sessions : null
|
||||||
|
const plural = n === 1 ? '' : 's'
|
||||||
|
const sessions = n != null ? ` (${n} session${plural})` : ''
|
||||||
|
return `Kicked ${acct}${sessions}.`
|
||||||
|
})
|
||||||
|
const ban = () =>
|
||||||
|
run(
|
||||||
|
'ban',
|
||||||
|
() =>
|
||||||
|
api.admin.shardOps.ban({
|
||||||
|
account: acct,
|
||||||
|
durationSec: durationSec === '' ? undefined : Number(durationSec),
|
||||||
|
reason: reason.trim() || undefined,
|
||||||
|
}),
|
||||||
|
() => {
|
||||||
|
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
|
||||||
|
return `Banned ${acct}${when}.`
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Account actions</h3>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
||||||
|
Kick, ban or unban a game account. Bans work even if the account is offline; the shard refuses to act on staff at or above co-owner.
|
||||||
|
</p>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Account</span>
|
||||||
|
<input type="text" value={account} onChange={(e) => setAccount(e.target.value)} className="input" placeholder="griefer42" autoComplete="off" style={{ maxWidth: 260 }} />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<label style={{ display: 'block', maxWidth: 200 }}>
|
||||||
|
<span className="field-label">Ban duration (seconds, blank = permanent)</span>
|
||||||
|
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', flex: 1, minWidth: 200 }}>
|
||||||
|
<span className="field-label">Ban reason (optional)</span>
|
||||||
|
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button onClick={kick} disabled={!!busy} className="btn btn-sq">{busy === 'kick' ? 'Kicking…' : 'Kick'}</button>
|
||||||
|
<button onClick={ban} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'ban' ? 'Banning…' : 'Ban'}</button>
|
||||||
|
<button onClick={unban} disabled={!!busy} className="btn btn-sq">{busy === 'unban' ? 'Unbanning…' : 'Unban'}</button>
|
||||||
|
<Flash ok={ok} err={err} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Support (help-page) queue ────────────────────────────────────────────────
|
||||||
|
function PageRow({ page, onDone }) {
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [busy, setBusy] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
async function respond(close) {
|
||||||
|
if (!message.trim()) return setErr('Enter a reply first.')
|
||||||
|
setBusy(close ? 'respond-close' : 'respond'); setErr('')
|
||||||
|
try {
|
||||||
|
await api.admin.shardOps.respondPage(page.pageId, { message: message.trim(), close })
|
||||||
|
onDone()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || 'Could not send.')
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function close() {
|
||||||
|
setBusy('close'); setErr('')
|
||||||
|
try {
|
||||||
|
await api.admin.shardOps.closePage(page.pageId)
|
||||||
|
onDone()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || 'Could not close.')
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<span className="sans" style={{ fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)' }}>{page.type || 'Page'}</span>
|
||||||
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
|
||||||
|
{page.sender?.name || page.pageId}
|
||||||
|
{page.handled && <span className="dim" style={{ fontSize: '0.72rem' }}> · claimed{page.handler ? ` by ${page.handler}` : ''}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{page.sentMs ? ago(page.sentMs) : ''}</span>
|
||||||
|
</div>
|
||||||
|
{page.message && <p className="sans" style={{ margin: 0, color: 'var(--ink)', fontSize: '0.88rem', lineHeight: 1.5 }}>{page.message}</p>}
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||||
|
{page.map || '—'}{page.x != null ? ` (${page.x}, ${page.y})` : ''}
|
||||||
|
</div>
|
||||||
|
<textarea value={message} onChange={(e) => setMessage(e.target.value)} className="input" rows={2} placeholder="A GM is on the way." style={{ resize: 'vertical' }} />
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button onClick={() => respond(false)} disabled={!!busy} className="btn btn-sq">{busy === 'respond' ? 'Sending…' : 'Reply'}</button>
|
||||||
|
<button onClick={() => respond(true)} disabled={!!busy} className="btn btn-primary btn-sq">{busy === 'respond-close' ? 'Sending…' : 'Reply & close'}</button>
|
||||||
|
<button onClick={close} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'close' ? 'Closing…' : 'Close'}</button>
|
||||||
|
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SupportQueue() {
|
||||||
|
const [pages, setPages] = useState(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const pollRef = useRef(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setPages(await api.admin.shardOps.pages())
|
||||||
|
} catch {
|
||||||
|
setErr('Could not load the support queue.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
pollRef.current = setInterval(load, 7000)
|
||||||
|
return () => clearInterval(pollRef.current)
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
let queueBody
|
||||||
|
if (pages == null) {
|
||||||
|
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading…</p>
|
||||||
|
} else if (pages.length === 0) {
|
||||||
|
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>The queue is empty.</p>
|
||||||
|
} else {
|
||||||
|
queueBody = (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{pages.map((p) => <PageRow key={p.pageId} page={p} onDone={load} />)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Support queue</h3>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
||||||
|
Open help pages from players. A reply reaches them in game (or on their next login).
|
||||||
|
</p>
|
||||||
|
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>}
|
||||||
|
{queueBody}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Audit log ────────────────────────────────────────────────────────────────
|
||||||
|
// Seeded from the stored admin.audit history, then kept live from the admin SSE
|
||||||
|
// channel (which carries every kind — we filter to admin.audit here).
|
||||||
|
function AuditLog() {
|
||||||
|
const [seed, setSeed] = useState([])
|
||||||
|
const { events } = useShardFeed({ url: api.adminShardStreamUrl, filter: new Set(['admin.audit']), max: 50 })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.admin.shardOps
|
||||||
|
.audit(50)
|
||||||
|
.then((rows) => setSeed(rows.map((r) => ({ ...r, _id: `seed-${r.id}` }))))
|
||||||
|
.catch(() => setSeed([]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Live events on top; fall back to the seed for anything older than the live tail.
|
||||||
|
const oldestLive = events.length ? Math.min(...events.map((e) => e.t || 0)) : Infinity
|
||||||
|
const rows = [...events, ...seed.filter((s) => (s.t || 0) < oldestLive)].slice(0, 60)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)', marginBottom: 12 }}>Audit log</h3>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No moderation actions recorded yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 320, overflowY: 'auto' }}>
|
||||||
|
{rows.map((e) => (
|
||||||
|
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
|
||||||
|
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
|
||||||
|
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShardOps() {
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||||
|
<Broadcast />
|
||||||
|
<AccountActions />
|
||||||
|
<SupportQueue />
|
||||||
|
<AuditLog />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
325
client/src/routes/admin/ShardVisibility.jsx
Normal file
325
client/src/routes/admin/ShardVisibility.jsx
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import { ErrorState, Loading } from '../../core.js'
|
||||||
|
|
||||||
|
// ── Admin · Shard visibility ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Who may see which shard surface, and which sensitive fields within it.
|
||||||
|
// Admin-only, because this decides what ANONYMOUS visitors get.
|
||||||
|
//
|
||||||
|
// Two things the UI must communicate honestly, because they are not negotiable
|
||||||
|
// server-side (see docs/link/v3.md §3.4):
|
||||||
|
// • acct / webId are admin-only always and are not listed as editable fields.
|
||||||
|
// • an event kind the server doesn't know about never reaches anyone below
|
||||||
|
// admin, whatever is set here.
|
||||||
|
//
|
||||||
|
// Defaults reproduce the behavior the site had before this panel existed, so a
|
||||||
|
// fresh install shows "everything as it was" rather than an empty form.
|
||||||
|
|
||||||
|
const RUNG_LABEL = {
|
||||||
|
anonymous: 'Everyone',
|
||||||
|
logged_in: 'Signed in',
|
||||||
|
player: 'Linked players',
|
||||||
|
staff: 'Staff',
|
||||||
|
admin: 'Admins only',
|
||||||
|
}
|
||||||
|
|
||||||
|
const RUNG_HINT = {
|
||||||
|
anonymous: 'Visible to anyone, signed in or not.',
|
||||||
|
logged_in: 'Any signed-in account, linked or not.',
|
||||||
|
player: 'Accounts with a linked game account. Staff always qualify.',
|
||||||
|
staff: 'Admins and moderators.',
|
||||||
|
admin: 'Admins only.',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURE_LABEL = {
|
||||||
|
status: 'Shard status',
|
||||||
|
activity: 'Activity feed',
|
||||||
|
champs: 'Champion spawns',
|
||||||
|
guilds: 'Guilds',
|
||||||
|
governors: 'Town governors',
|
||||||
|
houses: 'Houses / IDOC',
|
||||||
|
presence: 'Players online',
|
||||||
|
ruleset: 'Shard rules',
|
||||||
|
atlas: 'Spawn atlas',
|
||||||
|
leaderboards: 'Leaderboards',
|
||||||
|
market: 'Marketplace',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURE_HINT = {
|
||||||
|
status: 'Connection state, online count, gold-supply series.',
|
||||||
|
activity: 'Deaths, kills, skill gains, quests, logins.',
|
||||||
|
champs: 'The live champion / mini-champ / sea-boss board.',
|
||||||
|
guilds: 'Guild rosters, alliances and leaders.',
|
||||||
|
governors: 'City Loyalty governors, elections and term history.',
|
||||||
|
houses: 'Houses in danger (IDOC). Owner and price are separate fields below.',
|
||||||
|
presence: 'Population aggregate and the staff-online widget.',
|
||||||
|
ruleset: 'Skill/stat caps, house limits, vet rewards and the rest of the ruleset.',
|
||||||
|
atlas: 'The spawn atlas and bestiary. Static shard content, not live state.',
|
||||||
|
leaderboards: 'Point and loyalty standings across every points system.',
|
||||||
|
market: 'The shard-wide player-vendor index.',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_LABEL = {
|
||||||
|
owner: 'House owner',
|
||||||
|
price: 'House price',
|
||||||
|
location: 'In-game location (map + coordinates)',
|
||||||
|
connect: 'Server connect address',
|
||||||
|
// Keyed on the WIRE field, which for a leaderboard entry is `name` — the
|
||||||
|
// projection matches literal JSON keys, so the rule cannot be spelled after the
|
||||||
|
// field's meaning. The label is what carries the meaning to the admin.
|
||||||
|
name: 'Character names on leaderboards',
|
||||||
|
ownerName: 'Vendor owner name',
|
||||||
|
// One rule, one key — `location` is a nested object on both the wire frame and
|
||||||
|
// the stored read model precisely so that hiding it takes the facet, the
|
||||||
|
// coordinates, the region and the house together.
|
||||||
|
ownerSerial: 'Vendor owner character id',
|
||||||
|
}
|
||||||
|
|
||||||
|
function RungSelect({ value, onChange, ladder, disabled }) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
style={{ maxWidth: 200 }}
|
||||||
|
>
|
||||||
|
{ladder.map((rung) => (
|
||||||
|
<option key={rung} value={rung}>
|
||||||
|
{RUNG_LABEL[rung] || rung}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeatureRow({ name, settings, defaults, ladder, onPatch }) {
|
||||||
|
const fields = Object.entries(settings.fields || {})
|
||||||
|
const changed =
|
||||||
|
defaults &&
|
||||||
|
(settings.enabled !== defaults.enabled ||
|
||||||
|
settings.audience !== defaults.audience ||
|
||||||
|
settings.stream !== defaults.stream ||
|
||||||
|
JSON.stringify(settings.fields) !== JSON.stringify(defaults.fields))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 16,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 12,
|
||||||
|
opacity: settings.enabled ? 1 : 0.62,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
|
||||||
|
{FEATURE_LABEL[name] || name}
|
||||||
|
{changed && (
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{ marginLeft: 8, fontSize: '0.62rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
changed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<p className="sans" style={{ margin: '4px 0 0', fontSize: '0.82rem', color: 'var(--muted)', lineHeight: 1.5 }}>
|
||||||
|
{FEATURE_HINT[name]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
className="sans"
|
||||||
|
style={{ flex: 'none', display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.enabled}
|
||||||
|
onChange={(e) => onPatch(name, { enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 20, alignItems: 'flex-end' }}>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Who can see it</span>
|
||||||
|
<RungSelect
|
||||||
|
value={settings.audience}
|
||||||
|
ladder={ladder}
|
||||||
|
disabled={!settings.enabled}
|
||||||
|
onChange={(audience) => onPatch(name, { audience })}
|
||||||
|
/>
|
||||||
|
<span className="sans dim" style={{ display: 'block', marginTop: 4, fontSize: '0.75rem' }}>
|
||||||
|
{RUNG_HINT[settings.audience]}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className="sans"
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)', paddingBottom: 22 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.stream}
|
||||||
|
disabled={!settings.enabled}
|
||||||
|
onChange={(e) => onPatch(name, { stream: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Live updates
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fields.length > 0 && (
|
||||||
|
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 12 }}>
|
||||||
|
<span className="field-label" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
Sensitive fields
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
|
||||||
|
{fields.map(([field, rung]) => (
|
||||||
|
<label key={field} style={{ display: 'block' }}>
|
||||||
|
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginBottom: 4 }}>
|
||||||
|
{FIELD_LABEL[field] || field}
|
||||||
|
</span>
|
||||||
|
<RungSelect
|
||||||
|
value={rung}
|
||||||
|
ladder={ladder}
|
||||||
|
disabled={!settings.enabled}
|
||||||
|
onChange={(level) =>
|
||||||
|
onPatch(name, { fieldRules: { ...settings.fields, [field]: level } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShardVisibility() {
|
||||||
|
const [config, setConfig] = useState(null)
|
||||||
|
const [defaults, setDefaults] = useState(null)
|
||||||
|
const [ladder, setLadder] = useState([])
|
||||||
|
const [lockedFields, setLockedFields] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const data = await api.admin.getShardVisibility()
|
||||||
|
setConfig(data.features)
|
||||||
|
setDefaults(data.defaults)
|
||||||
|
setLadder(data.ladder || [])
|
||||||
|
setLockedFields(data.lockedFields || [])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not load visibility settings.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
function patch(name, changes) {
|
||||||
|
setMsg('')
|
||||||
|
setConfig((prev) => {
|
||||||
|
const next = { ...prev[name], ...changes }
|
||||||
|
// `fieldRules` in the API is `fields` in the effective config.
|
||||||
|
if (changes.fieldRules) {
|
||||||
|
next.fields = changes.fieldRules
|
||||||
|
delete next.fieldRules
|
||||||
|
}
|
||||||
|
return { ...prev, [name]: next }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true)
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const body = {}
|
||||||
|
for (const [name, s] of Object.entries(config)) {
|
||||||
|
body[name] = {
|
||||||
|
enabled: s.enabled,
|
||||||
|
audience: s.audience,
|
||||||
|
stream: s.stream,
|
||||||
|
fieldRules: s.fields || {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const data = await api.admin.saveShardVisibility(body)
|
||||||
|
setConfig(data.features)
|
||||||
|
setMsg('Saved. Changes take effect within a few seconds, including on open live streams.')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not save.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetToDefaults() {
|
||||||
|
setMsg('')
|
||||||
|
setConfig(structuredClone(defaults))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error && !config) return <ErrorState message={error} onRetry={load} />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<header>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||||
|
Shard visibility
|
||||||
|
</h2>
|
||||||
|
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||||
|
Choose who can see each shard surface on the public site, and how much detail they get.
|
||||||
|
Turning a feature off hides it entirely — its pages return “not found” rather than
|
||||||
|
revealing that it exists. “Live updates” controls whether the feature streams changes in
|
||||||
|
real time; the pages still work without it, they just refresh on load.
|
||||||
|
</p>
|
||||||
|
{lockedFields.length > 0 && (
|
||||||
|
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.82rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||||
|
Not configurable: <strong style={{ color: 'var(--ink)' }}>{lockedFields.join(', ')}</strong> —
|
||||||
|
game account names and website user ids are never shown below admin, on any surface. They
|
||||||
|
aren’t visible in game either, so publishing them would disclose something the shard
|
||||||
|
itself doesn’t.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
{Object.entries(config).map(([name, settings]) => (
|
||||||
|
<FeatureRow
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
settings={settings}
|
||||||
|
defaults={defaults?.[name]}
|
||||||
|
ladder={ladder}
|
||||||
|
onPatch={patch}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button onClick={save} disabled={saving} className="btn btn-primary btn-sq">
|
||||||
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
<button onClick={resetToDefaults} disabled={saving} className="btn btn-sq">
|
||||||
|
Restore defaults
|
||||||
|
</button>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
285
client/src/routes/admin/SpawnAtlas.jsx
Normal file
285
client/src/routes/admin/SpawnAtlas.jsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import { ErrorState, Loading } from '../../core.js'
|
||||||
|
|
||||||
|
// ── Admin · Spawn atlas ─────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The atlas re-derives itself from the shard's ServUO tree on every boot, so
|
||||||
|
// this panel exists for the three things a restart cannot do:
|
||||||
|
//
|
||||||
|
// • point it at a different tree,
|
||||||
|
// • apply a map change without restarting, and
|
||||||
|
// • answer a refresh that was parsed but deliberately NOT applied because it
|
||||||
|
// would remove a facet.
|
||||||
|
//
|
||||||
|
// That last one is the reason the panel is worth building. Losing a facet looks
|
||||||
|
// exactly like a half-copied or mid-update tree, and boot cannot tell them
|
||||||
|
// apart — so it stages the decision for a human instead of guessing. Until
|
||||||
|
// someone decides here, the site keeps serving the atlas it already had.
|
||||||
|
|
||||||
|
// A refresh reports its outcome rather than throwing (the boot path must never
|
||||||
|
// be stopped by a bad tree), so these are answers, not errors — the panel says
|
||||||
|
// what happened in the shard's terms instead of showing a failure box.
|
||||||
|
const OUTCOME = {
|
||||||
|
imported: (r) =>
|
||||||
|
`Imported — ${r.counts?.points?.toLocaleString() ?? '?'} spawners, ${r.counts?.creatures?.toLocaleString() ?? '?'} creatures.`,
|
||||||
|
unchanged: (r) =>
|
||||||
|
r.reason === 'refresh previously rejected'
|
||||||
|
? 'Unchanged — this exact tree was already reviewed and declined.'
|
||||||
|
: 'Unchanged — the tree matches what is already loaded.',
|
||||||
|
needsReview: () => 'Staged for review: this refresh would remove a facet, so it was not applied.',
|
||||||
|
unavailable: (r) => `The tree could not be read: ${r.reason || 'unknown reason'}`,
|
||||||
|
skipped: () => 'No ServUO path is configured, so there is nothing to import.',
|
||||||
|
failed: (r) => `Refresh failed: ${r.reason || 'unknown reason'}`,
|
||||||
|
rejected: () => 'Declined. It will not be offered again until the tree changes.',
|
||||||
|
}
|
||||||
|
|
||||||
|
const describe = (result) => (OUTCOME[result?.status] || (() => `Result: ${result?.status}`))(result)
|
||||||
|
|
||||||
|
function Row({ label, children }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 16,
|
||||||
|
padding: '7px 0',
|
||||||
|
borderBottom: '1px solid var(--line)',
|
||||||
|
fontSize: '0.86rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="dim">{label}</span>
|
||||||
|
<span style={{ color: 'var(--head)', textAlign: 'right', wordBreak: 'break-all' }}>{children}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PendingReview({ pending, busy, onApprove, onReject }) {
|
||||||
|
const declined = pending.status === 'rejected'
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${declined ? 'var(--line)' : '#c58f4a'}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 16,
|
||||||
|
background: declined ? 'transparent' : 'rgba(197,143,74,0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
|
||||||
|
{declined ? 'A refresh was declined' : 'A refresh is waiting for you'}
|
||||||
|
</h3>
|
||||||
|
<p className="sans" style={{ margin: '6px 0 12px', fontSize: '0.86rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||||
|
{declined ? (
|
||||||
|
<>
|
||||||
|
This tree was reviewed and declined, so it is not offered again until the files change.
|
||||||
|
Approving now applies it anyway.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
The tree parses cleanly but would <strong>remove {pending.removedFacets?.length || 0} facet
|
||||||
|
</strong>
|
||||||
|
{(pending.removedFacets?.length || 0) === 1 ? '' : 's'} the site is currently serving. That
|
||||||
|
is what a half-copied or mid-update tree looks like as well as a real map change, so it was
|
||||||
|
not applied. Approving re-parses the tree as it is right now — if you have since fixed the
|
||||||
|
mount, what lands is the corrected import.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Row label="Would remove">{(pending.removedFacets || []).join(', ') || '—'}</Row>
|
||||||
|
<Row label="Would add">{(pending.addedFacets || []).join(', ') || '—'}</Row>
|
||||||
|
<Row label="Detected">{pending.detectedAt ? new Date(pending.detectedAt).toLocaleString() : '—'}</Row>
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginTop: 14, flexWrap: 'wrap' }}>
|
||||||
|
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={onApprove}>
|
||||||
|
Approve and import
|
||||||
|
</button>
|
||||||
|
{!declined && (
|
||||||
|
<button type="button" className="btn btn-sq" disabled={busy} onClick={onReject}>
|
||||||
|
Keep the current atlas
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SpawnAtlas() {
|
||||||
|
const [status, setStatus] = useState(null)
|
||||||
|
const [path, setPath] = useState('')
|
||||||
|
const [force, setForce] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const data = await api.admin.atlas.status()
|
||||||
|
setStatus(data)
|
||||||
|
setPath(data.path || '')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not load atlas status.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
// Every mutating action shares this: run it, report what it said, then reload
|
||||||
|
// status so the panel reflects the world rather than what we assumed happened.
|
||||||
|
async function run(action, fn) {
|
||||||
|
setBusy(true)
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const result = await fn()
|
||||||
|
setMsg(describe(result))
|
||||||
|
const fresh = await api.admin.atlas.status()
|
||||||
|
setStatus(fresh)
|
||||||
|
setPath(fresh.path || '')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || `Could not ${action}.`)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePath() {
|
||||||
|
setBusy(true)
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const fresh = await api.admin.atlas.setPath(path.trim())
|
||||||
|
setStatus(fresh)
|
||||||
|
setPath(fresh.path || '')
|
||||||
|
setMsg(
|
||||||
|
fresh.path === ''
|
||||||
|
? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.'
|
||||||
|
: fresh.treeReadable
|
||||||
|
? 'Saved. The tree is readable — import when you are ready.'
|
||||||
|
: 'Saved, but the tree could not be read from here. Check the mount and permissions.',
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not save the path.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error && !status) return <ErrorState message={error} />
|
||||||
|
|
||||||
|
const counts = status?.counts || null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<header>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||||
|
Spawn atlas
|
||||||
|
</h2>
|
||||||
|
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||||
|
The bestiary and spawn map on the public site, parsed from the shard’s own ServUO files.
|
||||||
|
It refreshes itself on every server start; everything here is for the times you don’t want
|
||||||
|
to wait for one. Nothing on this page touches the sidecar — the atlas is shard content, not
|
||||||
|
shard state, and stays complete while the shard is down.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{status?.pending && (
|
||||||
|
<PendingReview
|
||||||
|
pending={status.pending}
|
||||||
|
busy={busy}
|
||||||
|
onApprove={() => run('approve the refresh', () => api.admin.atlas.approve())}
|
||||||
|
onReject={() => run('decline the refresh', () => api.admin.atlas.reject())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||||
|
<h3 className="display" style={{ margin: '0 0 10px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||||
|
What is loaded
|
||||||
|
</h3>
|
||||||
|
<Row label="Imported">
|
||||||
|
{status?.importedAt ? new Date(status.importedAt).toLocaleString() : 'Never'}
|
||||||
|
</Row>
|
||||||
|
<Row label="Facets">{status?.facets?.length ? status.facets.join(', ') : '—'}</Row>
|
||||||
|
{counts && (
|
||||||
|
<>
|
||||||
|
<Row label="Spawners">{counts.points?.toLocaleString() ?? '—'}</Row>
|
||||||
|
<Row label="Creatures">{counts.creatures?.toLocaleString() ?? '—'}</Row>
|
||||||
|
<Row label="Regions / landmarks">
|
||||||
|
{`${counts.regions?.toLocaleString() ?? '—'} / ${counts.landmarks?.toLocaleString() ?? '—'}`}
|
||||||
|
</Row>
|
||||||
|
<Row label="Champion altars">{counts.champions?.toLocaleString() ?? '—'}</Row>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Row label="Tree readable">
|
||||||
|
{!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'}
|
||||||
|
</Row>
|
||||||
|
<Row label="Tree changed since import">
|
||||||
|
{status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'}
|
||||||
|
</Row>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||||
|
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||||
|
ServUO tree
|
||||||
|
</h3>
|
||||||
|
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||||
|
Where the website reads the shard’s spawn files from — the same host, a bind mount or a
|
||||||
|
shared volume. This setting wins over the <code>SERVUO_PATH</code> deploy default, so the
|
||||||
|
mount can move without a redeploy. Leave it blank to turn the atlas off.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={path}
|
||||||
|
onChange={(e) => setPath(e.target.value)}
|
||||||
|
placeholder="/srv/servuo"
|
||||||
|
style={{ flex: '1 1 320px', minWidth: 0 }}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-sq" disabled={busy} onClick={savePath}>
|
||||||
|
Save path
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||||
|
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||||
|
Re-import
|
||||||
|
</h3>
|
||||||
|
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||||
|
Applies a map change without restarting. An unchanged tree costs nothing — the source files
|
||||||
|
are hashed first and skipped when they match. A refresh that would remove a facet still
|
||||||
|
comes back here for approval rather than being applied.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sq"
|
||||||
|
disabled={busy || !status?.configured}
|
||||||
|
onClick={() => run('import the atlas', () => api.admin.atlas.import(force))}
|
||||||
|
>
|
||||||
|
{busy ? 'Working…' : 'Import now'}
|
||||||
|
</button>
|
||||||
|
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: '0.85rem', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} />
|
||||||
|
Re-import even if the tree is unchanged
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{(msg || error) && (
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
163
client/src/routes/admin/UserShardSections.jsx
Normal file
163
client/src/routes/admin/UserShardSections.jsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
// ── Core's fill for the `admin.users.detail` extension slot ────────────────
|
||||||
|
//
|
||||||
|
// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1. Every section below
|
||||||
|
// is UO, and every one of them leaves core with the client half in slice 3 —
|
||||||
|
// this file exists so that when they do, core deletes a registration and a file
|
||||||
|
// instead of unpicking a page.
|
||||||
|
//
|
||||||
|
// Core registers it through the same seam a module uses
|
||||||
|
// (`registerExtension('core', …)` in main.jsx), which is the client twin of the
|
||||||
|
// server's `registries.registerCore()` and the same trick `useShardFlags`
|
||||||
|
// already uses for the feature seam. The mechanism is therefore exercised by
|
||||||
|
// core's own content from the day it lands, rather than first proved by the
|
||||||
|
// change that depends on it.
|
||||||
|
//
|
||||||
|
// The slot hands over `userId` and nothing else — deliberately, not `scope`.
|
||||||
|
// `api.admin.userShard` is a UO binding that leaves core in slice 3, so a slot
|
||||||
|
// that passed it would be handing a module something core is about to delete.
|
||||||
|
// An extension builds its own client for the routes it registered at the other
|
||||||
|
// end (MODULE_API.md §3.5), and this file does exactly what the module will.
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { ago } from '../../lib/format.js'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import CharacterStats from '../../components/CharacterStats.jsx'
|
||||||
|
import GameAccounts from '../../components/GameAccounts.jsx'
|
||||||
|
import VendorSales from '../../components/VendorSales.jsx'
|
||||||
|
import { useAsync } from '../../core.js'
|
||||||
|
|
||||||
|
// Its own copy, not an export from UserDetail.jsx: six lines of presentational
|
||||||
|
// furniture that is not in the §3.4 kit, so a module filling this slot would
|
||||||
|
// vendor the same thing. Core's copy stays behind with core's own security
|
||||||
|
// panel, which is the other caller.
|
||||||
|
function SectionTitle({ children }) {
|
||||||
|
return (
|
||||||
|
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currently-online characters on the user's accounts, with where they are. The
|
||||||
|
// per-character Online/Offline badge lives in the roster; this adds location.
|
||||||
|
function OnlineNow({ scope }) {
|
||||||
|
const { data } = useAsync(() => scope.online(), [scope])
|
||||||
|
if (!data) return null
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||||
|
<SectionTitle>Online now</SectionTitle>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{data.map((c) => (
|
||||||
|
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
|
||||||
|
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
|
||||||
|
</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
|
||||||
|
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shard "standing": city governorships held and guilds led by this user's
|
||||||
|
// accounts (both reliable current-state lookups). Renders nothing when empty.
|
||||||
|
function Standing({ scope }) {
|
||||||
|
const { data } = useAsync(() => scope.standing(), [scope])
|
||||||
|
if (!data) return null
|
||||||
|
const govs = data.governorOf || []
|
||||||
|
const guilds = data.guildsLed || []
|
||||||
|
if (govs.length === 0 && guilds.length === 0) return null
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||||
|
<SectionTitle>Standing</SectionTitle>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{govs.map((g) => (
|
||||||
|
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
|
||||||
|
Governor of {g.city}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{guilds.map((g) => (
|
||||||
|
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
|
||||||
|
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One house row — the many optional detail fields are gathered here so the
|
||||||
|
// Houses list stays a simple map.
|
||||||
|
function HouseRow({ house: h }) {
|
||||||
|
const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown')
|
||||||
|
const coords = h.x != null ? ` · ${h.x}, ${h.y}` : ''
|
||||||
|
const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : ''
|
||||||
|
const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
|
||||||
|
{h.name || 'Unnamed house'}
|
||||||
|
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
|
||||||
|
{location}
|
||||||
|
{coords}
|
||||||
|
{owner}
|
||||||
|
{shares}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
|
||||||
|
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
|
||||||
|
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
|
||||||
|
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Houses owned by the user's accounts, IDOC first (flagged).
|
||||||
|
function Houses({ scope }) {
|
||||||
|
const { data } = useAsync(() => scope.houses(), [scope])
|
||||||
|
if (!data) return null
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||||
|
<SectionTitle>Houses</SectionTitle>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this user’s accounts.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{data.map((h) => (
|
||||||
|
<HouseRow key={h.serial} house={h} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default function UserShardSections({ userId }) {
|
||||||
|
// Memoized so the child components' effects (keyed on `scope`) don't refetch
|
||||||
|
// on every render — the same reason UserDetail memoized it before this moved.
|
||||||
|
const scope = useMemo(() => api.admin.userShard(userId), [userId])
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CharacterStats scope={scope} />
|
||||||
|
<SectionTitle>Linked accounts & characters</SectionTitle>
|
||||||
|
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/uo/characters/${serial}`} />
|
||||||
|
<Standing scope={scope} />
|
||||||
|
<OnlineNow scope={scope} />
|
||||||
|
<Houses scope={scope} />
|
||||||
|
<VendorSales fetchSales={scope.sales} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
client/src/routes/player/PlayerCharacter.jsx
Normal file
28
client/src/routes/player/PlayerCharacter.jsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import CharacterSheet from '../../components/CharacterSheet.jsx'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||||
|
|
||||||
|
// A player's character sheet inside the portal. Owner-checked: the endpoint only
|
||||||
|
// returns a sheet for a character on an account linked to the caller.
|
||||||
|
export default function PlayerCharacter() {
|
||||||
|
const { serial } = useParams()
|
||||||
|
const { loading, error, data } = useAsync(() => api.player.shard.char(serial), [serial])
|
||||||
|
const restarting = error && error.status === 503
|
||||||
|
const forbidden = error && error.status === 403
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p style={{ margin: '0 0 18px' }}>
|
||||||
|
<Link to="/player/uo/characters" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
|
||||||
|
← Back to characters
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
{loading && <Loading />}
|
||||||
|
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
||||||
|
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
|
||||||
|
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
|
||||||
|
{!loading && !error && data && <CharacterSheet char={data} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
client/src/routes/player/PlayerCharacters.jsx
Normal file
58
client/src/routes/player/PlayerCharacters.jsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import GameAccounts from '../../components/GameAccounts.jsx'
|
||||||
|
import VendorSales from '../../components/VendorSales.jsx'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import { useAsync } from '../../core.js'
|
||||||
|
|
||||||
|
// The logged-in player's characters. Shows the link prompt when no game account
|
||||||
|
// is linked, otherwise their characters grouped by account (shared component),
|
||||||
|
// plus their own home status and recent vendor sales.
|
||||||
|
|
||||||
|
const DECAY_TONE = {
|
||||||
|
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
|
||||||
|
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
|
||||||
|
}
|
||||||
|
|
||||||
|
// The caller's own houses (home status). Only their own — never anyone else's.
|
||||||
|
function MyHouses() {
|
||||||
|
const { data } = useAsync(() => api.player.shard.houses(), [])
|
||||||
|
if (!data || data.length === 0) return null
|
||||||
|
return (
|
||||||
|
<section style={{ marginTop: 30 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 12 }}>My houses</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{data.map((h) => {
|
||||||
|
const label = h.isIdoc ? 'IDOC' : (h.decay || h.stage)
|
||||||
|
const tone = h.isIdoc ? '#e05a5a' : (DECAY_TONE[label] || 'var(--muted)')
|
||||||
|
return (
|
||||||
|
<div key={h.serial} className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>{h.name || 'An unnamed house'}</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||||
|
{h.region || h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{label && (
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 9px' }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.76rem' }}>
|
||||||
|
Keep an eye on the decay status — refresh a house in game before it reaches IDOC.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerCharacters() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/uo/characters/${serial}`} />
|
||||||
|
<MyHouses />
|
||||||
|
<VendorSales fetchSales={api.player.shard.sales} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
307
client/src/routes/public/Atlas.jsx
Normal file
307
client/src/routes/public/Atlas.jsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||||
|
|
||||||
|
// ── The spawn atlas ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// What the shard CONTAINS, as opposed to what it is doing: which creatures
|
||||||
|
// spawn, where, and which champion altars are configured. There is no live feed
|
||||||
|
// here and no `connected` indicator, deliberately — this is parsed from the
|
||||||
|
// shard's own files and stays complete while the shard is down.
|
||||||
|
//
|
||||||
|
// Facet names come from the shard's data, never from a list in this file. A
|
||||||
|
// shard running custom maps gets its own names in the filter with no code
|
||||||
|
// change (docs/link/v3.md §6.1 R2).
|
||||||
|
|
||||||
|
const PAGE = 50
|
||||||
|
|
||||||
|
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ key: 'creatures', label: 'Creatures' },
|
||||||
|
{ key: 'champions', label: 'Champion altars' },
|
||||||
|
{ key: 'places', label: 'Places' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function Chip({ active, onClick, children }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
padding: '5px 12px',
|
||||||
|
borderRadius: 999,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: active ? 'var(--bg-deep)' : 'var(--muted)',
|
||||||
|
background: active ? 'var(--accent)' : 'transparent',
|
||||||
|
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreatureCard({ creature }) {
|
||||||
|
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/uo/atlas/${encodeURIComponent(creature.slug)}`}
|
||||||
|
className="panel"
|
||||||
|
style={{
|
||||||
|
padding: '13px 15px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 14,
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div
|
||||||
|
className="display"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.98rem',
|
||||||
|
color: 'var(--head)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{creature.name}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||||
|
{facets.length === 0
|
||||||
|
? '—'
|
||||||
|
: facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||||
|
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(creature.total)}</div>
|
||||||
|
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>
|
||||||
|
{num(creature.points)} spawners
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The creature list owns its own paging rather than going through useAsync: a
|
||||||
|
// "load more" appends to what is already on screen, which a hook that resets to
|
||||||
|
// `{ loading: true, data: null }` on every dependency change cannot express.
|
||||||
|
function Creatures({ q, facet }) {
|
||||||
|
const [state, setState] = useState({ loading: true, error: null, items: [], total: 0 })
|
||||||
|
const [more, setMore] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
async (offset) => {
|
||||||
|
const page = await api.atlas.creatures({ q, facet, limit: PAGE, offset })
|
||||||
|
return page
|
||||||
|
},
|
||||||
|
[q, facet],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
setState({ loading: true, error: null, items: [], total: 0 })
|
||||||
|
load(0)
|
||||||
|
.then((page) => {
|
||||||
|
if (alive) setState({ loading: false, error: null, items: page.creatures || [], total: page.total || 0 })
|
||||||
|
})
|
||||||
|
.catch((error) => alive && setState({ loading: false, error, items: [], total: 0 }))
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
}
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
setMore(true)
|
||||||
|
try {
|
||||||
|
const page = await load(state.items.length)
|
||||||
|
setState((s) => ({ ...s, items: [...s.items, ...(page.creatures || [])], total: page.total ?? s.total }))
|
||||||
|
} catch {
|
||||||
|
// A failed "load more" leaves what is already on screen alone; the button
|
||||||
|
// simply stays available to retry.
|
||||||
|
} finally {
|
||||||
|
setMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.loading) return <Loading />
|
||||||
|
if (state.error) return <ErrorState message="Could not load the bestiary right now." />
|
||||||
|
if (state.items.length === 0) {
|
||||||
|
return <EmptyState>Nothing in the atlas matches that.</EmptyState>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
||||||
|
Showing {num(state.items.length)} of {num(state.total)}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{state.items.map((c) => (
|
||||||
|
<CreatureCard key={c.slug} creature={c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{state.items.length < state.total && (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||||
|
<button type="button" className="btn" onClick={loadMore} disabled={more}>
|
||||||
|
{more ? 'Loading…' : 'Load more'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CONFIGURED altar roster — where the altars are and what each summons. The
|
||||||
|
// live board ("it is on level 3 right now") is a different page, /uo/champs,
|
||||||
|
// fed by the sidecar. Both exist; they are not the same thing.
|
||||||
|
function Champions({ facet }) {
|
||||||
|
const { loading, error, data } = useAsync(() => api.atlas.champions(facet), [facet])
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState message="Could not load the champion altars right now." />
|
||||||
|
if (!data || data.length === 0) return <EmptyState>No champion altars are configured.</EmptyState>
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{data.map((champ) => (
|
||||||
|
<div key={champ.slug} className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div className="display" style={{ fontSize: '0.98rem', color: 'var(--head)' }}>
|
||||||
|
{champ.label || champ.name}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||||
|
{champ.facet}
|
||||||
|
{champ.group ? ` · ${champ.group}` : ''} · {champ.x}, {champ.y}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.76rem', color: 'var(--muted)' }}>
|
||||||
|
{champ.randomType ? 'Random champion' : champ.type || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regions and landmarks together: both answer "where is that?", and splitting
|
||||||
|
// them into two tabs would make the visitor guess which list a name lives in.
|
||||||
|
function Places({ q, facet }) {
|
||||||
|
const { loading, error, data } = useAsync(
|
||||||
|
() => Promise.all([api.atlas.regions({ q, facet }), api.atlas.landmarks({ q, facet })]),
|
||||||
|
[q, facet],
|
||||||
|
)
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
if (!data) return []
|
||||||
|
const [regions, landmarks] = data
|
||||||
|
return [
|
||||||
|
...regions.map((r) => ({ key: `r:${r.facet}:${r.name}`, name: r.name, facet: r.facet, detail: r.parent || r.type || 'Region', kind: 'Region' })),
|
||||||
|
...landmarks.map((l) => ({ key: `l:${l.facet}:${l.group || ''}:${l.name}:${l.x}:${l.y}`, name: l.group ? `${l.group} — ${l.name}` : l.name, facet: l.facet, detail: `${l.x}, ${l.y}`, kind: 'Landmark' })),
|
||||||
|
].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState message="Could not load places right now." />
|
||||||
|
if (rows.length === 0) return <EmptyState>No regions or landmarks match that.</EmptyState>
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div key={row.key} className="panel" style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}>
|
||||||
|
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>{row.name}</span>
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.72rem' }}>{row.facet} · {row.detail}</span>
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.66rem', letterSpacing: '0.06em', flex: 'none' }}>{row.kind}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Atlas() {
|
||||||
|
const [tab, setTab] = useState('creatures')
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [facet, setFacet] = useState('')
|
||||||
|
const meta = useAsync(() => api.atlas.meta())
|
||||||
|
|
||||||
|
// Debounced: typing "lizardman" should be one request, not nine.
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setQ(input.trim()), 250)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [input])
|
||||||
|
|
||||||
|
const facets = meta.data?.facets || []
|
||||||
|
const counts = meta.data?.counts || null
|
||||||
|
const imported = meta.data?.importedAt ? new Date(meta.data.importedAt) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Bestiary"
|
||||||
|
title="Spawn atlas"
|
||||||
|
lead="Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* The atlas is only as good as its placement rate, so the page states
|
||||||
|
it rather than implying every spawner resolved to a named place. */}
|
||||||
|
{counts && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
|
||||||
|
{num(counts.creatures)} creatures across {num(counts.points)} spawners
|
||||||
|
{Number.isFinite(counts.unresolvedPoints) && counts.points
|
||||||
|
? ` · ${Math.round(((counts.points - counts.unresolvedPoints) / counts.points) * 100)}% placed to a named region or landmark`
|
||||||
|
: ''}
|
||||||
|
{imported ? ` · parsed ${imported.toLocaleDateString()}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<Chip key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
|
||||||
|
{t.label}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab !== 'champions' && (
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="search"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder={tab === 'creatures' ? 'Search creatures…' : 'Search regions and landmarks…'}
|
||||||
|
style={{ width: '100%', marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{facets.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 18 }}>
|
||||||
|
<Chip active={facet === ''} onClick={() => setFacet('')}>
|
||||||
|
All facets
|
||||||
|
</Chip>
|
||||||
|
{facets.map((f) => (
|
||||||
|
<Chip key={f} active={facet === f} onClick={() => setFacet(f)}>
|
||||||
|
{f}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meta.error && <ErrorState message="Could not load the atlas right now." />}
|
||||||
|
{!meta.error && !meta.loading && !imported && (
|
||||||
|
<EmptyState>The spawn atlas has not been imported yet.</EmptyState>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!meta.error && imported && (
|
||||||
|
<>
|
||||||
|
{tab === 'creatures' && <Creatures q={q} facet={facet} />}
|
||||||
|
{tab === 'champions' && <Champions facet={facet} />}
|
||||||
|
{tab === 'places' && <Places q={q} facet={facet} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
198
client/src/routes/public/AtlasCreature.jsx
Normal file
198
client/src/routes/public/AtlasCreature.jsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||||
|
|
||||||
|
// One creature: where it spawns, and what spawns alongside it.
|
||||||
|
//
|
||||||
|
// `places` is the point of the page — the aggregate that turns 62 raw
|
||||||
|
// coordinates into "Shrines, Isamu-Jima, Yew". The individual spawners are
|
||||||
|
// available underneath for the reader who actually wants a coordinate, but they
|
||||||
|
// are secondary and collapsed by default.
|
||||||
|
|
||||||
|
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||||
|
|
||||||
|
// Spawn delays are stored in seconds. A raw "1200" tells the reader nothing.
|
||||||
|
function delay(min, max) {
|
||||||
|
const fmt = (s) => (s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`)
|
||||||
|
if (!Number.isFinite(min) || !Number.isFinite(max)) return null
|
||||||
|
if (min === max) return fmt(min)
|
||||||
|
return `${fmt(min)}–${fmt(max)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function Panel({ title, right, children }) {
|
||||||
|
return (
|
||||||
|
<section className="panel" style={{ padding: 18 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{right}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Places({ places }) {
|
||||||
|
if (places.length === 0) {
|
||||||
|
return <p className="sans dim" style={{ margin: 0 }}>No placed spawners.</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{places.map((place) => (
|
||||||
|
<div
|
||||||
|
key={`${place.facet}:${place.label}`}
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
padding: '6px 0',
|
||||||
|
borderBottom: '1px solid var(--line)',
|
||||||
|
fontSize: '0.86rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ minWidth: 0, color: 'var(--head)' }}>{place.label}</span>
|
||||||
|
<span className="dim" style={{ flex: 'none' }}>
|
||||||
|
{place.facet} · {num(place.spawners)} spawner{place.spawners === 1 ? '' : 's'} · up to{' '}
|
||||||
|
{num(place.maxAlive)} at once
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Spawners({ spawners, truncated }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
if (spawners.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title="Individual spawners"
|
||||||
|
right={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sans"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: '0.78rem' }}
|
||||||
|
>
|
||||||
|
{open ? 'Hide' : `Show ${num(spawners.length)}`}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{open && (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ textAlign: 'left', color: 'var(--muted)' }}>
|
||||||
|
<th style={{ padding: '4px 8px 8px 0' }}>Place</th>
|
||||||
|
<th style={{ padding: '4px 8px 8px 0' }}>Facet</th>
|
||||||
|
<th style={{ padding: '4px 8px 8px 0' }}>Coords</th>
|
||||||
|
<th style={{ padding: '4px 8px 8px 0' }}>Max</th>
|
||||||
|
<th style={{ padding: '4px 0 8px 0' }}>Respawn</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{spawners.map((s) => (
|
||||||
|
<tr key={s.id} style={{ borderTop: '1px solid var(--line)' }}>
|
||||||
|
<td style={{ padding: '6px 8px 6px 0', color: 'var(--head)' }}>{s.label}</td>
|
||||||
|
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.facet}</td>
|
||||||
|
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.x}, {s.y}</td>
|
||||||
|
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{num(s.maxCount)}</td>
|
||||||
|
<td style={{ padding: '6px 0' }} className="dim">{delay(s.minDelay, s.maxDelay) || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{truncated && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||||
|
Only the largest spawners are listed.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AtlasCreature() {
|
||||||
|
const { slug } = useParams()
|
||||||
|
const { loading, error, data } = useAsync(() => api.atlas.creature(slug), [slug])
|
||||||
|
|
||||||
|
// A 404 here means "no such creature in this atlas", which is a real answer
|
||||||
|
// and not a failure — a visitor following a stale link deserves to be told
|
||||||
|
// that plainly rather than shown a generic error box.
|
||||||
|
const missing = error?.status === 404 || error?.message === 'Not Found'
|
||||||
|
|
||||||
|
const facets = useMemo(
|
||||||
|
() => Object.entries(data?.facets || {}).sort((a, b) => b[1] - a[1]),
|
||||||
|
[data],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<p className="sans" style={{ marginBottom: 8 }}>
|
||||||
|
<Link to="/uo/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
|
||||||
|
← Spawn atlas
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && !missing && <ErrorState message="Could not load that creature right now." />}
|
||||||
|
{missing && <EmptyState>Nothing by that name spawns on this shard.</EmptyState>}
|
||||||
|
|
||||||
|
{!loading && !error && data && (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Bestiary"
|
||||||
|
title={data.name}
|
||||||
|
lead={`Up to ${num(data.total)} alive at once across ${num(data.points)} spawner${data.points === 1 ? '' : 's'}.`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<Panel
|
||||||
|
title="Where it spawns"
|
||||||
|
right={
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||||
|
{facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Places places={data.places || []} />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Spawners spawners={data.spawners || []} truncated={!!data.spawnersTruncated} />
|
||||||
|
|
||||||
|
{data.alsoHere?.length > 0 && (
|
||||||
|
<Panel title="Shares a spawner with">
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{data.alsoHere.map((other) => (
|
||||||
|
<Link
|
||||||
|
key={other.slug}
|
||||||
|
to={`/uo/atlas/${encodeURIComponent(other.slug)}`}
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
padding: '4px 11px',
|
||||||
|
borderRadius: 999,
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{other.name} <span className="dim">×{num(other.shared)}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
199
client/src/routes/public/ChampSpawns.jsx
Normal file
199
client/src/routes/public/ChampSpawns.jsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||||
|
|
||||||
|
// The champion-spawn board. Loaded once from /public/shard/champs, then kept live
|
||||||
|
// by merging champ.update / champ.remove deltas from the public SSE feed. Three
|
||||||
|
// families share the board, split by category into their own sections.
|
||||||
|
const CHAMP_KINDS = new Set(['champ.update', 'champ.remove'])
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{ id: 'champion', title: 'Champion altars', blurb: 'Felucca-style altar spawns.' },
|
||||||
|
{ id: 'mini', title: 'Mini champs', blurb: 'TerMur controllers — they re-arm on their own.' },
|
||||||
|
{ id: 'sea', title: 'Sea bosses', blurb: 'High Seas world bosses, alive only while summoned.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUS_STYLE = {
|
||||||
|
active: { bg: 'rgba(95,185,138,0.16)', fg: '#8fdcae', border: 'rgba(95,185,138,0.45)', label: 'Active' },
|
||||||
|
cooldown: { bg: 'rgba(230,194,106,0.14)', fg: '#e6c26a', border: 'rgba(230,194,106,0.4)', label: 'Cooldown' },
|
||||||
|
dormant: { bg: 'rgba(140,150,165,0.14)', fg: '#aab3c0', border: 'rgba(140,150,165,0.35)', label: 'Dormant' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// A short "in 4m" / "in 2h" for a future ISO timestamp (restartAt / expireAt).
|
||||||
|
function until(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const ms = new Date(iso).getTime() - Date.now()
|
||||||
|
if (!Number.isFinite(ms)) return ''
|
||||||
|
if (ms <= 0) return 'due'
|
||||||
|
const mins = Math.round(ms / 60000)
|
||||||
|
if (mins < 60) return `in ${mins}m`
|
||||||
|
const hrs = Math.round(mins / 60)
|
||||||
|
return `in ${hrs}h`
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
const s = STATUS_STYLE[status] || STATUS_STYLE.dormant
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
flex: 'none',
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
padding: '3px 9px',
|
||||||
|
borderRadius: 999,
|
||||||
|
color: s.fg,
|
||||||
|
background: s.bg,
|
||||||
|
border: `1px solid ${s.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A slim progress bar (kills toward the next level, or a sea boss's hit points).
|
||||||
|
function Meter({ value, max, tone = 'var(--accent)' }) {
|
||||||
|
if (!max) return null
|
||||||
|
const pct = Math.max(0, Math.min(100, (Number(value) / Number(max)) * 100))
|
||||||
|
return (
|
||||||
|
<div style={{ height: 6, borderRadius: 4, background: 'rgba(255,255,255,0.07)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', background: tone, borderRadius: 4 }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category-specific middle line + meter for one spawn.
|
||||||
|
function ChampDetail({ s }) {
|
||||||
|
const line = { display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.8rem', color: 'var(--muted)', marginTop: 8 }
|
||||||
|
if (s.category === 'sea') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="sans" style={line}>
|
||||||
|
<span>{s.boss || s.type}</span>
|
||||||
|
{s.hitsMax != null && <span>{Number(s.hits).toLocaleString()} / {Number(s.hitsMax).toLocaleString()} hp</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6 }}><Meter value={s.hits} max={s.hitsMax} tone="#d9736f" /></div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (s.category === 'mini') {
|
||||||
|
return (
|
||||||
|
<div className="sans" style={line}>
|
||||||
|
<span>Level {s.level ?? 0}{s.maxLevel != null ? ` / ${s.maxLevel}` : ''}</span>
|
||||||
|
<span>{s.status === 'active' ? 'Running' : 'Re-arming'}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// champion
|
||||||
|
let progress = ''
|
||||||
|
if (s.status === 'cooldown') progress = until(s.restartAt) || 'restarting'
|
||||||
|
else if (s.status === 'active') {
|
||||||
|
progress = `${Number(s.kills || 0).toLocaleString()} / ${Number(s.maxKills || 0).toLocaleString()} kills`
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="sans" style={line}>
|
||||||
|
<span>
|
||||||
|
Level {s.level ?? 0}
|
||||||
|
{s.bossUp && s.boss ? ` — ${s.boss}` : ''}
|
||||||
|
</span>
|
||||||
|
<span>{progress}</span>
|
||||||
|
</div>
|
||||||
|
{s.status === 'active' && (
|
||||||
|
<div style={{ marginTop: 6 }}><Meter value={s.kills} max={s.maxKills} /></div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChampCard({ s }) {
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||||
|
<strong className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{s.name || s.type || 'Spawn'}
|
||||||
|
</strong>
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
</div>
|
||||||
|
<ChampDetail s={s} />
|
||||||
|
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.74rem' }}>
|
||||||
|
{s.map || '—'}{s.x != null ? ` (${s.x}, ${s.y})` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChampSpawns() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.champs())
|
||||||
|
const { events, connected } = useShardFeed({ filter: CHAMP_KINDS, max: 60 })
|
||||||
|
|
||||||
|
// Merge the initial snapshot with live deltas: seed a map by serial, then apply
|
||||||
|
// buffered events oldest → newest (the buffer is newest-first) so live wins.
|
||||||
|
const board = useMemo(() => {
|
||||||
|
const map = new Map()
|
||||||
|
for (const s of data || []) if (s && s.serial) map.set(s.serial, s)
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const ev = events[i]
|
||||||
|
if (!ev || !ev.serial) continue
|
||||||
|
if (ev.kind === 'champ.update') map.set(ev.serial, ev)
|
||||||
|
else if (ev.kind === 'champ.remove') map.delete(ev.serial)
|
||||||
|
}
|
||||||
|
return [...map.values()]
|
||||||
|
}, [data, events])
|
||||||
|
|
||||||
|
const byCategory = (id) =>
|
||||||
|
board.filter((s) => (s.category || 'champion') === id).sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||||
|
|
||||||
|
const activeCount = board.filter((s) => s.status === 'active').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<PageHeader eyebrow="Live" title="Champion spawns" lead="Every altar, mini-champ and sea boss across the shard, updating in real time." />
|
||||||
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the champion board right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{board.length === 0 ? (
|
||||||
|
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0 }}>No champion spawns are being tracked right now.</p>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', marginTop: -12, marginBottom: 24 }}>
|
||||||
|
{activeCount} active · {board.length} tracked
|
||||||
|
</p>
|
||||||
|
{SECTIONS.map((sec) => {
|
||||||
|
const rows = byCategory(sec.id)
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return (
|
||||||
|
<section key={sec.id} style={{ marginBottom: 28 }}>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.1rem', color: 'var(--head)' }}>{sec.title}</h2>
|
||||||
|
<p className="sans dim" style={{ margin: '2px 0 0', fontSize: '0.8rem' }}>{sec.blurb}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid-2" style={{ gap: 12 }}>
|
||||||
|
{rows.map((s) => <ChampCard key={s.serial} s={s} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
184
client/src/routes/public/Governors.jsx
Normal file
184
client/src/routes/public/Governors.jsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import { crestFor } from '../../data/cityCrests.js'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||||
|
|
||||||
|
// The town-governor board (City Loyalty). Loaded from /public/shard/governors,
|
||||||
|
// kept live by merging city.update deltas by city. Empty on shards without the
|
||||||
|
// City Loyalty system. Each city card links to its term history (look-back).
|
||||||
|
const GOV_KINDS = new Set(['city.update'])
|
||||||
|
|
||||||
|
const PHASE = {
|
||||||
|
none: null,
|
||||||
|
nominate: { label: 'Nominations open', color: '#7f8fd0' },
|
||||||
|
vote: { label: 'Voting', color: '#e6c26a' },
|
||||||
|
pending: { label: 'Result pending', color: '#c9a24b' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// A short "in 3d" / "in 5h" for a future ISO timestamp (autoPickAt).
|
||||||
|
function until(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const ms = new Date(iso).getTime() - Date.now()
|
||||||
|
if (!Number.isFinite(ms) || ms <= 0) return ''
|
||||||
|
const mins = Math.round(ms / 60000)
|
||||||
|
if (mins < 60) return `in ${mins}m`
|
||||||
|
const hrs = Math.round(mins / 60)
|
||||||
|
if (hrs < 24) return `in ${hrs}h`
|
||||||
|
return `in ${Math.round(hrs / 24)}d`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(ms) {
|
||||||
|
if (ms == null) return ''
|
||||||
|
return new Date(Number(ms)).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function CityCrest({ city, size = 44 }) {
|
||||||
|
const c = crestFor(city)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
flex: 'none', width: size, height: size, borderRadius: '50%',
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: size * 0.5, background: 'rgba(255,255,255,0.04)',
|
||||||
|
border: `2px solid ${c.color}`, boxShadow: `0 0 10px ${c.color}22`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.sigil}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsible term history for one city, fetched on demand from the ledger.
|
||||||
|
function TermHistory({ city }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const { loading, error, data } = useAsync(
|
||||||
|
() => (open ? api.shard.governorHistory(city, 25) : Promise.resolve(null)),
|
||||||
|
[open, city],
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sans"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0, fontSize: '0.76rem' }}
|
||||||
|
>
|
||||||
|
{open ? 'Hide past governors' : 'Past governors →'}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{loading && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Loading…</p>}
|
||||||
|
{error && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Could not load history.</p>}
|
||||||
|
{data && data.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>No recorded terms yet.</p>
|
||||||
|
)}
|
||||||
|
{data && data.length > 0 && (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||||
|
{data.map((t) => (
|
||||||
|
<li key={`${t.startedAt}-${t.governor?.name ?? 'vacant'}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.8rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{t.governor?.name || 'Vacant'}
|
||||||
|
</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.72rem' }}>
|
||||||
|
{fmtDate(t.startedAt)}{t.endedAt ? ` – ${fmtDate(t.endedAt)}` : ' – present'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CityCard({ c }) {
|
||||||
|
const phase = PHASE[c.electionPhase] || null
|
||||||
|
const gov = c.governor
|
||||||
|
const candidatePlural = c.candidates === 1 ? '' : 's'
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: 18 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<CityCrest city={c.city} />
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||||
|
<strong className="display" style={{ fontSize: '1.05rem', color: 'var(--head)' }}>
|
||||||
|
{crestFor(c.city).label || c.city}
|
||||||
|
</strong>
|
||||||
|
{phase && (
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.66rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: phase.color, border: `1px solid ${phase.color}66`, borderRadius: 999, padding: '2px 8px' }}>
|
||||||
|
{phase.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sans" style={{ marginTop: 3, fontSize: '0.9rem', color: gov ? 'var(--ink)' : 'var(--muted)' }}>
|
||||||
|
{gov ? (
|
||||||
|
<>Governor <strong style={{ color: 'var(--head)' }}>{gov.name}</strong></>
|
||||||
|
) : (
|
||||||
|
'Seat vacant'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{c.electionPhase && c.electionPhase !== 'none' && (
|
||||||
|
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.78rem' }}>
|
||||||
|
{c.candidates ? `${c.candidates} candidate${candidatePlural}` : 'No candidates yet'}
|
||||||
|
{c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TermHistory city={c.city} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Governors() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.governors())
|
||||||
|
const { events, connected } = useShardFeed({ filter: GOV_KINDS, max: 30 })
|
||||||
|
|
||||||
|
const board = useMemo(() => {
|
||||||
|
const map = new Map()
|
||||||
|
for (const c of data || []) if (c && c.city) map.set(c.city, c)
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const ev = events[i]
|
||||||
|
if (ev.kind === 'city.update' && ev.city) map.set(ev.city, ev)
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => (a.city || '').localeCompare(b.city || ''))
|
||||||
|
}, [data, events])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<PageHeader eyebrow="Live" title="Governors of Britannia" lead="Who rules each city, and where the next election stands." />
|
||||||
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the governor board right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{board.length === 0 ? (
|
||||||
|
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0 }}>
|
||||||
|
City Loyalty governance is not enabled on this shard.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<div className="grid-2" style={{ gap: 12 }}>
|
||||||
|
{board.map((c) => <CityCard key={c.city} c={c} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
client/src/routes/public/Guilds.jsx
Normal file
166
client/src/routes/public/Guilds.jsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||||
|
|
||||||
|
// The guild board. Loaded once from /public/shard/guilds, then kept live by
|
||||||
|
// merging guild.update / guild.remove deltas; guild.join drives a small "recently
|
||||||
|
// joined" strip on top of the board.
|
||||||
|
const GUILD_KINDS = new Set(['guild.update', 'guild.remove', 'guild.join'])
|
||||||
|
|
||||||
|
function Leader({ leader }) {
|
||||||
|
if (!leader || !leader.name) return <span className="dim">—</span>
|
||||||
|
return <span>{leader.name}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuildRow({ g }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="panel"
|
||||||
|
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
|
||||||
|
{g.abbr && (
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
flex: 'none',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
border: '1px solid rgba(201,162,75,0.4)',
|
||||||
|
borderRadius: 5,
|
||||||
|
padding: '1px 6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g.abbr}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<strong
|
||||||
|
className="display"
|
||||||
|
style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{g.name || 'A guild'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
{g.alliance && (
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||||
|
{g.alliance}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sans" style={{ flex: 'none', textAlign: 'right', fontSize: '0.84rem', color: 'var(--ink)' }}>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: '#7fd0a4' }}>{g.online ?? 0}</span>
|
||||||
|
<span className="dim"> / {g.members ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||||
|
<Leader leader={g.leader} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Guilds() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.guilds())
|
||||||
|
const { events, connected } = useShardFeed({ filter: GUILD_KINDS, max: 60 })
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
|
||||||
|
// Merge snapshot + live deltas by guild id (apply oldest → newest so live wins).
|
||||||
|
const board = useMemo(() => {
|
||||||
|
const map = new Map()
|
||||||
|
for (const g of data || []) if (g && g.id != null) map.set(g.id, g)
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const ev = events[i]
|
||||||
|
if (ev.kind === 'guild.update' && ev.id != null) map.set(ev.id, ev)
|
||||||
|
else if (ev.kind === 'guild.remove' && ev.id != null) map.delete(ev.id)
|
||||||
|
}
|
||||||
|
return [...map.values()]
|
||||||
|
}, [data, events])
|
||||||
|
|
||||||
|
// Recent joins strip (newest first, deduped, capped).
|
||||||
|
const joins = useMemo(
|
||||||
|
() => events.filter((e) => e.kind === 'guild.join' && e.who).slice(0, 6),
|
||||||
|
[events],
|
||||||
|
)
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const needle = q.trim().toLowerCase()
|
||||||
|
const rows = needle
|
||||||
|
? board.filter((g) =>
|
||||||
|
[g.name, g.abbr, g.alliance].some((v) => v && v.toLowerCase().includes(needle)),
|
||||||
|
)
|
||||||
|
: board
|
||||||
|
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||||
|
}, [board, q])
|
||||||
|
|
||||||
|
const totalMembers = board.reduce((n, g) => n + (Number(g.members) || 0), 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<PageHeader eyebrow="Live" title="Guilds" lead="Every guild on the shard — rosters, alliances and who's online, updating in real time." />
|
||||||
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the guild board right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{board.length === 0 ? (
|
||||||
|
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0 }}>No guilds are being tracked right now.</p>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{joins.length > 0 && (
|
||||||
|
<section className="panel" style={{ padding: '12px 16px', marginBottom: 18 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.66rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 8 }}>
|
||||||
|
Recently joined
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||||
|
{joins.map((j) => (
|
||||||
|
<div key={j._id} className="sans" style={{ fontSize: '0.84rem', color: 'var(--ink)' }}>
|
||||||
|
<strong style={{ color: 'var(--head)' }}>{j.who.name}</strong>
|
||||||
|
<span className="dim"> joined </span>
|
||||||
|
{j.abbr ? `[${j.abbr}] ` : ''}{j.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
|
||||||
|
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', margin: 0 }}>
|
||||||
|
{board.length} guilds · {totalMembers.toLocaleString()} members
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
className="input sans"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Search guilds…"
|
||||||
|
style={{ flex: 'none', width: 190, maxWidth: '50%', fontSize: '0.84rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{filtered.map((g) => <GuildRow key={g.id} g={g} />)}
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No guilds match “{q}”.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
88
client/src/routes/public/Houses.jsx
Normal file
88
client/src/routes/public/Houses.jsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||||
|
|
||||||
|
// PUBLIC houses board: only houses in danger (IDOC), shown by location. Owner,
|
||||||
|
// price, decay detail and the full registry are staff-only (admin Houses view).
|
||||||
|
// Loaded from /public/shard/houses (IDOC-only), kept live by house.decay: a
|
||||||
|
// house entering IDOC appears, one leaving it drops off.
|
||||||
|
const HOUSE_KINDS = new Set(['house.decay'])
|
||||||
|
|
||||||
|
function HouseRow({ h }) {
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#e05a5a', boxShadow: '0 0 8px rgba(224,90,90,0.7)' }}
|
||||||
|
/>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{h.region || 'The wilderness'}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||||
|
{h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', letterSpacing: '0.06em', color: '#e05a5a', border: '1px solid #e05a5a66', borderRadius: 999, padding: '2px 9px' }}>
|
||||||
|
IDOC
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Houses() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.houses())
|
||||||
|
const { events, connected } = useShardFeed({ filter: HOUSE_KINDS, max: 60 })
|
||||||
|
|
||||||
|
// Merge the IDOC snapshot with live house.decay deltas by serial: entering IDOC
|
||||||
|
// adds/updates the row; anything else (refreshed, collapsed) drops it.
|
||||||
|
const board = useMemo(() => {
|
||||||
|
const map = new Map()
|
||||||
|
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const ev = events[i]
|
||||||
|
if (ev.kind !== 'house.decay' || !ev.serial) continue
|
||||||
|
if (String(ev.to).toUpperCase() === 'IDOC') {
|
||||||
|
map.set(ev.serial, { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y, z: ev.z, isIdoc: true })
|
||||||
|
} else {
|
||||||
|
map.delete(ev.serial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => (a.region || '').localeCompare(b.region || ''))
|
||||||
|
}, [data, events])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<PageHeader eyebrow="Live" title="Houses in danger" lead="Homes that have fallen into IDOC — where to find them before they collapse." />
|
||||||
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the houses board right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
board.length === 0 ? (
|
||||||
|
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0 }}>No houses are collapsing right now.</p>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="sans" style={{ color: '#e0928a', fontSize: '0.8rem', marginTop: -12, marginBottom: 20 }}>
|
||||||
|
{board.length} in danger
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{board.map((h) => <HouseRow key={h.serial} h={h} />)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
236
client/src/routes/public/Leaderboards.jsx
Normal file
236
client/src/routes/public/Leaderboards.jsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync, useSite } from '../../core.js'
|
||||||
|
|
||||||
|
// Points / loyalty leaderboards (Protocol 3.0 §7). The shard carries ~25 separate
|
||||||
|
// point currencies — Queen's Loyalty, Void Pool, Clean Up Britannia, the nine city
|
||||||
|
// loyalties, the Doom/Khaldun/Kotl treasure systems — every one of them a standing
|
||||||
|
// players build over months, and none of them visible anywhere but an in-game gump
|
||||||
|
// until now.
|
||||||
|
//
|
||||||
|
// Loaded from /public/shard/points, then kept current from the live feed. Unlike
|
||||||
|
// the ruleset (one frame = the whole thing), a points.board frame describes ONE
|
||||||
|
// system, so live frames are merged over the fetched set by system key rather than
|
||||||
|
// replacing it.
|
||||||
|
const POINTS_KINDS = new Set(['points.board'])
|
||||||
|
|
||||||
|
// A board's display name may arrive as a literal (`nameString`), a cliloc id
|
||||||
|
// (`nameNumber`), or both — Name is a ServUO TextDefinition. We have no cliloc
|
||||||
|
// table on the site, so a cliloc-only board falls back to humanising its own
|
||||||
|
// PointsType key, which is already close to a display name ("CleanUpBritannia" →
|
||||||
|
// "Clean Up Britannia"). Better than showing a bare number.
|
||||||
|
const humanise = (key) =>
|
||||||
|
String(key || '')
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/^./, (c) => c.toUpperCase())
|
||||||
|
|
||||||
|
const boardTitle = (b) => b.nameString || humanise(b.system)
|
||||||
|
|
||||||
|
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||||
|
|
||||||
|
// Merge live frames over the fetched boards. Newest frame per system wins; a
|
||||||
|
// system that has never appeared in either is simply absent.
|
||||||
|
function mergeBoards(fetched, events) {
|
||||||
|
const bySystem = new Map()
|
||||||
|
for (const b of Array.isArray(fetched) ? fetched : []) {
|
||||||
|
if (b && b.system) bySystem.set(b.system, b)
|
||||||
|
}
|
||||||
|
// Events arrive newest-first, so walk backwards and let the newest land last.
|
||||||
|
for (let i = events.length - 1; i >= 0; i--) {
|
||||||
|
const ev = events[i]
|
||||||
|
if (ev && ev.system) bySystem.set(ev.system, ev)
|
||||||
|
}
|
||||||
|
return [...bySystem.values()].sort((a, b) => boardTitle(a).localeCompare(boardTitle(b)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Medal({ rank }) {
|
||||||
|
// Gold / silver / bronze for the podium, plain for the rest.
|
||||||
|
const tone = rank === 1 ? '#c9a24b' : rank === 2 ? '#b6bcc6' : rank === 3 ? '#b3805a' : 'var(--muted)'
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="display"
|
||||||
|
style={{
|
||||||
|
flex: 'none', width: 26, textAlign: 'right', color: tone,
|
||||||
|
fontSize: rank <= 3 ? '1rem' : '0.86rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rank}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One ranked player. `name` is absent rather than empty when an admin has gated
|
||||||
|
// the leaderboards `name` field above this viewer's rung — the row still renders,
|
||||||
|
// because the standing itself is the point.
|
||||||
|
function Entry({ entry, best }) {
|
||||||
|
const pct = best > 0 ? Math.max(2, Math.round((entry.points / best) * 100)) : 0
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||||||
|
<Medal rank={entry.rank} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
color: entry.name ? 'var(--ink)' : 'var(--muted)',
|
||||||
|
fontSize: '0.86rem', fontStyle: entry.name ? 'normal' : 'italic',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.name || 'Name hidden'}
|
||||||
|
</span>
|
||||||
|
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
|
||||||
|
{num(entry.points)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden', marginTop: 3 }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Board({ board }) {
|
||||||
|
const { siteTitle } = useSite()
|
||||||
|
const top = Array.isArray(board.top) ? board.top : []
|
||||||
|
// Bars are relative to the board leader, not to maxPoints: most systems have no
|
||||||
|
// cap (maxPoints 0), and where there is one the leader is often nowhere near it,
|
||||||
|
// which would render every bar as a stub.
|
||||||
|
const best = top.reduce((m, e) => Math.max(m, e.points || 0), 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel" style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10 }}>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.02rem', color: 'var(--head)' }}>
|
||||||
|
{boardTitle(board)}
|
||||||
|
</h2>
|
||||||
|
{Number.isFinite(board.players) && (
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.72rem', flex: 'none' }}>
|
||||||
|
{num(board.players)} ranked
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{top.length === 0 ? (
|
||||||
|
// A board nobody has scored on still gets a row, so the page reads as a set
|
||||||
|
// of standings waiting to be filled rather than a stack of blanks. It is
|
||||||
|
// deliberately NOT shaped like an Entry — no medal, no bar, an em dash where
|
||||||
|
// a score goes — because a placeholder that looked like a real standing would
|
||||||
|
// be a fabricated one. The first real entry replaces it.
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10, padding: '6px 0' }}>
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
color: 'var(--muted)', fontSize: '0.86rem',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{siteTitle}
|
||||||
|
</span>
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.82rem', flex: 'none' }}>—</span>
|
||||||
|
</div>
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
|
||||||
|
Nobody has earned points here yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{top.map((entry) => (
|
||||||
|
<Entry key={`${board.system}-${entry.rank}-${entry.serial}`} entry={entry} best={best} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Number.isFinite(board.maxPoints) && board.maxPoints > 0 && (
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||||
|
Maximum {num(board.maxPoints)} points
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Leaderboards() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.points())
|
||||||
|
// Buffer generously: a single sweep can emit a frame for every system at once,
|
||||||
|
// and a board dropped from the buffer would silently revert to its fetched copy.
|
||||||
|
const { events, connected } = useShardFeed({ filter: POINTS_KINDS, max: 60 })
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
const boards = useMemo(() => mergeBoards(data, events), [data, events])
|
||||||
|
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return boards
|
||||||
|
// Match the board name, the raw system key, or any ranked player on it — the
|
||||||
|
// last is what makes the filter useful ("where do I appear?").
|
||||||
|
return boards.filter(
|
||||||
|
(b) =>
|
||||||
|
boardTitle(b).toLowerCase().includes(q) ||
|
||||||
|
String(b.system).toLowerCase().includes(q) ||
|
||||||
|
(b.top || []).some((e) => e.name && e.name.toLowerCase().includes(q)),
|
||||||
|
)
|
||||||
|
}, [boards, query])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell page-body">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Live"
|
||||||
|
title="Leaderboards"
|
||||||
|
lead="Loyalty and points standings, straight from the shard — every currency the server tracks, updated as players climb."
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem',
|
||||||
|
color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the leaderboards right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && boards.length === 0 && (
|
||||||
|
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0 }}>
|
||||||
|
The shard has not published any leaderboards yet.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && boards.length > 0 && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Filter by board or player name…"
|
||||||
|
aria-label="Filter leaderboards"
|
||||||
|
style={{ maxWidth: 340, marginBottom: 14 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{shown.length === 0 ? (
|
||||||
|
<p className="sans dim">No board or ranked player matches “{query}”.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid-2" style={{ gap: 12, alignItems: 'start' }}>
|
||||||
|
{shown.map((board) => (
|
||||||
|
<Board key={board.system} board={board} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
322
client/src/routes/public/Market.jsx
Normal file
322
client/src/routes/public/Market.jsx
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||||
|
|
||||||
|
// ── The player-vendor marketplace ───────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// What every player vendor on the shard is selling, for how much, and where it
|
||||||
|
// is standing — the same index the in-game Vendor Search gump reads, honouring
|
||||||
|
// the same per-vendor opt-out, reachable without logging in to the game.
|
||||||
|
//
|
||||||
|
// Three things this page must be honest about, all of them consequences of how
|
||||||
|
// the data is gathered (docs/link/v3.md §8):
|
||||||
|
//
|
||||||
|
// • **The prices are not live.** The shard sweeps vendors round-robin, so a
|
||||||
|
// shop can be a full cycle behind. The banner says how far, from `staleAt`.
|
||||||
|
// A page that implied live prices would send people across the world to a
|
||||||
|
// vendor whose item sold twenty minutes ago.
|
||||||
|
// • **A shop can be truncated.** A commodity reseller with thousands of stacks
|
||||||
|
// publishes only the first N, and saying so beats presenting a partial shop
|
||||||
|
// as complete.
|
||||||
|
// • **An item may have no name.** On a shard whose operator has not converted
|
||||||
|
// a cliloc table, `displayName` is null and the honest render is the item id
|
||||||
|
// — not an invented name.
|
||||||
|
//
|
||||||
|
// There is deliberately no live feed here. The market feature's SSE stream ships
|
||||||
|
// disabled: a firehose of whole vendor inventories would be the site's single
|
||||||
|
// biggest bandwidth consumer, and nothing on this page needs it.
|
||||||
|
|
||||||
|
const PAGE = 50
|
||||||
|
|
||||||
|
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
|
||||||
|
|
||||||
|
const SORTS = [
|
||||||
|
{ key: 'price_asc', label: 'Cheapest' },
|
||||||
|
{ key: 'price_desc', label: 'Priciest' },
|
||||||
|
{ key: 'recent', label: 'Recently seen' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// How old the index may be, in words. `staleAt` is the OLDEST vendor row, so
|
||||||
|
// this is a worst case rather than an average — which is the number worth
|
||||||
|
// showing, because the one stale shop is the one that wastes a trip.
|
||||||
|
function staleness(staleAt) {
|
||||||
|
if (!staleAt) return null
|
||||||
|
const ms = Date.now() - new Date(staleAt).getTime()
|
||||||
|
if (!Number.isFinite(ms) || ms < 0) return null
|
||||||
|
const mins = Math.round(ms / 60000)
|
||||||
|
if (mins < 1) return 'just now'
|
||||||
|
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`
|
||||||
|
const hours = Math.round(mins / 60)
|
||||||
|
if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
|
||||||
|
return `${Math.round(hours / 24)} days ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The item's name, or an honest statement that we do not have one. Never a
|
||||||
|
// fabricated label — "Item 3922" would be indistinguishable from a real name.
|
||||||
|
const itemLabel = (l) => l.displayName || l.name || `id ${l.itemId}`
|
||||||
|
|
||||||
|
function Chip({ active, onClick, children }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
padding: '5px 12px',
|
||||||
|
borderRadius: 999,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: active ? 'var(--bg-deep)' : 'var(--muted)',
|
||||||
|
background: active ? 'var(--accent)' : 'transparent',
|
||||||
|
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListingRow({ listing }) {
|
||||||
|
const v = listing.vendor || {}
|
||||||
|
// `location` is one field the admin can gate away wholesale, so everything
|
||||||
|
// that reads from it has to tolerate its absence rather than assuming a map.
|
||||||
|
const loc = v.location || null
|
||||||
|
const where = loc ? [loc.region, loc.map].filter(Boolean).join(', ') : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div
|
||||||
|
className="display"
|
||||||
|
style={{ fontSize: '0.98rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{listing.amount > 1 ? `${num(listing.amount)} × ` : ''}
|
||||||
|
{itemLabel(listing)}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||||
|
{v.serial ? (
|
||||||
|
<Link to={`/uo/market/vendors/${encodeURIComponent(v.serial)}`} style={{ color: 'inherit' }}>
|
||||||
|
{v.shopName || 'an unnamed shop'}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
v.shopName || 'an unnamed shop'
|
||||||
|
)}
|
||||||
|
{v.ownerName ? ` · ${v.ownerName}` : ''}
|
||||||
|
{where ? ` · ${where}` : ''}
|
||||||
|
{/* Priced by the container it sits in, exactly as the in-game search
|
||||||
|
reports it — the price buys the whole container, not this item. */}
|
||||||
|
{listing.child ? ' · sold with its container' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||||
|
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(listing.price)}</div>
|
||||||
|
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>gold</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Market() {
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [map, setMap] = useState('')
|
||||||
|
const [region, setRegion] = useState('')
|
||||||
|
const [sort, setSort] = useState('price_asc')
|
||||||
|
const [minPrice, setMinPrice] = useState('')
|
||||||
|
const [maxPrice, setMaxPrice] = useState('')
|
||||||
|
// Applied prices are separate from the typed ones so the search fires when the
|
||||||
|
// user is done, not on every digit of "250000".
|
||||||
|
const [prices, setPrices] = useState({ min: '', max: '' })
|
||||||
|
|
||||||
|
const [state, setState] = useState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
|
||||||
|
const [more, setMore] = useState(false)
|
||||||
|
|
||||||
|
const meta = useAsync(() => api.shard.marketMeta())
|
||||||
|
|
||||||
|
// Debounced: typing "vanquishing" should be one request, not eleven — and the
|
||||||
|
// endpoint is rate-limited, so an undebounced box would 429 a fast typist.
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setQ(input.trim()), 300)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [input])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setPrices({ min: minPrice, max: maxPrice }), 500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [minPrice, maxPrice])
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
(offset) =>
|
||||||
|
api.shard.market({
|
||||||
|
q,
|
||||||
|
map,
|
||||||
|
region,
|
||||||
|
sort,
|
||||||
|
minPrice: prices.min,
|
||||||
|
maxPrice: prices.max,
|
||||||
|
limit: PAGE,
|
||||||
|
offset,
|
||||||
|
}),
|
||||||
|
[q, map, region, sort, prices],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
setState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
|
||||||
|
load(0)
|
||||||
|
.then((page) => {
|
||||||
|
if (!alive) return
|
||||||
|
setState({
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
listings: page.listings || [],
|
||||||
|
total: page.total || 0,
|
||||||
|
staleAt: page.staleAt || null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => alive && setState({ loading: false, error, listings: [], total: 0, staleAt: null }))
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
}
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
setMore(true)
|
||||||
|
try {
|
||||||
|
const page = await load(state.listings.length)
|
||||||
|
setState((s) => ({
|
||||||
|
...s,
|
||||||
|
listings: [...s.listings, ...(page.listings || [])],
|
||||||
|
total: page.total ?? s.total,
|
||||||
|
staleAt: page.staleAt ?? s.staleAt,
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
// A failed "load more" leaves what is on screen alone; the button stays
|
||||||
|
// available to retry.
|
||||||
|
} finally {
|
||||||
|
setMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const maps = meta.data?.maps || []
|
||||||
|
const regions = meta.data?.regions || []
|
||||||
|
const age = staleness(state.staleAt)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Marketplace"
|
||||||
|
title="Player vendors"
|
||||||
|
lead="Every shop on the shard, searchable from here — the same index the in-game vendor search reads, and it honours the same per-vendor opt-out."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Not decoration. The sweep is round-robin, so the index is inherently
|
||||||
|
up to one full cycle old and the page has to say so. */}
|
||||||
|
{age && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
|
||||||
|
Prices last refreshed {age}
|
||||||
|
{meta.data?.vendors ? ` · ${num(meta.data.vendors)} shops` : ''}
|
||||||
|
{meta.data?.items ? ` · ${num(meta.data.items)} listings` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="search"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="Search listings…"
|
||||||
|
style={{ width: '100%', marginBottom: 10 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={minPrice}
|
||||||
|
onChange={(e) => setMinPrice(e.target.value)}
|
||||||
|
placeholder="Min price"
|
||||||
|
style={{ maxWidth: 140 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={maxPrice}
|
||||||
|
onChange={(e) => setMaxPrice(e.target.value)}
|
||||||
|
placeholder="Max price"
|
||||||
|
style={{ maxWidth: 140 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||||
|
{SORTS.map((s) => (
|
||||||
|
<Chip key={s.key} active={sort === s.key} onClick={() => setSort(s.key)}>
|
||||||
|
{s.label}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Facet and region names come from the shard's own data, never a list in
|
||||||
|
this file — a shard running custom maps gets its own names here with
|
||||||
|
no code change (docs/link/v3.md §6.1 R2). */}
|
||||||
|
{maps.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||||
|
<Chip active={map === ''} onClick={() => setMap('')}>All facets</Chip>
|
||||||
|
{maps.map((m) => (
|
||||||
|
<Chip key={m} active={map === m} onClick={() => setMap(m)}>{m}</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{regions.length > 0 && (
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={region}
|
||||||
|
onChange={(e) => setRegion(e.target.value)}
|
||||||
|
style={{ width: '100%', marginBottom: 18 }}
|
||||||
|
>
|
||||||
|
<option value="">Anywhere</option>
|
||||||
|
{regions.map((r) => (
|
||||||
|
<option key={r} value={r}>{r}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.loading && <Loading />}
|
||||||
|
{state.error && <ErrorState message="Could not load the marketplace right now." />}
|
||||||
|
|
||||||
|
{!state.loading && !state.error && state.listings.length === 0 && (
|
||||||
|
<EmptyState>
|
||||||
|
{meta.data?.vendors
|
||||||
|
? 'Nothing on the shard matches that.'
|
||||||
|
: 'No player vendors have been indexed yet.'}
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!state.loading && !state.error && state.listings.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
||||||
|
Showing {num(state.listings.length)} of {num(state.total)}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{state.listings.map((l) => (
|
||||||
|
<ListingRow key={`${l.vendor?.serial}:${l.serial}`} listing={l} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{state.listings.length < state.total && (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||||
|
<button type="button" className="btn" onClick={loadMore} disabled={more}>
|
||||||
|
{more ? 'Loading…' : 'Load more'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
99
client/src/routes/public/MarketVendor.jsx
Normal file
99
client/src/routes/public/MarketVendor.jsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||||
|
|
||||||
|
// One player vendor: where to find it and everything it is selling.
|
||||||
|
//
|
||||||
|
// The page a search result points at. Two states it has to render honestly and
|
||||||
|
// which the search list cannot (docs/link/v3.md §8):
|
||||||
|
//
|
||||||
|
// • `truncated` — the shop holds more than the shard publishes per frame. A
|
||||||
|
// commodity reseller with thousands of stacks is a real thing, and showing
|
||||||
|
// 250 of 3,104 as if it were the whole shop would be a lie about the shard.
|
||||||
|
// • a gated `location` — an admin may put vendor whereabouts behind a rung, in
|
||||||
|
// which case there is nothing to render and the page says so rather than
|
||||||
|
// showing an empty coordinate.
|
||||||
|
|
||||||
|
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
|
||||||
|
|
||||||
|
const itemLabel = (i) => i.displayName || i.name || `id ${i.itemId}`
|
||||||
|
|
||||||
|
export default function MarketVendor() {
|
||||||
|
const { serial } = useParams()
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.marketVendor(serial), [serial])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body"><Loading /></div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<ErrorState message="That shop is not in the index — it may have been dismissed or hidden." />
|
||||||
|
<p style={{ marginTop: 16 }}>
|
||||||
|
<Link to="/uo/market" className="sans">← Back to the marketplace</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loc = data.location || null
|
||||||
|
const items = data.items || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow={data.ownerName ? `Run by ${data.ownerName}` : 'Player vendor'}
|
||||||
|
title={data.shopName || 'An unnamed shop'}
|
||||||
|
lead={
|
||||||
|
loc
|
||||||
|
? [loc.house, loc.region, loc.map].filter(Boolean).join(' · ') +
|
||||||
|
(Number.isFinite(loc.x) ? ` — ${loc.x}, ${loc.y}` : '')
|
||||||
|
: 'This shard does not publish vendor locations.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '-12px 0 18px' }}>
|
||||||
|
{data.truncated
|
||||||
|
? `Showing ${num(data.count)} of ${num(data.total)} listings — this shop holds more than the shard publishes.`
|
||||||
|
: `${num(data.total)} listing${data.total === 1 ? '' : 's'}`}
|
||||||
|
{data.updatedAt ? ` · last seen ${new Date(data.updatedAt).toLocaleString()}` : ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<EmptyState>This shop has nothing priced for sale.</EmptyState>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{items.map((i) => (
|
||||||
|
<div
|
||||||
|
key={i.serial}
|
||||||
|
className="panel"
|
||||||
|
style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}
|
||||||
|
>
|
||||||
|
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>
|
||||||
|
{i.amount > 1 ? `${num(i.amount)} × ` : ''}
|
||||||
|
{itemLabel(i)}
|
||||||
|
{i.child ? <span className="dim"> · sold with its container</span> : null}
|
||||||
|
</span>
|
||||||
|
<span className="sans" style={{ flex: 'none', color: 'var(--head)', fontSize: '0.88rem' }}>
|
||||||
|
{num(i.price)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p style={{ marginTop: 20 }}>
|
||||||
|
<Link to="/uo/market" className="sans">← Back to the marketplace</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
338
client/src/routes/public/Rules.jsx
Normal file
338
client/src/routes/public/Rules.jsx
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||||
|
|
||||||
|
// The shard ruleset. Loaded from /public/shard/ruleset, replaced wholesale by any
|
||||||
|
// world.ruleset frame on the live feed (the shard re-emits the entire ruleset, so
|
||||||
|
// there is nothing to merge — latest wins).
|
||||||
|
//
|
||||||
|
// Everything on this page is published BY THE SHARD from its own Config/*.cfg, so
|
||||||
|
// it cannot drift the way a hand-written rules page does. That is the whole point
|
||||||
|
// of the feature, and the page says so.
|
||||||
|
const RULESET_KINDS = new Set(['world.ruleset'])
|
||||||
|
|
||||||
|
// Skill and stat caps arrive in tenths, the way ServUO stores them: 1000 is 100.0
|
||||||
|
// skill. Showing the raw number would be actively misleading.
|
||||||
|
const tenths = (v) => (Number.isFinite(v) ? (v / 10).toFixed(1) : null)
|
||||||
|
|
||||||
|
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : null)
|
||||||
|
|
||||||
|
const pct = (v) => (Number.isFinite(v) ? `${v}%` : null)
|
||||||
|
|
||||||
|
// The systems block is a flat bag of booleans; these are their display names, and
|
||||||
|
// the order here is the order they render. A key the shard sends that we don't
|
||||||
|
// know about still renders, humanised, rather than being silently dropped — a new
|
||||||
|
// plugin must not go invisible against an older client.
|
||||||
|
const SYSTEM_LABELS = {
|
||||||
|
cityLoyalty: 'City Loyalty (governors)',
|
||||||
|
vvv: 'Vice vs Virtue',
|
||||||
|
factions: 'Factions',
|
||||||
|
siege: 'Siege ruleset',
|
||||||
|
chat: 'In-game chat',
|
||||||
|
store: 'Ultima Store',
|
||||||
|
dailyRares: 'Daily rares',
|
||||||
|
honesty: 'Honesty virtue',
|
||||||
|
shadowguard: 'Shadowguard',
|
||||||
|
treasureMaps: 'Treasure maps',
|
||||||
|
vetRewards: 'Veteran rewards',
|
||||||
|
testCenter: 'Test Center',
|
||||||
|
}
|
||||||
|
|
||||||
|
const humanise = (key) =>
|
||||||
|
key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())
|
||||||
|
|
||||||
|
function Panel({ title, children }) {
|
||||||
|
return (
|
||||||
|
<section className="panel" style={{ padding: 18 }}>
|
||||||
|
<h2
|
||||||
|
className="display"
|
||||||
|
style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A label/value row. Rows whose value is null are dropped by the caller, so a
|
||||||
|
// block never renders a dangling label for something the shard didn't publish.
|
||||||
|
function Row({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
padding: '5px 0',
|
||||||
|
borderBottom: '1px solid var(--line)',
|
||||||
|
fontSize: '0.86rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="dim" style={{ minWidth: 0 }}>{label}</span>
|
||||||
|
<strong style={{ flex: 'none', color: 'var(--head)' }}>{value}</strong>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Rows({ items }) {
|
||||||
|
const rows = items.filter(([, value]) => value !== null && value !== undefined)
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{rows.map(([label, value]) => (
|
||||||
|
<Row key={label} label={label} value={value} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemPill({ label, on }) {
|
||||||
|
const color = on ? '#8fdcae' : 'var(--muted)'
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 7,
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
padding: '5px 11px',
|
||||||
|
borderRadius: 999,
|
||||||
|
color,
|
||||||
|
background: on ? 'rgba(95,185,138,0.12)' : 'rgba(140,150,165,0.1)',
|
||||||
|
border: `1px solid ${on ? 'rgba(95,185,138,0.4)' : 'var(--line)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ width: 7, height: 7, borderRadius: '50%', background: color, flex: 'none' }}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Systems({ systems }) {
|
||||||
|
// Known keys first in their declared order, then anything the shard added that
|
||||||
|
// this build doesn't know about.
|
||||||
|
const known = Object.keys(SYSTEM_LABELS).filter((k) => k in systems)
|
||||||
|
const extra = Object.keys(systems).filter((k) => !(k in SYSTEM_LABELS))
|
||||||
|
const keys = [...known, ...extra]
|
||||||
|
if (keys.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Panel title="Systems">
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{keys.map((k) => (
|
||||||
|
<SystemPill key={k} label={SYSTEM_LABELS[k] || humanise(k)} on={!!systems[k]} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Caps({ caps }) {
|
||||||
|
return (
|
||||||
|
<Panel title="Skill & stat caps">
|
||||||
|
<Rows
|
||||||
|
items={[
|
||||||
|
['Individual skill cap', tenths(caps.skill)],
|
||||||
|
['Total skill cap', tenths(caps.totalSkill)],
|
||||||
|
['Total stat cap', num(caps.stat)],
|
||||||
|
['Strength cap', num(caps.str)],
|
||||||
|
['Dexterity cap', num(caps.dex)],
|
||||||
|
['Intelligence cap', num(caps.int)],
|
||||||
|
['Strength max', num(caps.strMax)],
|
||||||
|
['Dexterity max', num(caps.dexMax)],
|
||||||
|
['Intelligence max', num(caps.intMax)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountsAndHousing({ accounts, housing, vetRewards }) {
|
||||||
|
const items = []
|
||||||
|
if (accounts) {
|
||||||
|
items.push(['Accounts per IP', num(accounts.perIp)])
|
||||||
|
items.push(['Character slots', num(accounts.charSlots)])
|
||||||
|
items.push([
|
||||||
|
'In-game account creation',
|
||||||
|
accounts.autoCreate === undefined ? null : accounts.autoCreate ? 'Enabled' : 'Website only',
|
||||||
|
])
|
||||||
|
}
|
||||||
|
if (housing) items.push(['Houses per account', num(housing.accountHouseLimit)])
|
||||||
|
if (vetRewards?.enabled) {
|
||||||
|
items.push(['Veteran reward interval', vetRewards.rewardIntervalDays
|
||||||
|
? `${vetRewards.rewardIntervalDays} days`
|
||||||
|
: null])
|
||||||
|
}
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Panel title="Accounts & housing">
|
||||||
|
<Rows items={items} />
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Champions({ champions }) {
|
||||||
|
const t = champions.rankThresholds
|
||||||
|
return (
|
||||||
|
<Panel title="Champion spawns">
|
||||||
|
<Rows
|
||||||
|
items={[
|
||||||
|
['Power scrolls per spawn', num(champions.powerScrolls)],
|
||||||
|
['Stat scrolls per spawn', num(champions.statScrolls)],
|
||||||
|
['Scroll drop chance', pct(champions.scrollChance)],
|
||||||
|
['Transcendence chance', pct(champions.transcendenceChance)],
|
||||||
|
[
|
||||||
|
'Red skulls per rank',
|
||||||
|
Array.isArray(t) && t.length > 0 ? t.join(' · ') : null,
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Felucca({ loot }) {
|
||||||
|
return (
|
||||||
|
<Panel title="Felucca bonuses">
|
||||||
|
<Rows
|
||||||
|
items={[
|
||||||
|
['Luck bonus', num(loot.feluccaLuckBonus)],
|
||||||
|
['Loot budget bonus', num(loot.feluccaBudgetBonus)],
|
||||||
|
['Max item properties', num(loot.feluccaMaxProps)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Vendors({ vendors }) {
|
||||||
|
return (
|
||||||
|
<Panel title="Vendors">
|
||||||
|
<Rows
|
||||||
|
items={[
|
||||||
|
['Restock delay', vendors.restockDelayMinutes
|
||||||
|
? `${vendors.restockDelayMinutes} min`
|
||||||
|
: null],
|
||||||
|
['Max items sold at once', num(vendors.maxSell)],
|
||||||
|
['Economy stock amount', num(vendors.economyStockAmount)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pvp({ vvv }) {
|
||||||
|
return (
|
||||||
|
<Panel title="Vice vs Virtue">
|
||||||
|
<Rows
|
||||||
|
items={[
|
||||||
|
['Starting silver', num(vvv.startSilver)],
|
||||||
|
['Enhanced rules', vvv.enhancedRules === undefined
|
||||||
|
? null
|
||||||
|
: vvv.enhancedRules ? 'On' : 'Off'],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Schedule({ schedule }) {
|
||||||
|
const items = []
|
||||||
|
if (schedule.autoSaveEnabled && schedule.autoSaveFrequencyMinutes) {
|
||||||
|
items.push(['World save', `every ${schedule.autoSaveFrequencyMinutes} min`])
|
||||||
|
} else if (schedule.autoSaveEnabled === false) {
|
||||||
|
items.push(['World save', 'Disabled'])
|
||||||
|
}
|
||||||
|
if (schedule.autoRestartEnabled) {
|
||||||
|
const h = String(schedule.autoRestartHour ?? 0).padStart(2, '0')
|
||||||
|
const m = String(schedule.autoRestartMinute ?? 0).padStart(2, '0')
|
||||||
|
items.push(['Automatic restart', `${h}:${m} server time`])
|
||||||
|
if (schedule.autoRestartFrequencyHours) {
|
||||||
|
items.push(['Restart interval', `every ${schedule.autoRestartFrequencyHours}h`])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Panel title="Save & restart schedule">
|
||||||
|
<Rows items={items} />
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Rules() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.ruleset())
|
||||||
|
const { events, connected } = useShardFeed({ filter: RULESET_KINDS, max: 4 })
|
||||||
|
|
||||||
|
// The newest world.ruleset on the feed wins outright over the fetched copy —
|
||||||
|
// the frame is a complete ruleset, not a delta.
|
||||||
|
const ruleset = useMemo(() => events[0] || data || null, [data, events])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Live"
|
||||||
|
title="Shard ruleset"
|
||||||
|
lead="Published by the server itself, straight from its configuration — so it cannot drift from how the shard actually plays."
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem',
|
||||||
|
color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the shard ruleset right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && !ruleset && (
|
||||||
|
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0 }}>
|
||||||
|
The shard has not published its ruleset yet.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && ruleset && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<Panel title="Shard">
|
||||||
|
<Rows
|
||||||
|
items={[
|
||||||
|
['Name', ruleset.shard || null],
|
||||||
|
['Expansion', ruleset.expansion || null],
|
||||||
|
['Connect', ruleset.connect || null],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{ruleset.systems && <Systems systems={ruleset.systems} />}
|
||||||
|
{ruleset.caps && <Caps caps={ruleset.caps} />}
|
||||||
|
<AccountsAndHousing
|
||||||
|
accounts={ruleset.accounts}
|
||||||
|
housing={ruleset.housing}
|
||||||
|
vetRewards={ruleset.vetRewards}
|
||||||
|
/>
|
||||||
|
{ruleset.champions && <Champions champions={ruleset.champions} />}
|
||||||
|
{ruleset.loot && <Felucca loot={ruleset.loot} />}
|
||||||
|
{ruleset.vendors && <Vendors vendors={ruleset.vendors} />}
|
||||||
|
{ruleset.vvv?.enabled && <Pvp vvv={ruleset.vvv} />}
|
||||||
|
{ruleset.schedule && <Schedule schedule={ruleset.schedule} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
250
client/src/routes/public/Shard.jsx
Normal file
250
client/src/routes/public/Shard.jsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import { describe } from '../../lib/shardEvents.js'
|
||||||
|
import { ago } from '../../lib/format.js'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import PlayersOnline from '../../components/PlayersOnline.jsx'
|
||||||
|
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync, useAuth } from '../../core.js'
|
||||||
|
|
||||||
|
// Flavor line under the online/offline banner: online, configured-but-down, or
|
||||||
|
// not configured yet.
|
||||||
|
function statusMessage(online, enabled) {
|
||||||
|
if (online) return 'The gate to Britannia stands open.'
|
||||||
|
if (enabled) return 'The link to the game world is down — checking back automatically.'
|
||||||
|
return 'Live shard data is not configured yet.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gold-supply sparkline ───────────────────────────────────────────────────
|
||||||
|
function Sparkline({ series }) {
|
||||||
|
if (!series || series.length < 2) return null
|
||||||
|
const w = 320
|
||||||
|
const h = 56
|
||||||
|
const golds = series.map((s) => Number(s.gold) || 0)
|
||||||
|
const min = Math.min(...golds)
|
||||||
|
const max = Math.max(...golds)
|
||||||
|
const span = max - min || 1
|
||||||
|
const pts = series
|
||||||
|
.map((s, i) => {
|
||||||
|
const x = (i / (series.length - 1)) * w
|
||||||
|
const y = h - ((Number(s.gold) || 0) - min) / span * h
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
<polyline points={pts} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stat tile (matches Status.jsx) ──────────────────────────────────────────
|
||||||
|
function Stat({ value, label }) {
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
|
||||||
|
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 6 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Shard() {
|
||||||
|
const { loading, error, data } = useAsync(() =>
|
||||||
|
Promise.all([
|
||||||
|
api.shard.status(),
|
||||||
|
api.shard.idoc(),
|
||||||
|
api.shard.economy(60),
|
||||||
|
api.shard.online(),
|
||||||
|
]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
|
||||||
|
)
|
||||||
|
const { events, connected } = useShardFeed({ max: 30 })
|
||||||
|
const { user } = useAuth()
|
||||||
|
// Staff in-game location is privileged: only admins/moderators see it. Players
|
||||||
|
// and the public see that staff are online but not where. The server enforces
|
||||||
|
// this too (it omits the location fields entirely for non-privileged callers).
|
||||||
|
const canSeeLocation = user?.role === 'admin' || user?.role === 'moderator'
|
||||||
|
|
||||||
|
const status = data?.status
|
||||||
|
const online = status?.pluginConnected
|
||||||
|
const gold = status?.economy?.gold
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<PageHeader eyebrow="Live" title="Shard" />
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load shard data right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && data && (
|
||||||
|
<>
|
||||||
|
<ConnectionBanner online={online} status={status} />
|
||||||
|
|
||||||
|
{/* Stat tiles */}
|
||||||
|
<section className="grid-2" style={{ gap: 14, marginBottom: 24 }}>
|
||||||
|
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
|
||||||
|
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Live players-online breakdown (total + region buckets) */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<PlayersOnline />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StaffOnline list={data.online} canSeeLocation={canSeeLocation} />
|
||||||
|
|
||||||
|
{/* Economy sparkline */}
|
||||||
|
{data.economy && data.economy.length > 1 && (
|
||||||
|
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||||
|
Gold supply over time
|
||||||
|
</div>
|
||||||
|
<Sparkline series={data.economy} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
{/* Latest IDOC */}
|
||||||
|
<FeedList
|
||||||
|
title="Houses in danger (IDOC)"
|
||||||
|
empty="No houses are collapsing right now."
|
||||||
|
items={data.idoc.map((h) => {
|
||||||
|
const region = h.region ? ` — ${h.region}` : ''
|
||||||
|
return {
|
||||||
|
id: h.serial,
|
||||||
|
text: `${h.name || 'A house'}${region}`,
|
||||||
|
when: h.updatedAt,
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live ticker */}
|
||||||
|
<section className="panel" style={{ padding: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
||||||
|
Live feed
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<Link to="/uo/shard/activity" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.78rem' }}>
|
||||||
|
View all activity →
|
||||||
|
</Link>
|
||||||
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||||
|
Waiting for something to happen in the world…
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{events.map((ev) => (
|
||||||
|
<li key={ev._id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(ev)}</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(ev.t)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Online/offline banner with the flavor line under it.
|
||||||
|
function ConnectionBanner({ online, status }) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
padding: '24px 26px',
|
||||||
|
border: `1px solid ${online ? 'rgba(95,185,138,0.45)' : '#5a4a2a'}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: online
|
||||||
|
? 'linear-gradient(180deg,rgba(22,46,34,0.5),rgba(16,26,20,0.4))'
|
||||||
|
: 'linear-gradient(180deg,rgba(58,46,22,0.5),rgba(30,26,16,0.4))',
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 'none',
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: online ? 'var(--mode-live)' : 'var(--mode-maint)',
|
||||||
|
boxShadow: `0 0 12px ${online ? 'rgba(95,185,138,0.7)' : 'rgba(230,194,106,0.7)'}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<strong className="display" style={{ display: 'block', fontSize: '1.2rem', color: online ? '#bfe6cf' : '#f0e3c4' }}>
|
||||||
|
{online ? 'The shard is online' : 'The shard is offline'}
|
||||||
|
</strong>
|
||||||
|
<span className="sans" style={{ color: online ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
|
||||||
|
{statusMessage(online, status?.enabled)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linked staff accounts currently online; in-game location is admin/mod-only.
|
||||||
|
function StaffOnline({ list, canSeeLocation }) {
|
||||||
|
return (
|
||||||
|
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||||
|
Staff online
|
||||||
|
</div>
|
||||||
|
{(!list || list.length === 0) ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No staff are online right now.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{list.map((p) => (
|
||||||
|
<div key={p.serial} className="sans" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||||
|
<span style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
|
||||||
|
{p.name || p.serial}
|
||||||
|
</span>
|
||||||
|
{canSeeLocation && (
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>
|
||||||
|
{p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedList({ title, items, empty }) {
|
||||||
|
return (
|
||||||
|
<section className="panel" style={{ padding: 20 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>{empty}</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{items.map((it) => (
|
||||||
|
<li key={it.id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.text}</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(it.when)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
client/src/routes/public/ShardActivity.jsx
Normal file
78
client/src/routes/public/ShardActivity.jsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import { describe, categoryOf, kindLabel, CATEGORIES } from '../../lib/shardEvents.js'
|
||||||
|
import { ago } from '../../lib/format.js'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||||
|
|
||||||
|
// Public activity feed: the full shard event log, filterable by category, with a
|
||||||
|
// live tail that prepends new events as they happen.
|
||||||
|
export default function ShardActivity() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.feed({ limit: 150 }))
|
||||||
|
const { events: live } = useShardFeed({ max: 60 })
|
||||||
|
const [cat, setCat] = useState('all')
|
||||||
|
|
||||||
|
// Merge the live tail with the loaded history, de-duped by kind+t, newest first.
|
||||||
|
const merged = useMemo(() => {
|
||||||
|
const seen = new Set()
|
||||||
|
const out = []
|
||||||
|
for (const e of [...live, ...(data || [])]) {
|
||||||
|
const key = `${e.kind}-${e.t}`
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
out.push(e)
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => (b.t || 0) - (a.t || 0))
|
||||||
|
}, [live, data])
|
||||||
|
|
||||||
|
const filtered = cat === 'all' ? merged : merged.filter((e) => categoryOf(e.kind) === cat)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<PageHeader eyebrow="Live" title="Shard Activity" />
|
||||||
|
<p style={{ marginTop: -8, marginBottom: 18 }}>
|
||||||
|
<Link to="/uo/shard" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>← Back to shard</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Category tabs */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 18 }}>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => setCat(c.id)}
|
||||||
|
className="pill"
|
||||||
|
style={cat === c.id ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : undefined}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the activity feed right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
filtered.length === 0 ? (
|
||||||
|
<div className="panel" style={{ padding: 22 }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.9rem' }}>Nothing here yet — events will appear as they happen in the world.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{filtered.map((e) => (
|
||||||
|
<li key={e._id || `${e.kind}-${e.t}`} className="panel" style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>
|
||||||
|
{kindLabel(e.kind)}
|
||||||
|
</span>
|
||||||
|
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', fontSize: '0.92rem' }}>{describe(e)}</span>
|
||||||
|
<span className="sans dim" style={{ flex: 'none', fontSize: '0.76rem' }}>{ago(e.t)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
// itself, only harder to see, because it shows up as a hook dispatcher error in
|
// 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
|
||||||
|
|
||||||
|
|||||||
4
client/src/shim/react-dom.js
vendored
4
client/src/shim/react-dom.js
vendored
@@ -5,7 +5,9 @@
|
|||||||
// react-dom, and one that resolved to a bundled copy would put a second
|
// 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
|
||||||
|
|
||||||
|
|||||||
4
client/src/shim/react-router-dom.js
vendored
4
client/src/shim/react-router-dom.js
vendored
@@ -5,7 +5,9 @@
|
|||||||
// whose `useParams` returns nothing and whose `<Link>` navigates the browser
|
// 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
|
||||||
|
|
||||||
|
|||||||
4
client/src/shim/react.js
vendored
4
client/src/shim/react.js
vendored
@@ -13,7 +13,9 @@
|
|||||||
// compiles to a named import, and a module with only a default export would fail
|
// 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
29
client/src/shim/rg.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// The one place this module reads `window.__rg`, and the one place that says
|
||||||
|
// something useful when it is not there.
|
||||||
|
//
|
||||||
|
// Every shim beside this file, and `src/core.js`, go through here. That is not
|
||||||
|
// tidiness — it removes an ordering dependency that was genuinely fragile. ES
|
||||||
|
// modules evaluate dependencies in the source order of their import statements,
|
||||||
|
// so "put the friendly check in the file that is imported first" is a guarantee
|
||||||
|
// that survives exactly until someone sorts the imports. Whichever module the
|
||||||
|
// bundler happens to reach first, it reaches `window.__rg` through this.
|
||||||
|
//
|
||||||
|
// A missing global means core did not publish its shared dependencies before
|
||||||
|
// this chunk evaluated: an injection or ordering fault in CORE (MODULE_API.md
|
||||||
|
// §3.1), not a fault in this module. Without this, the first symptom is
|
||||||
|
// "Cannot read properties of undefined (reading 'react')" thrown from a file
|
||||||
|
// called react.js, which reads like the module bundled React wrong — the
|
||||||
|
// opposite of what happened.
|
||||||
|
export function rg() {
|
||||||
|
const shared = window.__rg
|
||||||
|
if (!shared) {
|
||||||
|
throw new Error(
|
||||||
|
'[module-uo] window.__rg is missing — core did not publish its shared dependencies before this ' +
|
||||||
|
'chunk evaluated. That is an injection or ordering fault in core (MODULE_API.md §3.1), not a ' +
|
||||||
|
'fault in this module.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return shared
|
||||||
|
}
|
||||||
|
|
||||||
|
export default rg
|
||||||
@@ -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";'), [])
|
||||||
|
})
|
||||||
|
|||||||
60
client/test/regionBuckets.test.js
Normal file
60
client/test/regionBuckets.test.js
Normal 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)
|
||||||
|
})
|
||||||
200
client/test/registration.test.js
Normal file
200
client/test/registration.test.js
Normal 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])
|
||||||
|
})
|
||||||
74
client/test/shardEvents.test.js
Normal file
74
client/test/shardEvents.test.js
Normal 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), '')
|
||||||
|
})
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
160
server/test/gameSignup.test.js
Normal file
160
server/test/gameSignup.test.js
Normal 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')
|
||||||
|
})
|
||||||
63
server/utils/gameSignup.js
Normal file
63
server/utils/gameSignup.js
Normal 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 }
|
||||||
Reference in New Issue
Block a user