From 28f4b9afe2202b5598f1b6b1fdb353a5580707eb Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 11 Aug 2026 18:00:25 -0500 Subject: [PATCH] feat(client): the whole client half (phase 3, slice 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 35 files behind twelve public pages, seven admin views, two player views and three core-page extensions, ported onto `window.__rg`. Every one of them imports exactly the seven kit members plus `lib/format.js`, which is the finding §2.7.1 predicted and this confirms. `client/src/core.js` is the port mechanism, and unlike the server's it is a plain read: `window.__rg` is published before any module chunk evaluates, so there is no gap to defer around and a ported component keeps its ordinary import shape. `client/src/api.js` rebuilds the UO namespaces over the request primitive — same URLs, because §1.2 freezes the API surface. SPA paths changed and API paths did not. `/site/shard` is `/uo/shard`, and the admin paths lost their now-redundant `shard-` prefixes (`/admin/uo/ops`), a clean break being the only moment that is free. `shim/rg.js` becomes the single reader of the global, so the "core did not publish its dependencies" message is reachable from whichever module the bundler happens to touch first rather than from whichever one is imported first — a guarantee that used to last until someone sorted the imports. Co-Authored-By: Claude --- client/src/api.js | 231 ++++++++++++ client/src/components/CharacterSheet.jsx | 293 +++++++++++++++ client/src/components/CharacterStats.jsx | 79 ++++ .../src/components/CreateGameAccountForm.jsx | 69 ++++ client/src/components/GameAccounts.jsx | 228 ++++++++++++ .../src/components/InviteGameAccountStep.jsx | 51 +++ client/src/components/PlayersOnline.jsx | 84 +++++ client/src/components/ShardAccountActions.jsx | 88 +++++ client/src/components/ShardStatusLink.jsx | 24 ++ client/src/components/VendorSales.jsx | 41 +++ client/src/core.js | 72 ++++ client/src/data/cityCrests.js | 31 ++ client/src/data/regionBuckets.js | 72 ++++ client/src/entry.jsx | 235 ++++++++---- client/src/icons.jsx | 38 ++ client/src/lib/format.js | 36 ++ client/src/lib/shardEvents.js | 128 +++++++ client/src/lib/useShardFeatures.js | 95 +++++ client/src/lib/useShardFeed.js | 54 +++ client/src/routes/admin/AdminCharacter.jsx | 28 ++ client/src/routes/admin/AdminCharacters.jsx | 18 + client/src/routes/admin/HousesAdmin.jsx | 118 ++++++ client/src/routes/admin/ShardAdmin.jsx | 323 +++++++++++++++++ client/src/routes/admin/ShardOps.jsx | 291 +++++++++++++++ client/src/routes/admin/ShardVisibility.jsx | 325 +++++++++++++++++ client/src/routes/admin/SpawnAtlas.jsx | 285 +++++++++++++++ client/src/routes/admin/UserShardSections.jsx | 163 +++++++++ client/src/routes/player/PlayerCharacter.jsx | 28 ++ client/src/routes/player/PlayerCharacters.jsx | 58 +++ client/src/routes/public/Atlas.jsx | 307 ++++++++++++++++ client/src/routes/public/AtlasCreature.jsx | 198 ++++++++++ client/src/routes/public/ChampSpawns.jsx | 199 +++++++++++ client/src/routes/public/Governors.jsx | 184 ++++++++++ client/src/routes/public/Guilds.jsx | 166 +++++++++ client/src/routes/public/Houses.jsx | 88 +++++ client/src/routes/public/Leaderboards.jsx | 236 ++++++++++++ client/src/routes/public/Market.jsx | 322 +++++++++++++++++ client/src/routes/public/MarketVendor.jsx | 99 +++++ client/src/routes/public/Rules.jsx | 338 ++++++++++++++++++ client/src/routes/public/Shard.jsx | 250 +++++++++++++ client/src/routes/public/ShardActivity.jsx | 78 ++++ client/src/shim/jsx-runtime.js | 4 +- client/src/shim/react-dom.js | 4 +- client/src/shim/react-router-dom.js | 4 +- client/src/shim/react.js | 4 +- client/src/shim/rg.js | 29 ++ server/router/player/shard.controller.js | 5 +- 47 files changed, 6031 insertions(+), 70 deletions(-) create mode 100644 client/src/api.js create mode 100644 client/src/components/CharacterSheet.jsx create mode 100644 client/src/components/CharacterStats.jsx create mode 100644 client/src/components/CreateGameAccountForm.jsx create mode 100644 client/src/components/GameAccounts.jsx create mode 100644 client/src/components/InviteGameAccountStep.jsx create mode 100644 client/src/components/PlayersOnline.jsx create mode 100644 client/src/components/ShardAccountActions.jsx create mode 100644 client/src/components/ShardStatusLink.jsx create mode 100644 client/src/components/VendorSales.jsx create mode 100644 client/src/core.js create mode 100644 client/src/data/cityCrests.js create mode 100644 client/src/data/regionBuckets.js create mode 100644 client/src/icons.jsx create mode 100644 client/src/lib/format.js create mode 100644 client/src/lib/shardEvents.js create mode 100644 client/src/lib/useShardFeatures.js create mode 100644 client/src/lib/useShardFeed.js create mode 100644 client/src/routes/admin/AdminCharacter.jsx create mode 100644 client/src/routes/admin/AdminCharacters.jsx create mode 100644 client/src/routes/admin/HousesAdmin.jsx create mode 100644 client/src/routes/admin/ShardAdmin.jsx create mode 100644 client/src/routes/admin/ShardOps.jsx create mode 100644 client/src/routes/admin/ShardVisibility.jsx create mode 100644 client/src/routes/admin/SpawnAtlas.jsx create mode 100644 client/src/routes/admin/UserShardSections.jsx create mode 100644 client/src/routes/player/PlayerCharacter.jsx create mode 100644 client/src/routes/player/PlayerCharacters.jsx create mode 100644 client/src/routes/public/Atlas.jsx create mode 100644 client/src/routes/public/AtlasCreature.jsx create mode 100644 client/src/routes/public/ChampSpawns.jsx create mode 100644 client/src/routes/public/Governors.jsx create mode 100644 client/src/routes/public/Guilds.jsx create mode 100644 client/src/routes/public/Houses.jsx create mode 100644 client/src/routes/public/Leaderboards.jsx create mode 100644 client/src/routes/public/Market.jsx create mode 100644 client/src/routes/public/MarketVendor.jsx create mode 100644 client/src/routes/public/Rules.jsx create mode 100644 client/src/routes/public/Shard.jsx create mode 100644 client/src/routes/public/ShardActivity.jsx create mode 100644 client/src/shim/rg.js 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..3a8d55f 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 `