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 (
+
+ {/* 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 && (
+
+ {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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+ )
+}
+
+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.
+ )
+}
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 (
+
+
+ {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 (
+
+
+ )}
+
+ )
+}
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 `