diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 67a9983..1422d8c 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -94,11 +94,16 @@ jobs: - name: Install client deps run: npm ci --prefix client - - name: Run client tests - run: npm test --prefix client - + # The build comes FIRST, and that ordering is load-bearing as of slice 3. + # 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 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) run: npm run check:externals --prefix client diff --git a/client/scripts/checkExternals.js b/client/scripts/checkExternals.js index 385aa07..031b226 100644 --- a/client/scripts/checkExternals.js +++ b/client/scripts/checkExternals.js @@ -27,53 +27,146 @@ import { fileURLToPath } from 'node:url' 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.`) - process.exit(1) +/** + * Which characters of the chunk are inside a string, template or comment. + * + * **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 // 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. -const IMPORTS = /(?:^|[\s;}])(?:import\s+[^'"]*?from\s*|import\s*|import\()\s*['"]([^'"]+)['"]/g -const bare = new Set() -for (const [, specifier] of chunk.matchAll(IMPORTS)) { - if (!specifier.startsWith('.') && !specifier.startsWith('/')) bare.add(specifier) -} -if (bare.size) { - 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).', - ) +// +// **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() + 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) + } + return [...bare] } // Fingerprints from the shared libraries' own source. Each is a string those // 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 = [ { what: 'react', probe: 'react.development.js' }, { what: 'react', probe: 'Invalid hook call' }, { what: 'react-dom', probe: 'react-dom.development.js' }, { what: 'react-router-dom', probe: 'useRoutes() may be used only in the context of a component' }, ] -for (const { what, probe } of BUNDLED) { - if (chunk.includes(probe)) { + +/** 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 appears to BUNDLE ${what} (found ${JSON.stringify(probe)}). ` + - 'There is exactly one React in the page and core owns it — a second copy ' + - 'loads fine and then fails at the first hook (MODULE_API.md §3.2).', + `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) { + if (chunk.includes(probe)) { + problems.push( + `the chunk appears to BUNDLE ${what} (found ${JSON.stringify(probe)}). ` + + 'There is exactly one React in the page and core owns it — a second copy ' + + 'loads fine and then fails at the first hook (MODULE_API.md §3.2).', + ) + } + } + return problems } -if (problems.length) { - console.error('\nThe built chunk breaks the shared-dependency rule:\n') - for (const p of problems) console.error(` - ${p}\n`) - process.exit(1) +// 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) { + console.error('\nThe built chunk breaks the shared-dependency rule:\n') + for (const p of problems) console.error(` - ${p}\n`) + process.exit(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.`) } - -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.`) diff --git a/client/src/api.js b/client/src/api.js new file mode 100644 index 0000000..28aed0d --- /dev/null +++ b/client/src/api.js @@ -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 diff --git a/client/src/components/CharacterSheet.jsx b/client/src/components/CharacterSheet.jsx new file mode 100644 index 0000000..58c8fb4 --- /dev/null +++ b/client/src/components/CharacterSheet.jsx @@ -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 ( +
+
+ + {label} + {Number.isFinite(entry.rank) && ( + · #{entry.rank} + )} + + + {(entry.points ?? 0).toLocaleString()} + {max > 0 && / {max.toLocaleString()}} + +
+ {/* 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 && ( +
+
+
+ )} +
+ ) +} + +function TitleChip({ children, tone = 'var(--muted)' }) { + return ( + + {children} + + ) +} + +function StatTile({ value, label }) { + return ( +
+
{value}
+
{label}
+
+ ) +} + +function Vital({ label, cur, max }) { + const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0 + return ( +
+
+ {label} + {cur ?? '—'} / {max ?? '—'} +
+
+
+
+
+ ) +} + +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 ( +
+ {/* Identity */} +
+

{char.name || 'Unknown'}

+ {char.title && {char.title}} + + + {char.online ? 'Online' : 'Offline'} + + {char.serial} +
+ + {/* Titles + standing (guild led / governorship) — all optional */} + {(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && ( +
+ {char.governorOf && char.governorOf.map((city) => ( + Governor of {city} + ))} + {char.guild && ( + + Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name} + + )} + {displayTitles(char.titles).map((t) => {t})} +
+ )} + + {/* Staff moderation for this character's account (self-gates to staff). */} + {moderation && char.acct && ( +
+ Account {char.acct} + +
+ )} + + {/* Core stats */} +
+
Attributes
+
+ + + +
+
+ + + +
+
+ + {/* Resistances */} + {Object.keys(resist).length > 0 && ( +
+
Resistances
+
+ {['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => ( +
+
{resist[k] ?? 0}
+
{RESIST_LABELS[k]}
+
+ ))} +
+
+ )} + + {/* Skills */} + {skills.length > 0 && ( +
+
Skills ({skills.length})
+
+ {skills.map((s) => { + const cap = s.cap || 100 + const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100)) + return ( +
+
+ {s.n} + {s.value} +
+
+
+
+
+ ) + })} +
+
+ )} + + {/* Loyalty & points — one entry per system this character has scored in */} + {points.length > 0 && ( +
+
+ Loyalty & points ({points.length}) +
+
+ {points.map((p) => ( + + ))} +
+
+ )} + + {/* Equipment */} + {equipment.length > 0 && ( +
+
Equipment
+
+ {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 ( +
+ +
+
{label}
+
{detail.filter(Boolean).join(' · ')}
+
+ {it.mods && Object.keys(it.mods).length > 0 && ( +
+ {Object.entries(it.mods).map(([k, v]) => ( + {k} {v} + ))} +
+ )} +
+ ) + })} +
+
+ )} +
+ ) +} diff --git a/client/src/components/CharacterStats.jsx b/client/src/components/CharacterStats.jsx new file mode 100644 index 0000000..12142ed --- /dev/null +++ b/client/src/components/CharacterStats.jsx @@ -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 ( +
+
{value}
+
+ {label} +
+
+ ) +} + +// 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 ( +
+ + + +
+ ) +} diff --git a/client/src/components/CreateGameAccountForm.jsx b/client/src/components/CreateGameAccountForm.jsx new file mode 100644 index 0000000..7b045f6 --- /dev/null +++ b/client/src/components/CreateGameAccountForm.jsx @@ -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 ( +
+ {!compact && ( +

+ Choose the username and password you’ll type into the game client. These are your + game credentials — separate from your website login. +

+ )} + + + + {error &&

{error}

} + {msg &&

{msg}

} + + +
+ ) +} diff --git a/client/src/components/GameAccounts.jsx b/client/src/components/GameAccounts.jsx new file mode 100644 index 0000000..4d706ca --- /dev/null +++ b/client/src/components/GameAccounts.jsx @@ -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 ( +
+ + + {msg && {msg}} + {error && {error}} +
+ ) +} + +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 ( +
+

The game server is restarting — try again shortly.

+ +
+ ) + } + if (error) return

{error}

+ if (!roster) return

Loading…

+ + const chars = roster.chars || [] + if (chars.length === 0) return

No characters on this account.

+ + return ( +
+ {chars.map((c) => ( + + + {(c.name || '?').charAt(0)} + +
+
{c.name}
+
{c.online ? 'Online' : 'Offline'}
+
+ + + ))} +
+ ) +} + +// 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 ( + + + {error && {error}} + + ) +} + +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 + if (!accounts) return + + // 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 ( +
+

+ This user has not linked a game account. +

+
+ ) + } + return ( +
+
+
Link your game account
+

+ Already play? In game, type [link to get a + one-time code, then enter it below to see your characters, stats, skills and vendors here. +

+ +
+ {canCreate && ( +
+
Create a new game account
+ +
+ )} +
+ ) + } + + // Linked — characters grouped by account. + return ( +
+ {accounts.map((a) => ( +
+
+
+ {a.account} +
+ {onUnlink && { await onUnlink(acct); await load() }} />} +
+ {moderation && } + +
+ ))} + {!readOnly && ( +
+
Link another account
+ + {canCreate && ( +
+
Create another game account
+ +
+ )} +
+ )} +
+ ) +} diff --git a/client/src/components/InviteGameAccountStep.jsx b/client/src/components/InviteGameAccountStep.jsx new file mode 100644 index 0000000..ecee0d0 --- /dev/null +++ b/client/src/components/InviteGameAccountStep.jsx @@ -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 ( +
+ +
+ ) + } + + return ( + <> +

+ Your account is ready. Create a game account now to play, or skip and do it later from your portal. +

+ + + ) +} diff --git a/client/src/components/PlayersOnline.jsx b/client/src/components/PlayersOnline.jsx new file mode 100644 index 0000000..b17bad9 --- /dev/null +++ b/client/src/components/PlayersOnline.jsx @@ -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 ( +
+
+ + Players online + + + {loading ? '—' : total} + +
+ + {error && ( +

+ Population is unavailable right now. +

+ )} + + {!loading && !error && ( +
+ {rows.length === 0 ? ( +

+ {total > 0 ? 'Locations are settling…' : 'The realm is quiet.'} +

+ ) : ( + rows.map((r) => ( +
+ {r.label} + {/* tabular figures keep the right-aligned counts in a clean column */} + {r.count} +
+ )) + )} +
+ )} +
+ ) +} diff --git a/client/src/components/ShardAccountActions.jsx b/client/src/components/ShardAccountActions.jsx new file mode 100644 index 0000000..06a81c7 --- /dev/null +++ b/client/src/components/ShardAccountActions.jsx @@ -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 ( +
+
+ + + + {ok && {ok}} + {err && {err}} +
+ + {banOpen && ( +
+ + + +
+ )} +
+ ) +} diff --git a/client/src/components/ShardStatusLink.jsx b/client/src/components/ShardStatusLink.jsx new file mode 100644 index 0000000..49d0840 --- /dev/null +++ b/client/src/components/ShardStatusLink.jsx @@ -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 ( + + Shard Status + + ) +} diff --git a/client/src/components/VendorSales.jsx b/client/src/components/VendorSales.jsx new file mode 100644 index 0000000..06c4345 --- /dev/null +++ b/client/src/components/VendorSales.jsx @@ -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 ( +
+
Recent vendor sales
+ {sales.length === 0 ? ( +

No vendor sales recorded yet.

+ ) : ( +
    + {sales.map((s) => ( +
  • + + {s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} — {Number(s.price || 0).toLocaleString()}gp + + {ago(s.t)} +
  • + ))} +
+ )} +
+ ) +} diff --git a/client/src/core.js b/client/src/core.js new file mode 100644 index 0000000..bce4793 --- /dev/null +++ b/client/src/core.js @@ -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 diff --git a/client/src/data/cityCrests.js b/client/src/data/cityCrests.js new file mode 100644 index 0000000..d9bff1b --- /dev/null +++ b/client/src/data/cityCrests.js @@ -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) } +} diff --git a/client/src/data/regionBuckets.js b/client/src/data/regionBuckets.js new file mode 100644 index 0000000..ffbc1dd --- /dev/null +++ b/client/src/data/regionBuckets.js @@ -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 } +} diff --git a/client/src/entry.jsx b/client/src/entry.jsx index 2713af5..3b78cf0 100644 --- a/client/src/entry.jsx +++ b/client/src/entry.jsx @@ -1,9 +1,8 @@ // ── module-uo's client entry point ───────────────────────────────────────── // -// This file is the whole of the chunk's top-level behaviour: core injects -// `dist/entry.js` as a `