1 Commits

Author SHA1 Message Date
bf470c7658 spike(modules): carry /public/atlas/* behind the proposed module surface
THROWAWAY BRANCH — evidence for the Phase 1 contract, never merged. See
modules/uo/SPIKE.md and docs/website/MODULE_API.md Part 7.

The six public spawn-atlas routes now live in modules/uo/, reached only through
the ctx/register surface, with the client half loading as a prebuilt ESM chunk.
All three exit criteria met:

  • zero internal-file imports from the module into core; the built chunk has
    zero bare import specifiers and bundles no React
  • routes.manifest.json AND routes.guards.json are byte-identical
  • /uo/atlas renders from /modules/uo/entry.js under script-src 'self' with
    zero CSP violation reports

729 core tests and 81 module tests pass. Verified end to end against the real
database: the schema fragment replays after core's, onBoot runs the atlas
refresh, and the six API URLs answer unchanged.

Two things the spike changed in the contract:

  • ctx.express / ctx.validator. A module lives outside server/, so Node never
    reaches server/node_modules and require('express') fails outright — the
    server-side twin of the one-React rule, which §2.6 had only for the client.
  • window.__rg.jsxRuntime, so a module can build with the automatic JSX
    runtime its tooling already assumes rather than being forced to classic.

And it confirmed §6.1 empirically: regenerating the OpenAPI spec silently
deleted all 361 lines of the atlas paths with "Swagger-autogen: Success", while
the route manifest kept all six in the same run. That is exactly the
static-analysis-vs-runtime split the fragment merge exists to prevent.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 05:29:35 -05:00
211 changed files with 28736 additions and 7560 deletions

View File

@@ -10,8 +10,5 @@ uploads
server/logs
logs
*.log
# Installed modules are mounted at runtime, never baked into the image. Without
# this a module in the builder's working tree would ship inside every image.
modules
.DS_Store
Thumbs.db

7
.gitignore vendored
View File

@@ -21,13 +21,6 @@ uploads/
server/logs/
logs/
# Installed modules (docs/website/MODULE_SYSTEM.md). Core ships no module, so
# anything here is an operator's install or a developer's scratch copy. The
# directory itself IS tracked, via its README: docker-compose.yml bind-mounts it,
# and a missing bind-mount source is recreated by Docker as root-owned.
modules/*
!modules/README.md
# Operator-supplied spawn atlas artwork. Creature art is never committed: sprites
# are extracted from the operator's own UO client .mul/.uop files and are theirs,
# not ours to redistribute. The images live under server/uploads/atlas/, already

View File

@@ -21,12 +21,6 @@ RUN if [ -f client/package.json ]; then \
# Persistent uploads + logs live on mounted volumes.
RUN mkdir -p /app/uploads /app/logs && chown -R node:node /app/uploads /app/logs
# Installed modules are mounted in too (docker-compose.yml), and .dockerignore
# keeps any local modules/ OUT of the image — a module must never be baked in.
# The directory is still created here so a container run without the mount finds
# an empty, writable modules dir rather than no directory at all.
RUN mkdir -p /app/modules && chown node:node /app/modules
USER node
EXPOSE 3000

View File

@@ -222,10 +222,6 @@ IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d
- Health check: `GET http://localhost:3000/api/health` → `{ "status": "ok" }`
- Logs: `docker compose logs -f app` (and `./logs/app.log` on the host)
- Stop: `docker compose down` (add `-v` to also wipe the database + uploads volumes)
- Modules: installed into `./modules` on the host (bind-mounted to `/app/modules`), never baked into
the image — an operator adds one to a pull-only deployment without building anything. Adding or
removing one takes a `docker compose restart app`; the scan is synchronous at startup. See
[`modules/README.md`](modules/README.md).
**Build the images locally instead of pulling** (offline, or to test an unmerged change) — overlay
the dev file, which adds `build:` back:
@@ -395,10 +391,9 @@ npm run routes:manifest -- --check # exit 1 if either file is stale (what CI ru
The generator walks the live Express stack (runtime introspection, not source parsing — a route's path
sits on the line *after* `router.get(`, which defeats greps) and keeps only
`/api/**` and `/.well-known/**` plus the internal listener. The SPA catch-all, `/uploads`, `/brand`
and installed modules' `/modules/<id>` chunks are filesystem-conditional static mounts, not API
contract, so they are excluded and the output depends neither on whether the client has been built
nor on which modules are mounted.
`/api/**` and `/.well-known/**` plus the internal listener. The SPA catch-all, `/uploads` and `/brand`
are filesystem-conditional static mounts, not API contract, so they are excluded and the output does
not depend on whether the client has been built.
Two generated files, two very different meanings:
@@ -512,7 +507,6 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
| `NODE_ENV` | `production` | |
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
| `UPLOAD_DIR` | `<server>/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) |
| `MODULES_DIR` | `<repo>/modules` | where installed modules are scanned from (`/app/modules`, bind-mounted, in Compose) |
| `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev |
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `runic_gateway` / `runic` / — | app database credentials |
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |

View File

@@ -6,7 +6,6 @@ import RequireAuth from './components/RequireAuth.jsx'
import RequirePlayer from './components/RequirePlayer.jsx'
import RoleGate from './components/RoleGate.jsx'
import { routesFor } from './modules/registry.js'
import { ModuleFeaturesProvider } from './modules/features.jsx'
// Public
import Portal from './routes/public/Portal.jsx'
@@ -18,6 +17,16 @@ import Newsletter from './routes/public/Newsletter.jsx'
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
import About from './routes/public/About.jsx'
import Status from './routes/public/Status.jsx'
import Shard from './routes/public/Shard.jsx'
import ShardActivity from './routes/public/ShardActivity.jsx'
import ChampSpawns from './routes/public/ChampSpawns.jsx'
import Guilds from './routes/public/Guilds.jsx'
import Governors from './routes/public/Governors.jsx'
import Houses from './routes/public/Houses.jsx'
import Rules from './routes/public/Rules.jsx'
import Leaderboards from './routes/public/Leaderboards.jsx'
import Market from './routes/public/Market.jsx'
import MarketVendor from './routes/public/MarketVendor.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
import CmsPage from './routes/public/CmsPage.jsx'
@@ -37,10 +46,17 @@ import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
import ShardAdmin from './routes/admin/views/ShardAdmin.jsx'
import ShardVisibility from './routes/admin/views/ShardVisibility.jsx'
import SpawnAtlasAdmin from './routes/admin/views/SpawnAtlas.jsx'
import ShardOps from './routes/admin/views/ShardOps.jsx'
import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import UserDetail from './routes/admin/views/UserDetail.jsx'
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
import HousesAdmin from './routes/admin/views/HousesAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
@@ -52,7 +68,9 @@ import PlayerRegister from './routes/player/PlayerRegister.jsx'
import ForgotPassword from './routes/player/ForgotPassword.jsx'
import ResetPassword from './routes/player/ResetPassword.jsx'
import AcceptInvite from './routes/player/AcceptInvite.jsx'
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
import PlayerAccount from './routes/player/PlayerAccount.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
@@ -60,168 +78,173 @@ export default function App() {
return (
<AuthProvider>
<SiteProvider>
{/* Inside the auth and site contexts, because a feature provider is a
hook that may well read either — the shard one does, indirectly, by
asking an endpoint whose answer depends on the session. Outside the
routes, so the nav in every layout is filtered by the same gate and
the provider hooks are called once for the whole app rather than
once per screen. */}
<ModuleFeaturesProvider>
<Routes>
{/* Landing hero — always public, even in maintenance mode. The hero is
itself the pre-launch "coming soon" page, so it sits outside the
MaintenanceGate and every visitor sees it regardless of auth/site mode. */}
<Route path="/" element={<Portal />} />
<Routes>
{/* Landing hero — always public, even in maintenance mode. The hero is
itself the pre-launch "coming soon" page, so it sits outside the
MaintenanceGate and every visitor sees it regardless of auth/site mode. */}
<Route path="/" element={<Portal />} />
{/* Rest of the public site — gated by maintenance mode (admins preview through it) */}
{/* Rest of the public site — gated by maintenance mode (admins preview through it) */}
<Route
element={
<MaintenanceGate>
<Outlet />
</MaintenanceGate>
}
>
<Route path="/site" element={<Website />} />
<Route path="/site/news" element={<News />} />
<Route path="/site/screenshots" element={<Screenshots />} />
<Route path="/site/five-on-friday" element={<FiveOnFriday />} />
<Route path="/site/newsletter" element={<Newsletter />} />
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
<Route path="/site/about" element={<About />} />
<Route path="/site/status" element={<Status />} />
<Route path="/site/shard" element={<Shard />} />
<Route path="/site/shard/activity" element={<ShardActivity />} />
<Route path="/site/champs" element={<ChampSpawns />} />
<Route path="/site/guilds" element={<Guilds />} />
<Route path="/site/governors" element={<Governors />} />
<Route path="/site/houses" element={<Houses />} />
<Route path="/site/rules" element={<Rules />} />
<Route path="/site/leaderboards" element={<Leaderboards />} />
<Route path="/site/market" element={<Market />} />
<Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} />
{/* Installed modules' public pages, namespaced `/<id>/…` (§2.8).
Declared BEFORE the /:slug CMS catch-all: React Router ranks
static segments over dynamic ones so the order is not what saves
us, but keeping them adjacent makes the relationship visible. */}
{routesFor('public').map((r) => (
<Route key={r.path} path={`/${r.path}`} element={r.element} />
))}
{/* CMS pages: top-level /:slug, matched only after the named routes
above (React Router ranks static routes over this dynamic one). */}
<Route path="/:slug" element={<CmsPage />} />
</Route>
{/* Draft-preview link (token-gated). Outside the maintenance gate so a
preview link works regardless of site mode. */}
<Route path="/preview/:id/:token" element={<CmsPage preview />} />
{/* Admin */}
<Route path="/admin/login" element={<AdminLogin />} />
<Route
path="/admin"
element={
<RequireAuth>
<AdminLayout />
</RequireAuth>
}
>
<Route index element={<Dashboard />} />
<Route path="posts" element={<PostsAdmin />} />
<Route path="pages" element={<PagesAdmin />} />
<Route path="pages/new" element={<PageBuilder />} />
<Route path="pages/:id" element={<PageBuilder />} />
<Route path="wiki" element={<WikiAdmin />} />
<Route path="hero" element={<HeroEditor />} />
{/* Theme editing writes an admin-only settings key; the route sits
behind the same RoleGate as the sidebar entry that reaches it,
and PUT/DELETE /admin/settings is admin-only server-side too. */}
<Route
path="appearance"
element={
<MaintenanceGate>
<RoleGate roles={['admin']}>
<AppearanceAdmin />
</RoleGate>
}
/>
{/* Same reasoning as Appearance: the nav overrides are an admin-only
settings key, so the route carries the same RoleGate as the
sidebar entry that reaches it. */}
<Route
path="navigation"
element={
<RoleGate roles={['admin']}>
<NavEditor />
</RoleGate>
}
/>
<Route path="settings" element={<SettingsAdmin />} />
<Route
path="moderation"
element={
<RoleGate roles={['admin', 'moderator']}>
<Outlet />
</MaintenanceGate>
</RoleGate>
}
>
<Route path="/site" element={<Website />} />
<Route path="/site/news" element={<News />} />
<Route path="/site/screenshots" element={<Screenshots />} />
<Route path="/site/five-on-friday" element={<FiveOnFriday />} />
<Route path="/site/newsletter" element={<Newsletter />} />
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
<Route path="/site/about" element={<About />} />
<Route path="/site/status" element={<Status />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} />
{/* Installed modules' public pages, namespaced `/<id>/…` — the
registry prefixes the segment, so a module cannot spell its way
out of it (docs/website/MODULE_API.md §3.3). Declared before the
CMS catch-all below: React Router ranks a static segment over a
dynamic one, so the order is not what saves us, but keeping the
two adjacent makes the relationship visible to whoever adds the
next route here. */}
{routesFor('public').map((r) => (
<Route key={r.path} path={`/${r.path}`} element={r.element} />
))}
{/* CMS pages: top-level /:slug, matched only after the named routes
above (React Router ranks static routes over this dynamic one). */}
<Route path="/:slug" element={<CmsPage />} />
<Route index element={<Moderation />} />
<Route path="user/:discordId" element={<ModerationUser />} />
<Route path="appeals" element={<Appeals />} />
</Route>
{/* Draft-preview link (token-gated). Outside the maintenance gate so a
preview link works regardless of site mode. */}
<Route path="/preview/:id/:token" element={<CmsPage preview />} />
{/* Admin */}
<Route path="/admin/login" element={<AdminLogin />} />
<Route path="activity" element={<ActivityAdmin />} />
<Route path="bot-activity" element={<BotActivityAdmin />} />
<Route path="discord-bot" element={<DiscordBotAdmin />} />
<Route path="shard" element={<ShardAdmin />} />
<Route path="shard-visibility" element={<ShardVisibility />} />
<Route path="shard-atlas" element={<SpawnAtlasAdmin />} />
<Route
path="/admin"
path="shard-ops"
element={
<RequireAuth>
<AdminLayout />
</RequireAuth>
<RoleGate roles={['admin', 'moderator']}>
<ShardOps />
</RoleGate>
}
>
<Route index element={<Dashboard />} />
<Route path="posts" element={<PostsAdmin />} />
<Route path="pages" element={<PagesAdmin />} />
<Route path="pages/new" element={<PageBuilder />} />
<Route path="pages/:id" element={<PageBuilder />} />
<Route path="wiki" element={<WikiAdmin />} />
<Route path="hero" element={<HeroEditor />} />
{/* Theme editing writes an admin-only settings key; the route sits
behind the same RoleGate as the sidebar entry that reaches it,
and PUT/DELETE /admin/settings is admin-only server-side too. */}
<Route
path="appearance"
element={
<RoleGate roles={['admin']}>
<AppearanceAdmin />
</RoleGate>
}
/>
{/* Same reasoning as Appearance: the nav overrides are an admin-only
settings key, so the route carries the same RoleGate as the
sidebar entry that reaches it. */}
<Route
path="navigation"
element={
<RoleGate roles={['admin']}>
<NavEditor />
</RoleGate>
}
/>
<Route path="settings" element={<SettingsAdmin />} />
<Route
path="moderation"
element={
<RoleGate roles={['admin', 'moderator']}>
<Outlet />
</RoleGate>
}
>
<Route index element={<Moderation />} />
<Route path="user/:discordId" element={<ModerationUser />} />
<Route path="appeals" element={<Appeals />} />
</Route>
<Route path="activity" element={<ActivityAdmin />} />
<Route path="bot-activity" element={<BotActivityAdmin />} />
<Route path="discord-bot" element={<DiscordBotAdmin />} />
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
<Route path="users" element={<UsersAdmin />} />
<Route path="users/:id" element={<UserDetail />} />
<Route path="invites" element={<InvitesAdmin />} />
<Route path="account" element={<AccountAdmin />} />
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth
wrapper — only an optional { roles }, which core applies as the
same RoleGate its own routes above use, so the sidebar and the
route table cannot disagree about who may see what. Before the
`*` redirect, which would otherwise swallow every one of them. */}
{routesFor('admin').map((r) => (
<Route
key={r.path}
path={r.path}
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
/>
))}
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
{/* Player portal */}
<Route path="/account/login" element={<PlayerLogin />} />
<Route path="/account/register" element={<PlayerRegister />} />
<Route path="/account/forgot" element={<ForgotPassword />} />
<Route path="/account/reset/:token" element={<ResetPassword />} />
<Route path="/invite/:token" element={<AcceptInvite />} />
/>
<Route
path="houses"
element={
<RequirePlayer>
<PlayerPortalLayout />
</RequirePlayer>
<RoleGate roles={['admin', 'moderator']}>
<HousesAdmin />
</RoleGate>
}
>
{/* The portal index resolves to the first nav row this viewer can
reach rather than naming a page: `PlayerCharacters` was a UO
page and left with the client half (MODULE_SYSTEM.md §2.7.1).
With the UO module installed that is still Characters. */}
<Route path="/player" element={<PlayerIndex />} />
<Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} />
{/* Installed modules' player-portal pages, at /player/<id>/…. This
group's own routes are absolute (its layout route has no path),
so the prefix is written here rather than inherited — the one
place the three areas do not read alike. */}
{routesFor('player').map((r) => (
<Route
key={r.path}
path={`/player/${r.path}`}
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
/>
))}
</Route>
/>
<Route path="characters" element={<AdminCharacters />} />
<Route path="characters/:serial" element={<AdminCharacter />} />
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
<Route path="users" element={<UsersAdmin />} />
<Route path="users/:id" element={<UserDetail />} />
<Route path="invites" element={<InvitesAdmin />} />
<Route path="account" element={<AccountAdmin />} />
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth
wrapper — only an optional { roles } that core applies as the
same RoleGate its own routes use (MODULE_API.md §3.3). */}
{routesFor('admin').map((r) => (
<Route
key={r.path}
path={r.path}
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
/>
))}
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</ModuleFeaturesProvider>
{/* Player portal */}
<Route path="/account/login" element={<PlayerLogin />} />
<Route path="/account/register" element={<PlayerRegister />} />
<Route path="/account/forgot" element={<ForgotPassword />} />
<Route path="/account/reset/:token" element={<ResetPassword />} />
<Route path="/invite/:token" element={<AcceptInvite />} />
<Route
element={
<RequirePlayer>
<PlayerPortalLayout />
</RequirePlayer>
}
>
<Route path="/player" element={<PlayerCharacters />} />
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
<Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</SiteProvider>
</AuthProvider>
)

View File

@@ -42,19 +42,15 @@ function safeParse(text) {
}
}
// The request PRIMITIVE, exported for installed modules and handed to them on
// `window.__rg.api` (docs/website/MODULE_API.md §3.5). Core owns the fetch
// semantics — same-origin /api/v1, cookies included, JSON in and out, ApiError
// on a non-2xx — and nothing above them: a module owns the paths it calls,
// because it owns the routes at the other end.
// The request PRIMITIVE, exported for installed modules (window.__rg.api — see
// docs/website/MODULE_API.md §3.5). A module owns the paths it calls, because it
// owns the routes at the other end; core owns only the fetch semantics —
// same-origin /api/v1, cookies included, JSON in/out, ApiError on non-2xx.
//
// The `api` object below stays core's own binding surface. Its `atlas` and
// `shard` namespaces are module bindings that only still live here because
// Phase 3 has not moved them yet.
// `BASE` goes with it: a module that needs an EventSource URL cannot go through
// `req` (fetch-only) and must not hardcode `/api/v1`, which is core's choice of
// mount point and not a promise it has made.
export { req as request, BASE }
// `api` below stays core's own binding surface. Its `atlas` and `shard`
// namespaces are module bindings that only still live here because Phase 3 has
// not moved them yet.
export { req as request }
export const api = {
// ----- auth -----

View File

@@ -0,0 +1,293 @@
// Reusable character-sheet renderer for the char.profile shape returned by
// /public/shard/char/:serial. Presentational only — the parent handles loading
// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles).
//
// `moderation` opts in the in-game kick/ban controls for the character's account;
// they self-gate to staff (ShardAccountActions), so passing it from a page a
// player can reach is safe.
import ShardAccountActions from './ShardAccountActions.jsx'
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
// What to call an equipped item.
//
// Items on the wire carry a `LabelNumber`, not a name, so this used to be able
// to show nothing but the layer and `id 12345`. The server now resolves the
// cliloc against its own table and attaches `clilocName` (see
// docs/website/CLILOCS.md); a shard with no cliloc file configured sends none,
// and the layer fallback below is exactly what the sheet did before.
//
// A player-given `name` outranks the resolved type name — "Bob's lucky axe"
// should not be relabelled "hatchet" — and the server applies the same
// precedence, so this only re-states it for a profile that arrived with both.
const itemName = (it) => it.name || it.clilocName || it.layer || 'Item'
// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already
// computed display strings; reward entries may be a cliloc NUMBER-as-string or a
// literal string.
//
// `rewardResolved` is the server's parallel array with the numeric entries turned
// into words (null where the cliloc table had nothing, or is not configured at
// all). Prefer it, and keep the literal-only path as the fallback for a profile
// served before the cliloc table existed — a numeric entry with no resolution is
// still skipped rather than shown as a raw number.
function displayTitles(titles) {
if (!titles) return []
const out = []
if (titles.fameKarma) out.push(titles.fameKarma)
if (titles.skill) out.push(titles.skill)
const raw = Array.isArray(titles.reward) ? titles.reward : []
const resolved = Array.isArray(titles.rewardResolved) ? titles.rewardResolved : null
const reward = raw.map((r, i) => resolved?.[i] ?? (/^\d+$/.test(String(r)) ? null : String(r)))
const sel = typeof titles.selected === 'number' ? titles.selected : -1
// Prefer the selected reward title; fall back to the first one that resolved.
// The `??` matters: a selected title whose cliloc did not resolve must fall
// through to the fallback rather than suppress the chip entirely.
const candidate = (sel >= 0 && sel < reward.length ? reward[sel] : null) ?? reward.find(Boolean)
if (candidate) out.push(String(candidate))
return [...new Set(out.filter(Boolean))]
}
// The char.profile `points` block (Protocol 3.0 §7.3): one entry per point system
// the character actually holds a score in. Systems at zero are omitted by the
// shard, so an empty list means "this character has earned nothing anywhere",
// which is a normal state for a new character and renders as nothing at all.
//
// `nameString` may be null when the system's name is a cliloc; fall back to
// humanising the PointsType key, exactly as the leaderboards page does. `rank` is
// absent unless the shard runs with Bridge.cfg PointsProfileRank=true — absent and
// "unranked" are different, so the chip only appears when it was actually sent.
const humanisePoints = (key) =>
String(key || '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/^./, (c) => c.toUpperCase())
function PointsRow({ entry }) {
const label = entry.nameString || humanisePoints(entry.system)
const max = Number.isFinite(entry.maxPoints) && entry.maxPoints > 0 ? entry.maxPoints : 0
const pct = max ? Math.min(100, Math.round((entry.points / max) * 100)) : 0
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3, gap: 10 }}>
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>
{label}
{Number.isFinite(entry.rank) && (
<span className="dim" style={{ fontSize: '0.74rem' }}> · #{entry.rank}</span>
)}
</span>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
{(entry.points ?? 0).toLocaleString()}
{max > 0 && <span className="dim"> / {max.toLocaleString()}</span>}
</span>
</div>
{/* Only systems with a real cap get a bar; an uncapped score has nothing to
be a fraction of, and a full-width bar would imply completion. */}
{max > 0 && (
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
)}
</div>
)
}
function TitleChip({ children, tone = 'var(--muted)' }) {
return (
<span
className="sans"
style={{
fontSize: '0.72rem', padding: '3px 9px', borderRadius: 999,
border: `1px solid ${tone}55`, color: tone, whiteSpace: 'nowrap',
}}
>
{children}
</span>
)
}
function StatTile({ value, label }) {
return (
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
<div className="display" style={{ fontSize: '1.35rem', color: 'var(--head)' }}>{value}</div>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 4 }}>{label}</div>
</div>
)
}
function Vital({ label, cur, max }) {
const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0
return (
<div className="panel" style={{ padding: '12px 14px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
<span className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>{label}</span>
<span className="display" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>{cur ?? '—'}<span className="dim" style={{ fontSize: '0.8rem' }}> / {max ?? '—'}</span></span>
</div>
<div style={{ height: 6, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
)
}
export default function CharacterSheet({ char, moderation = false }) {
if (!char) return null
const stats = char.stats || {}
const resist = stats.resist || {}
// Skills the character actually has, best first.
const skills = (char.skills || [])
.filter((s) => (s.value || s.base || 0) > 0)
.sort((a, b) => (b.value || 0) - (a.value || 0))
const equipment = char.equipment || []
// Best standing first, so the character's strongest loyalty leads. Guarded for
// an older shard plugin that sends no `points` block at all.
const points = (Array.isArray(char.points) ? char.points : [])
.filter((p) => p && (p.points || 0) > 0)
.sort((a, b) => (b.points || 0) - (a.points || 0))
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
{/* Identity */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>{char.name || 'Unknown'}</h2>
{char.title && <span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>{char.title}</span>}
<span
className="sans"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 999,
border: '1px solid var(--line)', fontSize: '0.74rem',
color: char.online ? '#7fd0a4' : 'var(--muted)',
}}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: char.online ? '#7fd0a4' : 'var(--dim)' }} />
{char.online ? 'Online' : 'Offline'}
</span>
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
</div>
{/* Titles + standing (guild led / governorship) — all optional */}
{(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: -8 }}>
{char.governorOf && char.governorOf.map((city) => (
<TitleChip key={`gov-${city}`} tone="#c9a24b">Governor of {city}</TitleChip>
))}
{char.guild && (
<TitleChip tone="var(--accent)">
Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name}
</TitleChip>
)}
{displayTitles(char.titles).map((t) => <TitleChip key={t}>{t}</TitleChip>)}
</div>
)}
{/* Staff moderation for this character's account (self-gates to staff). */}
{moderation && char.acct && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
<span className="sans dim" style={{ fontSize: '0.76rem' }}>Account <strong style={{ color: 'var(--ink)' }}>{char.acct}</strong></span>
<ShardAccountActions account={char.acct} />
</div>
)}
{/* Core stats */}
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>
<div className="grid-3" style={{ gap: 12 }}>
<StatTile value={stats.str ?? '—'} label="Strength" />
<StatTile value={stats.dex ?? '—'} label="Dexterity" />
<StatTile value={stats.int ?? '—'} label="Intelligence" />
</div>
<div className="grid-3" style={{ gap: 12, marginTop: 12 }}>
<Vital label="Hits" cur={stats.hits} max={stats.hitsMax} />
<Vital label="Mana" cur={stats.mana} max={stats.manaMax} />
<Vital label="Stamina" cur={stats.stam} max={stats.stamMax} />
</div>
</section>
{/* Resistances */}
{Object.keys(resist).length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Resistances</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => (
<div key={k} className="panel" style={{ padding: '10px 16px', textAlign: 'center', minWidth: 84 }}>
<div className="display" style={{ color: 'var(--head)', fontSize: '1.1rem' }}>{resist[k] ?? 0}</div>
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.66rem', textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: 2 }}>{RESIST_LABELS[k]}</div>
</div>
))}
</div>
</section>
)}
{/* Skills */}
{skills.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Skills <span className="dim">({skills.length})</span></div>
<div className="grid-2" style={{ gap: '8px 18px' }}>
{skills.map((s) => {
const cap = s.cap || 100
const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100))
return (
<div key={s.n}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 }}>
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>{s.n}</span>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem' }}>{s.value}</span>
</div>
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
)
})}
</div>
</section>
)}
{/* Loyalty & points — one entry per system this character has scored in */}
{points.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>
Loyalty &amp; points <span className="dim">({points.length})</span>
</div>
<div className="grid-2" style={{ gap: '8px 18px' }}>
{points.map((p) => (
<PointsRow key={p.system} entry={p} />
))}
</div>
</section>
)}
{/* Equipment */}
{equipment.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{equipment.map((it) => {
const label = itemName(it)
const layer = it.layer || 'Item'
// The layer only earns its own line once the headline is a real
// name; when it IS the headline, repeating it is just noise.
const detail = [label === layer ? null : layer, `id ${it.itemId}`, it.hue ? `hue ${it.hue}` : null]
return (
<div key={it.serial} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: 5, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.05)' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{label}</div>
<div className="sans dim" style={{ fontSize: '0.74rem' }}>{detail.filter(Boolean).join(' · ')}</div>
</div>
{it.mods && Object.keys(it.mods).length > 0 && (
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
{Object.entries(it.mods).map(([k, v]) => (
<span key={k} className="pill" style={{ fontSize: '0.7rem', padding: '2px 8px' }}>{k} {v}</span>
))}
</div>
)}
</div>
)
})}
</div>
</section>
)}
</div>
)
}

View File

@@ -0,0 +1,79 @@
import { useEffect, useState } from 'react'
// A small stat-tile row for a "My Characters" page: total characters, how many
// are online right now, and how many game accounts are linked. `scope` is the
// shard api object (admin or player self-service). Renders nothing until an
// account is linked, so the empty/link-prompt state below it stands alone.
//
// It fetches the same rosters GameAccounts loads; for a personal page that's at
// most a couple of extra live round-trips, and keeps this presentational bit
// decoupled from GameAccounts' per-account roster loading.
function Tile({ value, label }) {
return (
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: 8 }}>
{label}
</div>
</div>
)
}
// Fold the settled roster results into totals. `complete` is false when any
// account's roster failed (a partial result — shown as a dash rather than a
// misleadingly low count).
function summarizeRosters(rosters) {
let chars = 0
let online = 0
let complete = true
for (const r of rosters) {
if (r.status !== 'fulfilled') {
complete = false
continue
}
const cs = r.value.chars || []
chars += cs.length
online += cs.filter((c) => c.online).length
}
return { chars, online, complete }
}
export default function CharacterStats({ scope }) {
const [stats, setStats] = useState(null)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const accounts = await scope.accounts()
const linked = accounts.length
if (linked === 0) {
if (!cancelled) setStats({ linked: 0 })
return
}
// Roster is a live round-trip and can be unavailable (503); tolerate a
// partial result so a restarting shard doesn't blank the whole row.
const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account)))
if (!cancelled) setStats({ linked, ...summarizeRosters(rosters) })
} catch {
if (!cancelled) setStats({ error: true })
}
})()
return () => { cancelled = true }
}, [scope])
// Hidden until we know an account is linked (or while first loading).
if (!stats || stats.error || stats.linked === 0) return null
// Counts depend on live rosters; show a dash if none came back.
const count = (n) => (stats.complete || stats.chars > 0 ? n : '—')
return (
<section className="grid-3" style={{ gap: 14, marginBottom: 26 }}>
<Tile value={count(stats.chars)} label="Characters" />
<Tile value={count(stats.online)} label="Online now" />
<Tile value={stats.linked} label={stats.linked === 1 ? 'Linked account' : 'Linked accounts'} />
</section>
)
}

View File

@@ -0,0 +1,69 @@
import { useState } from 'react'
// Reusable "create a game account" form (its own username + password — the game
// client credentials, distinct from the website login). Calls `submit(account,
// password)` which should POST /player/shard/account; on success calls onCreated.
// Used by the player portal (self-serve) and the invite-accept page alike.
export default function CreateGameAccountForm({ submit, onCreated, compact = false }) {
const [account, setAccount] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function onSubmit(e) {
e.preventDefault()
setMsg(''); setError('')
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/.test(account)) {
return setError('Account name must be 3–30 letters, numbers, . _ or -.')
}
if (password.length < 8) return setError('Password must be at least 8 characters.')
setBusy(true)
try {
await submit(account, password)
setMsg(`Game account “${account}” created and linked.`)
setAccount(''); setPassword('')
if (onCreated) await onCreated()
} catch (err) {
if (err.status === 409) setError('That account name is already taken.')
else if (err.status === 429) setError('The account limit for your network has been reached.')
else if (err.status === 403) setError('Game-account signup is not available right now.')
else if (err.status === 503) setError('The game server is unavailable — try again shortly.')
else setError(err.message || 'Could not create the account right now.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={onSubmit}>
{!compact && (
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Choose the username and password you’ll type into the game client. These are your
<strong style={{ color: 'var(--head)' }}> game</strong> credentials — separate from your website login.
</p>
)}
<label style={{ display: 'block', marginBottom: 14 }}>
<span className="field-label">Game account name</span>
<input
type="text" autoComplete="off" value={account}
onChange={(e) => setAccount(e.target.value)} className="input" placeholder="e.g. darrow"
/>
</label>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Game password</span>
<input
type="password" autoComplete="new-password" value={password}
onChange={(e) => setPassword(e.target.value)} className="input"
/>
</label>
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
{msg && <p className="sans" style={{ margin: '0 0 12px', color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</p>}
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Creating…' : 'Create game account'}
</button>
</form>
)
}

View File

@@ -0,0 +1,231 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loading, ErrorState } from './PageState.jsx'
import ShardAccountActions from './ShardAccountActions.jsx'
import CreateGameAccountForm from './CreateGameAccountForm.jsx'
import { api } from '../api/client.js'
// Shared game-account linking + character roster, used by both the player portal
// (/player) and the staff account page (/admin/account). `scope` is the api
// object with { link, accounts, roster } (player or admin self-service); `charTo`
// maps a serial to the route for that character's sheet. `readOnly` drops the
// link forms and self-voice copy for the admin case where staff view *another*
// user's accounts (no `scope.link`) at /admin/users/:id.
function LinkForm({ scope, onLinked, compact }) {
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function submit(e) {
e.preventDefault()
setMsg(''); setError('')
if (!code.trim()) return
setBusy(true)
try {
const { account } = await scope.link(code.trim())
setMsg(`Linked ${account}.`)
setCode('')
await onLinked()
} catch (err) {
setError(err.message || 'Could not link that code.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={submit} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: compact ? 0 : 6 }}>
<label style={{ display: 'block' }}>
{!compact && <span className="field-label">Link code</span>}
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
className="input"
autoComplete="off"
placeholder="AB12CD"
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
/>
</label>
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
{busy ? 'Linking…' : 'Link account'}
</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</form>
)
}
function AccountRoster({ scope, account, charTo }) {
const [roster, setRoster] = useState(null)
const [error, setError] = useState('')
const [unavailable, setUnavailable] = useState(false)
const load = useCallback(async () => {
setError(''); setUnavailable(false)
try {
setRoster(await scope.roster(account))
} catch (err) {
if (err.status === 503) setUnavailable(true)
else setError(err.message || 'Could not load this account.')
}
}, [scope, account])
useEffect(() => { load() }, [load])
if (unavailable) {
return (
<div>
<p className="sans" style={{ margin: '0 0 8px', color: '#e0b070', fontSize: '0.85rem' }}>The game server is restarting — try again shortly.</p>
<button className="pill" onClick={load}>Retry</button>
</div>
)
}
if (error) return <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
if (!roster) return <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>Loading…</p>
const chars = roster.chars || []
if (chars.length === 0) return <p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>No characters on this account.</p>
return (
<div className="grid-2" style={{ gap: 12 }}>
{chars.map((c) => (
<Link
key={c.serial}
to={charTo(c.serial)}
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', border: '1px solid var(--line)', borderRadius: 10, textDecoration: 'none', background: 'rgba(255,255,255,0.02)' }}
>
<span style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '1rem', textTransform: 'uppercase' }}>
{(c.name || '?').charAt(0)}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="display" style={{ color: 'var(--head)', fontSize: '1.02rem' }}>{c.name}</div>
<div className="sans" style={{ fontSize: '0.76rem', color: c.online ? '#7fd0a4' : 'var(--muted)' }}>{c.online ? 'Online' : 'Offline'}</div>
</div>
<span className="sans dim" style={{ fontSize: '1.1rem' }}>›</span>
</Link>
))}
</div>
)
}
// Compact per-account "Unlink" button for the admin (readOnly) view. Confirms,
// then calls onUnlink(account) and reloads. Errors surface inline.
function UnlinkButton({ account, onUnlink }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function go() {
if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return
setBusy(true); setError('')
try {
await onUnlink(account)
} catch (err) {
const byStatus = { 403: 'Protected account — refused.', 404: 'Not linked.' }
setError(byStatus[err.status] || err.message || 'Could not unlink.')
setBusy(false)
}
}
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<button type="button" onClick={go} disabled={busy} className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }}>
{busy ? 'Unlinking…' : 'Unlink'}
</button>
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.76rem' }}>{error}</span>}
</span>
)
}
export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) {
const [accounts, setAccounts] = useState(null)
const [error, setError] = useState('')
// Whether the site currently offers game-account creation (public flag). Only
// relevant for the self-service (non-readOnly) view with a createAccount scope.
const [signupOk, setSignupOk] = useState(false)
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])
useEffect(() => {
if (readOnly || !scope.createAccount) return
let active = true
api.publicSettings()
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
.catch(() => {})
return () => { active = false }
}, [readOnly, scope])
const canCreate = !readOnly && Boolean(scope.createAccount) && signupOk
if (error) return <ErrorState message={error} />
if (!accounts) return <Loading />
// No linked accounts. In read-only (admin viewing another user) this is just an
// empty state; otherwise it's the link-your-account prompt.
if (accounts.length === 0) {
if (readOnly) {
return (
<div className="panel" style={{ padding: 22 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
This user has not linked a game account.
</p>
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="panel" style={{ padding: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Already play? In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
one-time code, then enter it below to see your characters, stats, skills and vendors here.
</p>
<LinkForm scope={scope} onLinked={load} />
</div>
{canCreate && (
<div className="panel" style={{ padding: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Create a new game account</div>
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} />
</div>
)}
</div>
)
}
// Linked — characters grouped by account.
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
{accounts.map((a) => (
<section key={a.account}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
{a.account}
</div>
{onUnlink && <UnlinkButton account={a.account} onUnlink={async (acct) => { await onUnlink(acct); await load() }} />}
</div>
{moderation && <ShardAccountActions account={a.account} style={{ marginBottom: 12 }} />}
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
</section>
))}
{!readOnly && (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
<LinkForm scope={scope} onLinked={load} compact />
{canCreate && (
<div style={{ marginTop: 20 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Create another game account</div>
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} compact />
</div>
)}
</section>
)}
</div>
)
}

View File

@@ -0,0 +1,84 @@
import { useMemo } from 'react'
import { useAsync } from '../lib/useAsync.js'
import { useShardFeed } from '../lib/useShardFeed.js'
import { bucketize } from '../data/regionBuckets.js'
import { api } from '../api/client.js'
// Compact live "Players Online" widget. Loads the presence.online aggregate once,
// then keeps the total + region breakdown current from the presence.online SSE
// kind. The raw byRegion map is rolled up into display buckets (see
// data/regionBuckets.js). NOT a page — drop it into any panel/column.
const PRESENCE_KINDS = new Set(['presence.online'])
export default function PlayersOnline() {
const { loading, error, data } = useAsync(() => api.shard.presence())
const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 4 })
// The freshest snapshot wins: the newest buffered presence.online event, else
// the initial fetch.
const snapshot = events[0] || data
const { total, rows } = useMemo(() => {
const count = Number(snapshot?.count) || 0
const { rows: bucketRows } = bucketize(snapshot?.byRegion)
return { total: count, rows: bucketRows }
}, [snapshot])
return (
<section className="panel" style={{ padding: 20 }}>
<div
className="sans"
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}
>
<span
style={{
color: 'var(--accent)',
fontSize: '0.7rem',
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}
>
Players online
</span>
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)', lineHeight: 1 }}>
{loading ? '—' : total}
</span>
</div>
{error && (
<p className="sans dim" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
Population is unavailable right now.
</p>
)}
{!loading && !error && (
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
{rows.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>
{total > 0 ? 'Locations are settling…' : 'The realm is quiet.'}
</p>
) : (
rows.map((r) => (
<div
key={r.id}
className="sans"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
fontSize: '0.9rem',
color: 'var(--ink)',
}}
>
<span>{r.label}</span>
{/* tabular figures keep the right-aligned counts in a clean column */}
<span className="dim" style={{ fontVariantNumeric: 'tabular-nums' }}>{r.count}</span>
</div>
))
)}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,88 @@
import { useState } from 'react'
import { useAuth } from '../contexts/AuthContext.jsx'
import { api } from '../api/client.js'
// Compact in-game moderation controls (kick / ban / unban) scoped to a single
// game account. Reused wherever a linked account or character is shown to staff:
// the admin user-detail account list and the character sheet. Self-gates on role
// (admin/moderator) so it is safe to render inside components that players also
// see — a player never gets the controls, and the API enforces the same gate.
//
// `actor` is stamped server-side from the session; nothing here sends it. Kick is
// reversible (they reconnect) so it acts immediately; Ban reveals an inline
// confirm with an optional duration + reason before it fires.
export default function ShardAccountActions({ account, style }) {
const { user } = useAuth()
const [busy, setBusy] = useState('')
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
const [banOpen, setBanOpen] = useState(false)
const [durationSec, setDurationSec] = useState('')
const [reason, setReason] = useState('')
// Only staff who can actually use the write plane see the controls.
if (!user || !['admin', 'moderator'].includes(user.role) || !account) return null
async function run(label, fn, done) {
setBusy(label); setOk(''); setErr('')
try {
const r = await fn()
setOk(done(r))
} catch (e) {
setErr(e.message || 'Action failed.')
} finally {
setBusy('')
}
}
const kick = () =>
run('kick', () => api.admin.shardOps.kick({ account }), (r) => {
const n = r && r.sessions != null ? r.sessions : null
const plural = n === 1 ? '' : 's'
const sessions = n != null ? ` (${n} session${plural})` : ''
return `Kicked${sessions}.`
})
const unban = () => run('unban', () => api.admin.shardOps.unban(account), () => 'Unbanned.')
const ban = () =>
run('ban', () =>
api.admin.shardOps.ban({
account,
durationSec: durationSec === '' ? undefined : Number(durationSec),
reason: reason.trim() || undefined,
}),
() => {
setBanOpen(false)
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
return `Banned${when}.`
})
const btn = { fontSize: '0.72rem', padding: '4px 10px' }
return (
<div className="sans" style={{ display: 'flex', flexDirection: 'column', gap: 8, ...style }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
<button onClick={kick} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'kick' ? '…' : 'Kick'}</button>
<button onClick={() => { setBanOpen((v) => !v); setOk(''); setErr('') }} disabled={!!busy} className="btn btn-sq" style={{ ...btn, borderColor: '#d98b84', color: '#d98b84' }}>Ban…</button>
<button onClick={unban} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'unban' ? '…' : 'Unban'}</button>
{ok && <span style={{ color: '#7fd0a4', fontSize: '0.8rem' }}>{ok}</span>}
{err && <span style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
</div>
{banOpen && (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 8, padding: '10px 12px', border: '1px solid var(--line)', borderRadius: 8, background: 'rgba(217,139,132,0.06)' }}>
<label style={{ display: 'block' }}>
<span className="field-label">Duration (sec, blank = permanent)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" style={{ maxWidth: 150 }} />
</label>
<label style={{ display: 'block', flex: 1, minWidth: 160 }}>
<span className="field-label">Reason (optional)</span>
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
</label>
<button onClick={ban} disabled={busy === 'ban'} className="btn btn-primary btn-sq" style={{ borderColor: '#d98b84', background: '#d98b84', ...btn }}>
{busy === 'ban' ? 'Banning…' : `Confirm ban ${account}`}
</button>
</div>
)}
</div>
)
}

View File

@@ -1,14 +1,5 @@
import { Link } from 'react-router-dom'
import { useSite } from '../contexts/SiteContext.jsx'
import Slot from '../modules/Slot.jsx'
const FOOTER_SLOT = 'site.footer.status'
// Handed to the extension rather than left for it to guess. A module rendering
// its own link in this row should look like the row, and the alternative is
// every module restating core's colours and then drifting from them the next
// time this footer is themed.
const LINK_STYLE = { color: 'var(--accent)', textDecoration: 'none' }
export default function SiteFooter() {
const { contactEmail, siteTitle } = useSite()
@@ -45,14 +36,10 @@ export default function SiteFooter() {
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
{contactEmail}
</a>
{/* A module's spot in the footer, and core supplies only the
position and the styling: the label, the target and whether
anything renders at all are the module's (MODULE_API.md §3.7).
The separator goes through `wrap` rather than sitting beside the
slot, so it shares the extension's fate — no module installed and
a module whose link throws both render nothing here, rather than
the second leaving a stray middot behind. */}
<Slot name={FOOTER_SLOT} linkStyle={LINK_STYLE} wrap={(link) => <>&nbsp;·&nbsp;{link}</>} />
&nbsp;·&nbsp;
<Link to="/site/shard" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Shard Status
</Link>
&nbsp;·&nbsp;
<Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}>
Admin

View File

@@ -4,27 +4,23 @@ import MoonDot from './MoonDot.jsx'
import BrandLogo from './BrandLogo.jsx'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
import NavDropdown from './NavDropdown.jsx'
import { buildPublicNav, pruneNav } from '../lib/navOverrides.js'
import { navFor } from '../modules/registry.js'
import { parseJsonSetting } from '../lib/settingsJson.js'
import { withModuleNav } from '../modules/nav.js'
import { useFeatureGate } from '../modules/features.jsx'
// One consistent top nav for the whole public site. Every page gets the same
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
//
// A row may carry a `feature`, naming a surface an installed module can disable
// or gate to a higher audience; it is hidden when this viewer cannot reach it,
// so we never render a link that would 403. No CORE row carries one today — the
// nine that did were UO and left with the client half in slice 3 — but the gate
// is not dead code: a module's rows join this list and bring their own flags,
// resolved by the module that registered them (modules/featureGate.js).
// Entries carrying a `feature` are shard surfaces an admin can disable or gate
// to a higher audience (Admin -> Shard Visibility). They are hidden when this
// viewer can't reach them, so we never render a link that would 403. The gate
// itself is server-side; this is only about not advertising a dead end.
//
// Exported because Admin -> Navigation edits this list. It stays declared here,
// with this component as its owner: the editor may only relabel, reorder and
// hide what it finds, and `to`/`feature` are never its to change (§7). An
// installed module's rows join it in `withModuleNav` below — before the override
// merge, so an admin can edit those rows exactly as they edit these.
// hide what it finds, and `to`/`feature` are never its to change (§7).
export const NAV = [
{ label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' },
@@ -32,6 +28,14 @@ export const NAV = [
{ label: 'Five on Friday', to: '/site/five-on-friday' },
{ label: 'Newsletter', to: '/site/newsletter' },
{ label: 'Wiki', to: '/wiki' },
{ label: 'Shard', to: '/site/shard', feature: 'status' },
{ label: 'Champions', to: '/site/champs', feature: 'champs' },
{ label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
{ label: 'Governors', to: '/site/governors', feature: 'governors' },
{ label: 'Houses', to: '/site/houses', feature: 'houses' },
{ label: 'Rules', to: '/site/rules', feature: 'ruleset' },
{ label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
{ label: 'Market', to: '/site/market', feature: 'market' },
{ label: 'About', to: '/site/about' },
]
@@ -44,12 +48,7 @@ const linkStyle = ({ isActive }) => ({
export default function SiteHeader() {
const { user, loading } = useAuth()
const { siteTitle, settings } = useSite()
const isVisible = useFeatureGate()
// Core's rows plus every installed module's. Computed once: the registry is
// fixed before the first render and there is no unregistering, so this cannot
// change during a session (modules/nav.js).
const baseNav = useMemo(() => withModuleNav(NAV, 'public'), [])
const shardFeatures = useShardFeatures()
// An admin may relabel, reorder and hide these entries from Admin →
// Navigation, and may group them into dropdown sections alongside links of
@@ -62,10 +61,26 @@ export default function SiteHeader() {
// never opens onto nothing;
// • with no stored row this is the coded NAV, in code order, so an
// untouched instance renders exactly what it renders today.
// Installed modules' entries interleave into this list by `order` BEFORE the
// override merge, so an admin edits one nav rather than "core's, plus whatever
// the module appended" — and a module item is hideable and re-labelable
// exactly like a core one. `order` defaults high, which lands module entries
// where the UO items already sat: after the content links, before About.
const base = useMemo(() => {
const items = navFor('public')
if (items.length === 0) return NAV
const merged = [...NAV]
for (const item of items) {
const at = Number.isFinite(item.order) ? item.order : merged.length
merged.splice(Math.min(at, merged.length), 0, { label: item.label, to: item.to, feature: item.feature })
}
return merged
}, [])
const nav = useMemo(() => {
const tree = buildPublicNav(baseNav, parseJsonSetting(settings.nav_public))
return pruneNav(tree, isVisible)
}, [baseNav, settings.nav_public, isVisible])
const tree = buildPublicNav(base, parseJsonSetting(settings.nav_public))
return pruneNav(tree, (item) => !item.feature || canSee(shardFeatures, item.feature))
}, [base, settings.nav_public, shardFeatures])
// Where the auth entry points: staff → admin, player → portal, else sign in.
let account

View File

@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react'
import { ago } from '../lib/format.js'
// Owner-private recent player-vendor sales. `fetchSales` is the scope method
// (api.player.shard.sales / api.admin.shard.sales) — the server only returns
// sales for accounts linked to the caller.
export default function VendorSales({ fetchSales }) {
const [sales, setSales] = useState(null)
const [error, setError] = useState('')
useEffect(() => {
let active = true
fetchSales()
.then((rows) => active && setSales(rows))
.catch(() => active && setError('Could not load your vendor sales.'))
return () => { active = false }
}, [fetchSales])
if (error) return null
if (!sales) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<div className="field-label" style={{ marginBottom: 12 }}>Recent vendor sales</div>
{sales.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No vendor sales recorded yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{sales.map((s) => (
<li key={`${s.t}-${s.itemType}-${s.price}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} — {Number(s.price || 0).toLocaleString()}gp
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(s.t)}</span>
</li>
))}
</ul>
)}
</section>
)
}

View File

@@ -0,0 +1,31 @@
// Placeholder heraldry for the eight City-Loyalty cities. Each entry is a simple
// emoji sigil + a ring colour — enough to make the Governors board and the
// governor badge read as distinct "crests" today, swappable for real artwork
// later WITHOUT touching any component: drop an `img` (an imported asset URL or a
// public path) onto an entry and update CityCrest to prefer it.
//
// Keyed by the exact `city` string the sidecar sends (see INTEGRATION.md §4:
// Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia).
export const CITY_CRESTS = {
Britain: { sigil: '⚜', color: '#c9a24b', label: 'Britain' },
Moonglow: { sigil: '🔮', color: '#7f8fd0', label: 'Moonglow' },
Minoc: { sigil: '⚒', color: '#b0763f', label: 'Minoc' },
Trinsic: { sigil: '⚓', color: '#5f9bd0', label: 'Trinsic' },
Yew: { sigil: '🌳', color: '#5fb98a', label: 'Yew' },
Jhelom: { sigil: '⚔', color: '#c76f6f', label: 'Jhelom' },
SkaraBrae: { sigil: '🐎', color: '#9a8bbf', label: 'Skara Brae' },
NewMagincia: { sigil: '🕊', color: '#cfc3a0', label: 'New Magincia' },
}
const FALLBACK = { sigil: '🏰', color: '#8c96a5', label: '' }
// Look up a crest by the raw city key, tolerating spacing variants
// ("Skara Brae" / "New Magincia"). `label` falls back to the given name.
export function crestFor(city) {
if (!city) return FALLBACK
const key = String(city).replace(/\s+/g, '')
const crest = CITY_CRESTS[city] || CITY_CRESTS[key]
if (crest) return crest
return { ...FALLBACK, label: String(city) }
}

View File

@@ -0,0 +1,72 @@
// Roll the sidecar's raw presence.online `byRegion` map (many named ServUO
// regions) up into a handful of labelled display buckets for the "Players Online"
// widget. This is the ONE place to retune the grouping — edit BUCKETS (order +
// membership) and the widget follows. Anything not matched lands in "Wilderness"
// so the bucket counts always reconcile to the true total.
// Named cities/towns, matched as a prefix on the (space/apostrophe-stripped)
// region name so "skara brae", "serpent's hold", etc. all resolve. Kept as a
// list rather than one giant alternation regex (simpler to read and retune).
const TOWN_PREFIXES = [
'moonglow', 'minoc', 'trinsic', 'jhelom', 'yew', 'skarabrae', 'magincia',
'newmagincia', 'vesper', 'nujelm', 'cove', 'ocllo', 'serpenthold', 'serpentshold',
'wind', 'delucia', 'papua',
]
const normalizeRegion = (r) => String(r).toLowerCase().replace(/['’\s]/g, '')
// Ordered list of buckets. `label` shows in the widget; `match(region)` decides
// membership. First matching bucket wins; the last bucket is the catch-all.
export const BUCKETS = [
{
id: 'britain',
label: 'Britain',
// Passthrough for the capital + its immediate surrounds.
match: (r) => /^britain/i.test(r),
},
{
id: 'towns',
label: 'Towns',
// The other named cities/towns.
match: (r) => {
const norm = normalizeRegion(r)
return TOWN_PREFIXES.some((t) => norm.startsWith(t))
},
},
{
id: 'dungeons',
label: 'Dungeons',
match: (r) =>
/(despise|destard|deceit|shame|hythloth|covetous|wrong|terathan|fire|ice|orc cave|dungeon|abyss|doom|khaldun|wrong|blackthorn|exodus|labyrinth|underworld)/i.test(
r,
),
},
{
id: 'housing',
label: 'Housing',
// House regions expose themselves as named house/townhouse regions.
match: (r) => /(house|townhouse|homestead|tent)/i.test(r),
},
{
id: 'wilderness',
label: 'Wilderness',
// Catch-all: the unnamed "Wilderness" region + anything unmatched above.
match: () => true,
},
]
// Given a raw { region: count } map, return [{ id, label, count }] in BUCKETS
// order, dropping empty buckets, with the summed total also returned.
export function bucketize(byRegion = {}) {
const totals = new Map(BUCKETS.map((b) => [b.id, 0]))
let total = 0
for (const [region, n] of Object.entries(byRegion || {})) {
const count = Number(n) || 0
total += count
const bucket = BUCKETS.find((b) => b.match(String(region))) || BUCKETS[BUCKETS.length - 1]
totals.set(bucket.id, totals.get(bucket.id) + count)
}
const rows = BUCKETS.map((b) => ({ id: b.id, label: b.label, count: totals.get(b.id) })).filter(
(r) => r.count > 0,
)
return { rows, total }
}

View File

@@ -1,100 +0,0 @@
// Who may see a row of the admin sidebar, and where that lets them go.
//
// Plain JS, in its own file, for two reasons. It is shared — AdminLayout renders
// by it and Admin -> Navigation builds its palette by it (THEMING_AND_NAV.md
// §8.1), and a second copy of this answer is exactly the thing this file exists
// to abolish. And it is the closest thing in the client to an authorization
// decision, so it belongs somewhere the test runner can reach, which a .jsx file
// is not.
//
// **A row's own `roles` is the whole answer.** Until Phase 2 PR 8 this was
// `roles` AND a hardcoded `MOD_PATHS` list of five paths that confined
// moderators, AND a third prefix list in the redirect effect that disagreed with
// both (docs/website/MODULE_SYSTEM.md §1.4). A module's rows could never be
// added to a list core hardcodes, which is what forced the derivation — but the
// lists had already drifted from each other without a module in sight.
/**
* Can a viewer with this role see this row?
*
* Applied AFTER the override merge in both callers: an override is presentation
* and this is the boundary, so an override saying `hidden: false` on a row this
* role cannot see still shows nothing (THEMING_AND_NAV.md §7).
*
* A row with no `roles` is visible to everyone who reached the admin area at
* all — that is the self-service case (Account, My Characters), and staff are a
* superset of players.
*/
export function navItemVisibleTo(item, role) {
return !item.roles || item.roles.includes(role)
}
/**
* The paths a viewer with this role may reach, derived from the rows they see.
*
* Takes the BASE nav, never the override-merged one: an override must not be
* able to move this boundary in either direction. Hiding a row from a
* moderator's sidebar must not also bar them from the page behind it, and
* un-hiding one must not admit them to a page their role does not carry.
*
* @param {Array<{items: Array}>} baseNav the grouped admin nav
* @param {string} role
* @returns {Array<{to: string, exact: boolean}>}
*/
export function allowedPathsFor(baseNav, role) {
return (Array.isArray(baseNav) ? baseNav : [])
.flatMap((g) => g.items || [])
.filter((item) => navItemVisibleTo(item, role))
.map((item) => ({ to: item.to, exact: item.end === true }))
}
/**
* Is this pathname one of them?
*
* A row carrying `end` matches exactly — `/admin` is the dashboard, not a prefix
* of the whole admin area, and treating it as one would let every path through.
* Every other row also covers its sub-routes, which is what keeps
* `/admin/moderation/appeals/12` and a module's detail pages reachable without
* anyone listing them.
*/
export function isAllowedPath(pathname, allowed) {
return (allowed || []).some(({ to, exact }) =>
exact ? pathname === to : pathname === to || pathname.startsWith(`${to}/`),
)
}
/**
* The first place in this nav a viewer with this role can actually go.
*
* Added in Phase 3 slice 3, for the player portal, whose index route was
* `PlayerCharacters` — a UO page. When it left, `/player` had nothing behind it,
* and the three ways out were: redirect somewhere fixed, invent a core landing
* page, or resolve the index from the nav the viewer already has. This is the
* third, and it is the only one that keeps today's behaviour — with the module
* installed the first row is still Characters, so a player still lands on their
* characters after signing in, and with nothing installed they land on Account.
*
* **From the BASE nav, never the override-merged one**, the same rule
* `allowedPathsFor` follows and for a sharper version of the same reason: an
* override is presentation, and a landing page is behaviour. An admin reordering
* the sidebar must not silently change where everybody arrives, and — more to
* the point — must not be able to move it somewhere a role cannot follow.
*
* Deliberately generic, and deliberately in this file rather than in the portal
* layout. The admin area has the same shape of question (its index is a
* hardcoded Dashboard), and the direction of travel is one logged-in area that
* shows the right things for the viewer's permissions rather than two that
* duplicate each other. When that happens this is the function it needs, and it
* already answers for both nav shapes.
*
* @param {Array} baseNav flat or grouped, before overrides
* @param {string} role
* @param {string} fallback where to go when the viewer can see nothing at all
*/
export function firstDestinationFor(baseNav, role, fallback) {
const items = (Array.isArray(baseNav) ? baseNav : []).flatMap((entry) =>
entry && Array.isArray(entry.items) ? entry.items : [entry],
)
const first = items.find((item) => item && item.to && navItemVisibleTo(item, role))
return first ? first.to : fallback
}

View File

@@ -20,10 +20,7 @@
// Two shapes are supported, because two exist:
// flat [{ to, label, ... }] — public header, player portal
// grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar
// Exported for modules/nav.js, which has to answer the same question about the
// same array a moment earlier — one implementation, so the interleave and the
// merge can never disagree about which shape they are looking at.
export function isGrouped(nav) {
function isGrouped(nav) {
return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items))
}

View File

@@ -0,0 +1,128 @@
// Shared formatting for shard events — used by the public Shard page, the
// Activity feed, and the admin live feed. One place decides how each kind reads
// and which category/badge it belongs to.
function nameOf(who) {
if (!who) return 'Someone'
if (typeof who === 'string') return who
return who.name || who.acct || 'Someone'
}
const n = (v) => Number(v || 0).toLocaleString()
// A one-line human description of each event kind, keyed by kind. Each formatter
// takes the payload and returns a string. Conditional suffixes are pulled into
// locals so no template literal is nested inside another.
const DESCRIBERS = {
'vendor.sale': (p) => {
const qty = p.amount > 1 ? ` ×${p.amount}` : ''
return `${p.itemType || 'An item'}${qty} sold for ${n(p.price)}gp`
},
'player.death': (p) => {
const by = p.killer ? ` by ${nameOf(p.killer)}` : ''
return `${nameOf(p.who)} was slain${by}`
},
'player.murdered': (p) => {
const by = p.murderer ? ` by ${nameOf(p.murderer)}` : ''
return `${nameOf(p.victim)} was murdered${by}`
},
'mob.killed': (p) => `${nameOf(p.killer)} killed ${nameOf(p.killed)}`,
'skill.gain': (p) => {
const base = p.base != null ? ` (${p.base})` : ''
return `${nameOf(p.who)} gained ${p.skill}${base}`
},
'fame.change': (p) => `${nameOf(p.who)}’s fame changed to ${n(p.new)}`,
'karma.change': (p) => `${nameOf(p.who)}’s karma changed to ${n(p.new)}`,
'quest.complete': (p) => `${nameOf(p.who)} completed “${p.quest}”`,
'house.decay': (p) => {
const region = p.region ? ` — ${p.region}` : ''
return `${p.name || 'A house'} is now ${p.to || p.stage}${region}`
},
'mob.login': (p) => `${nameOf(p.who)} entered the world`,
'mob.logout': (p) => `${nameOf(p.who)} left the world`,
'economy.supply': (p) => `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts`,
'server.hello': (p) => `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles`,
'server.shutdown': () => 'Shard shut down',
'server.crashed': (p) => {
const err = p.error ? `: ${p.error}` : ''
return `Shard crashed${err}`
},
'champ.update': (p) => {
const where = p.name || p.type || 'A champion spawn'
if (p.status === 'active' && p.bossUp) {
const boss = p.boss ? ` (${p.boss})` : ''
return `${where}: boss is up${boss}`
}
if (p.status === 'active') {
const level = p.level != null ? ` — level ${p.level}` : ''
return `${where} is active${level}`
}
if (p.status === 'cooldown') return `${where} is on cooldown`
return `${where} is ${p.status || 'idle'}`
},
'champ.remove': () => `A champion spawn ended`,
// Support (help-page) queue + in-game moderation (admin channel only)
'page.new': (p) => `New ${p.type || 'help'} page from ${nameOf(p.sender)}`,
'page.updated': (p) => {
const claimed = p.handled ? ' (claimed)' : ''
return `Help page from ${nameOf(p.sender)} updated${claimed}`
},
'page.closed': (p) => `Help page ${p.pageId || ''} closed`,
'admin.audit': (p) => {
const on = p.target ? ` on ${p.target}` : ''
const origin = p.origin ? ` [${p.origin}]` : ''
return `${p.actor || 'Staff'} ${p.action || 'acted'}${on}${origin}`
},
// Staff / sensitive (admin channel only)
'audit.set': (p) =>
`${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})`,
'audit.command': (p) => {
const args = p.args ? ` ${p.args}` : ''
return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${args}`
},
'cheat.fastwalk': (p) => {
const ip = p.ip ? ` (${p.ip})` : ''
return `Fast-walk flagged: ${nameOf(p.who)}${ip}`
},
'account.login.attempt': (p) => {
const ip = p.ip ? ` from ${p.ip}` : ''
return `Login attempt: ${p.acct}${ip}`
},
'gold.change': (p) => {
const sign = p.delta >= 0 ? '+' : ''
return `${p.acct}: gold ${sign}${n(p.delta)} → ${n(p.new)}`
},
}
// A one-line human description of an event. Accepts either a stored event
// (with .payload) or a raw live frame (fields at top level).
export function describe(ev) {
const fmt = DESCRIBERS[ev.kind]
return fmt ? fmt(ev.payload || ev) : ev.kind
}
// Category grouping for the filter tabs.
// Vendor sales are intentionally NOT a public category — they are owner-private
// (a linked player sees their own under the portal). The admin live feed still
// describes vendor.sale via describe() below.
export const CATEGORIES = [
{ id: 'all', label: 'All', kinds: null },
{ id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] },
{ id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] },
{ id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] },
]
const CATEGORY_OF = (() => {
const m = {}
for (const c of CATEGORIES) if (c.kinds) for (const k of c.kinds) m[k] = c.id
return m
})()
export function categoryOf(kind) {
return CATEGORY_OF[kind] || 'other'
}
// Short badge label for a kind (the part after the dot, title-cased-ish).
export function kindLabel(kind) {
return String(kind || '').replace(/[._]/g, ' ')
}

View File

@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client.js'
// Which shard surfaces the current viewer may reach, from
// GET /public/shard/features. Admins configure this per feature (Admin → Shard
// Visibility), so the nav can't be a static list any more.
//
// This is PRESENTATION only. The gate is server-side: a disabled feature 404s
// and an out-of-rung one 403s whether or not the link is rendered. So while the
// answer is still in flight we return `null` and callers show their default set
// — better a link that briefly 403s than a nav that flickers in on every load.
//
// Cached module-level: the answer is per-viewer but stable for a session, and
// every consumer would otherwise refetch it on mount.
let cached = null
let inFlight = null
export function resetShardFeatures() {
cached = null
inFlight = null
}
export function useShardFeatures() {
const [features, setFeatures] = useState(cached)
useEffect(() => {
if (cached) return undefined
let alive = true
inFlight =
inFlight ||
api.shard
.features()
.then((data) => {
cached = { level: data.level, set: new Set(data.features || []) }
return cached
})
.catch(() => {
// A failed lookup must not blank the nav — fall back to "show
// everything" and let the server do the gating.
cached = null
inFlight = null
return null
})
inFlight.then((result) => {
if (alive) setFeatures(result)
})
return () => {
alive = false
}
}, [])
return features
}
// Convenience: true when `name` is visible, or when we don't know yet.
export function canSee(features, name) {
return !features || features.set.has(name)
}

View File

@@ -0,0 +1,54 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../api/client.js'
// Subscribe to the public shard live-event SSE stream and keep a rolling buffer
// of the most recent events. The browser talks to our own /public/shard/stream
// route (plain HTTP EventSource) — never the sidecar's WebSocket — so the token
// stays server-side and it works through any reverse proxy.
//
// EventSource auto-reconnects on drop, so there is no manual retry loop here; a
// `connected` flag is exposed for a small live/offline indicator. `filter` (a
// Set of kinds, optional) limits which events are buffered. `max` caps the
// buffer length.
export function useShardFeed({ url, filter, max = 40 } = {}) {
const [events, setEvents] = useState([])
const [connected, setConnected] = useState(false)
// Keep the latest filter in a ref so re-renders don't tear down the stream.
const filterRef = useRef(filter)
filterRef.current = filter
const streamUrl = url || api.shardStreamUrl
useEffect(() => {
// EventSource isn't available during SSR / very old browsers — degrade to
// "no live feed" rather than throwing.
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return undefined
const es = new EventSource(streamUrl, { withCredentials: true })
es.onopen = () => setConnected(true)
es.onerror = () => setConnected(false) // EventSource will retry on its own
es.onmessage = (msg) => {
let event
try {
event = JSON.parse(msg.data)
} catch {
return
}
if (!event || !event.kind) return
const f = filterRef.current
if (f && !f.has(event.kind)) return
setEvents((prev) => {
// Tag with a stable-ish local id for React keys (events carry t but can
// collide within a ms) and cap the buffer.
const next = [{ ...event, _id: `${event.kind}-${event.t}-${prev.length}` }, ...prev]
return next.slice(0, max)
})
}
return () => es.close()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [max, streamUrl])
return { events, connected }
}

View File

@@ -3,84 +3,24 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js'
import { declareSlot } from './modules/registry.js'
import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
// Installed modules arrive as `<script type="module" src="/modules/<id>/…">`
// tags the server injects into the shell (server/src/utils/htmlShell.js), placed
// after this bundle's own tag; module scripts execute in document order, so they
// resolve their externals against the global this call sets up
// (docs/website/MODULE_API.md §3.2).
// Installed modules are `<script type="module" src="/modules/<id>/entry.js">`
// tags the server injects into <head> (server/src/utils/htmlShell.js); module
// scripts are deferred, so they run after this bundle and resolve their
// externals against the global this call sets up.
publishSharedDependencies()
// Core registered a feature provider here until slice 3, under owner id `core`
// and namespace `uo`, so that the seam was exercised by real content from the
// day it was built. That prediction paid out exactly as written: the extraction
// deleted the registration and the hook it named, and SiteHeader was not touched.
// There is nothing for core to register now — no core nav row carries a
// `feature` — and the filter is a correct no-op until a module supplies one.
// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
// Render after DOMContentLoaded rather than immediately.
//
// Declared HERE, in core's own bundle, which is what makes the ordering a fact
// rather than a hope: module chunks are deferred scripts the shell injects after
// this one (§3.1), so a module can never reach registerExtension before the slot
// it names exists. "Unknown slot" therefore always means a typo or a version
// skew, never a load-order accident — which is why that case throws.
//
// Both slots are named for a PLACE, not for a meaning. `site.footer.status` is
// the spot in the footer's info row, not a declaration that core knows what a
// game server's status is; the label, the target and whether anything renders at
// all belong to whoever fills it. A slot typed by its content would put game
// semantics back into core, which is the thing Phase 3 takes out.
declareSlot('site.footer.status')
// Deliberately the same name as the server's slot (MODULE_API.md §2.4): one
// resource, one extension point, two halves. The module with routes under
// /api/v1/admin/users/:id is the module with something to show on that page.
declareSlot('admin.users.detail')
// The invite-acceptance page's optional next step. Core owns invites — staff are
// invited too — and owned the game-account step inside them until slice 3, which
// meant core reading a `gameAccountSignup` flag and posting to a shard route.
//
// Named for the place, like the other two: it is "the point after an invite has
// been accepted and before the invitee is sent on", not "create a game account".
// Whether there is a step at all is the filling module's decision, made from
// data core does not have; core renders the shell and a skip control, and hands
// over `onDone`. With the slot unfilled the invitee goes straight to the portal,
// which is what core's own code did whenever the flag was off.
declareSlot('player.invite.accepted')
// Core filled the first two itself until slice 3, with the components that were
// inline in SiteFooter.jsx and UserDetail.jsx. Both are gone: the module fills
// all three, and core's own fills had to go for it to be able to — the first
// fill wins, and core registered first (§3.7).
// Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes.
//
// Deferred scripts — which every `type="module"` script is — execute in document
// order and ALL of them finish before DOMContentLoaded fires. Waiting for that
// event is therefore the guarantee that every installed module has registered
// its routes before React reads the registry: no loading state, no re-render,
// and no ordering race between core's bundle and a module's. A module chunk that
// 404s or throws does not hold the event back, so a broken module costs its own
// pages and not the site.
//
// The readyState check below is `'complete'`, and it is not the obvious
// `'loading'`. A DEFERRED script — which every `type="module"` script is — runs
// after the document has been parsed, so by the time this line executes
// readyState is already `'interactive'`; DOMContentLoaded has NOT fired yet and
// still comes after every deferred script. Testing for `'loading'` therefore
// mounts immediately, before any module chunk has evaluated, and a module's
// routes are missing from the very first render — which looks exactly like a
// module that failed to load: its URL falls through to core's catch-all and
// redirects home. Found by loading a real chunk in a browser; no unit test in
// this repo can see it.
//
// `'complete'` is only reached after `load`, which is strictly later than any
// static deferred script, so this branch is the genuine "the event has already
// been and gone" case and not a wrong guess about our own timing.
// Deferred scripts execute in document order and all of them finish before
// DOMContentLoaded fires. Waiting for that event is therefore the guarantee that
// every installed module has finished registering its routes and nav before
// React reads the registry — no loading state, no re-render, no ordering race
// between core's bundle and a module's. If this bundle happens to evaluate after
// the event has already fired (a cached, fast path), readyState is checked and
// render runs at once.
function mount() {
createRoot(document.getElementById('root')).render(
<React.StrictMode>
@@ -91,8 +31,8 @@ function mount() {
)
}
if (document.readyState === 'complete') {
mount()
} else {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true })
} else {
mount()
}

View File

@@ -1,68 +0,0 @@
// ── <Slot> — where core renders a module's content ─────────────────────────
//
// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1; the normative
// contract is docs/website/MODULE_API.md §3.7.
//
// The read side of registry.js's extension slots. Core puts one of these where a
// module may contribute to a core page, and gets back either the filling
// component with the props core passed, or nothing at all.
//
// **Nothing at all is the important half.** An instance with no module installed
// renders the identical page it renders today, which is the same untouched-path
// guarantee `withModuleNav` makes for nav — and the reason a core layout can
// place a slot without also acquiring an empty-state to design.
import React from 'react'
import { extensionFor } from './registry.js'
/**
* Contain a module's render failure to the module's own section.
*
* This is where the client differs from the server, deliberately. A module
* *route* that throws costs the module's own page and core does not need to care.
* An extension throws inside CORE's page — the admin's user detail, the site
* footer — and the whole reason core keeps ownership of that page is that it
* stays usable. So a slot renders nothing and logs, rather than taking the
* surrounding page down with it.
*
* A class because that is what React gives us: there is no hook form of
* componentDidCatch, and this is the only error boundary core has.
*/
class SlotBoundary extends React.Component {
constructor(props) {
super(props)
this.state = { failed: false }
}
static getDerivedStateFromError() {
return { failed: true }
}
componentDidCatch(error) {
// Named so the console says whose fault it is: a blank section with an
// anonymous stack is how a module bug becomes core's support ticket.
console.error(`[modules] extension in slot "${this.props.name}" threw and was dropped`, error)
}
render() {
return this.state.failed ? null : this.props.children
}
}
/**
* @param {string} name the slot id, declared by core in main.jsx
* @param {function} [wrap] core markup that only makes sense AROUND a rendered
* extension — a separator, a heading, a rule. Called with the extension's
* element and rendered inside the boundary, so it shares the extension's fate:
* an unfilled slot and a failed one both render nothing at all, decoration
* included. Found in a browser, because the obvious alternative — asking
* whether the slot is filled and rendering the separator alongside — is right
* about the unfilled case and leaves a stray separator behind on the failed one.
* @param {object} props everything else is handed to the filling component
*/
export default function Slot({ name, wrap, ...props }) {
const Extension = extensionFor(name)
if (!Extension) return null
const element = <Extension {...props} />
return <SlotBoundary name={name}>{wrap ? wrap(element) : element}</SlotBoundary>
}

View File

@@ -1,56 +0,0 @@
// Which nav rows a viewer may see, when the answer belongs to a module.
//
// Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.5 states the problem);
// the contract is docs/website/MODULE_API.md §3.3.
//
// Nine of the sixteen rows in the public header used to carry a `feature`, and
// every one of them was a shard surface an admin can disable or gate to a higher
// audience. The provider that answered those questions moved out with the module
// in Phase 3 slice 3, and core cannot call it directly and still be a core. It
// keeps this generic seam instead, and the module fills it.
//
// **The namespace comes from the registration, not from the string.** A row's
// `feature` is resolved by the provider its OWN module registered, so a module
// author writes `feature: 'status'` exactly as it reads today: nothing parses a
// prefix, and a typo'd namespace is not a thing that can exist. Core's own rows
// carry no `moduleId` and resolve against the owner id `core` — which nothing
// registers now that the shard rows are gone, and that is the correct resting
// state rather than a gap: no core nav row carries a `feature`.
//
// Everything here fails OPEN, and that is deliberate: this is presentation, the
// gate is server-side
// (a disabled feature 404s and an out-of-rung one 403s whether or not a link was
// rendered), so an unknown answer shows the link rather than blanking the nav.
// The one thing a UI mistake must never do here is hide a page from someone
// entitled to it.
/**
* The predicate the layouts filter their nav with.
*
* @param {Map<string, {has: (name: string) => boolean} | null | undefined>} flagsByOwner
* one entry per registered provider, keyed by the id of the module that
* registered it. The value is whatever that provider's hook returned this
* render: a Set-like of the flags this viewer may see, or `null` while the
* answer is still in flight.
* @returns {(item: object) => boolean}
*/
export function buildFeatureGate(flagsByOwner) {
return function isVisible(item) {
if (!item || !item.feature) return true
const owner = item.moduleId ?? 'core'
// No provider for this owner: the row names a flag nothing answers for. That
// is the no-module-installed case — no core row carries a `feature` once the
// module is out — and it is a correct no-op rather than a hidden row.
if (!flagsByOwner || !flagsByOwner.has(owner)) return true
const flags = flagsByOwner.get(owner)
// Still loading, or a provider that returned something unusable. Both are
// "we do not know yet", and both show the link.
if (!flags || typeof flags.has !== 'function') return true
return flags.has(item.feature)
}
}
/** The gate an area with no providers gets: everything is visible. */
export const OPEN_GATE = () => true
export default buildFeatureGate

View File

@@ -1,65 +0,0 @@
import { createContext, useContext, useMemo, useState } from 'react'
import { featureProviders } from './registry.js'
import { buildFeatureGate, OPEN_GATE } from './featureGate.js'
// The React half of the feature seam. The decision logic is featureGate.js,
// which is plain JS and therefore testable in a runner with no DOM; this file is
// wiring, the same split registry.js and shared.js already use.
//
// **Calling a hook per provider inside a loop is the point, and it is legal
// here.** The rules of hooks require the same hooks in the same order on every
// render of a component — not a statically known list. The provider list is
// fixed before the first render (registration happens while module chunks
// evaluate, and main.jsx does not mount until DOMContentLoaded), there is no
// unregistering, and the snapshot below freezes it per component instance
// anyway. So the loop's length cannot change between renders of this provider,
// which is the actual requirement.
//
// A provider hook returns a Set-like of the flags this viewer may see, or `null`
// while it is still fetching. Core knows nothing else about it: what a flag
// means, how it is fetched, and what it is gated on are all the module's.
const FeatureGateContext = createContext(OPEN_GATE)
export function ModuleFeaturesProvider({ children }) {
// Snapshotted once. useState's initialiser runs on the first render only, so
// even a provider that somehow registered late cannot change this instance's
// hook count mid-life — it would be ignored until the next mount, which is a
// far better failure than a crashed render.
const [providers] = useState(featureProviders)
// eslint-disable-next-line react-hooks/rules-of-hooks -- fixed-length list, see above
const values = providers.map((provider) => provider.hook())
const gate = useMemo(
() => {
const byOwner = new Map()
// First registration wins for a given owner: a module that registers two
// namespaces answers its own nav rows from the first, rather than from
// whichever happened to be stored last.
providers.forEach((provider, i) => {
if (!byOwner.has(provider.id)) byOwner.set(provider.id, values[i])
})
return buildFeatureGate(byOwner)
},
// One dependency per provider — a fixed-length list, for the same reason the
// hook loop above is fixed-length.
// eslint-disable-next-line react-hooks/exhaustive-deps
[providers, ...values],
)
return <FeatureGateContext.Provider value={gate}>{children}</FeatureGateContext.Provider>
}
/**
* The predicate to filter nav rows with: `(item) => boolean`, true when the row
* carries no `feature` or when its module says this viewer may see it.
*
* Outside a provider it is the open gate, so a component rendered in isolation
* (a test, a preview) shows its whole nav rather than none of it.
*/
export function useFeatureGate() {
return useContext(FeatureGateContext)
}
export default ModuleFeaturesProvider

View File

@@ -1,174 +0,0 @@
// The interleave of module nav items into core's nav.
//
// Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.4 states the problem);
// the normative contract is docs/website/MODULE_API.md §3.3.
//
// **Module items join the BASE array, before anything else happens to it.** That
// is the whole design of this file and the override merge next door forces it:
// `applyNavOverrides` / `buildPublicNav` are keyed by `to` and drop any key the
// base array does not declare (lib/navOverrides.js — deliberately, so a deleted
// route cannot leave a stale row doing something unexpected later). Append
// module items *after* that merge and they are unreachable to Admin →
// Navigation: unorderable, unrelabellable, unhideable. Today's UO rows are all
// three of those things, so appending would make the extraction a visible
// regression for every operator who has ever touched their nav.
//
// So the pipeline gains one step at the front and nothing else changes:
//
// withModuleNav(NAV, area) → admin overrides → role/feature filter → rendered
//
// and the filter stays last, which is what keeps it the boundary an override
// cannot cross (THEMING_AND_NAV.md §7). MODULE_API.md §3.3 wrote those last two
// the other way round; the code is right and the contract was amended.
//
// The result is that a module row is, to everything downstream, an ordinary row.
// Nothing in navOverrides.js, NavEditor.jsx or the layouts knows a module exists.
import { navFor } from './registry.js'
import { isGrouped } from '../lib/navOverrides.js'
// Rows with no group of their own are collected under this key. A Symbol rather
// than a string so it cannot collide with a group an admin or a module names.
const UNGROUPED = Symbol('ungrouped')
/**
* Sort by effective position, where a row that asked for nothing keeps the index
* it already had. Three tie-breaks, in this order: an explicit `order` beats a
* coincidental index (the module said "third", so third), and two explicit
* orders keep registration order, which `navFor` has already put in scan order.
*
* The same rule byOrder/place use in lib/navOverrides.js, and it has to be — an
* admin who then drags that row is editing the position this produced.
*/
function place(entries) {
return entries
.map((entry, index) => ({ ...entry, index }))
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit) || a.index - b.index)
.map(({ item }) => item)
}
function entryFor(item, fallbackKey) {
return { item, key: item.order ?? fallbackKey, explicit: item.order !== undefined }
}
function coreEntries(items) {
return items.map((item, index) => ({ item, key: index, explicit: false }))
}
/** The `to`s a base nav already claims, flat or grouped. */
function claimedPaths(baseNav, grouped) {
return new Set(grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to))
}
/**
* Drop a module row whose `to` is already on the nav, and say so.
*
* Not a policy about where a module may link — it is that `to` is the KEY the
* override layer stores under and React renders by. Two rows sharing one would
* give an admin a single editor row that silently moves both, and a duplicate
* key in the rendered list. Dropping the newcomer keeps core's row, which is the
* one any existing override was written against.
*
* Fail-safe like every other read in this area: the offending row goes, its
* neighbours stay.
*/
function withoutCollisions(items, claimed) {
const out = []
for (const item of items) {
if (!item || typeof item.to !== 'string' || !item.to) continue
if (claimed.has(item.to)) {
console.warn(
`[modules] nav item "${item.to}" from module "${item.moduleId}" collides with an existing row and was dropped`,
)
continue
}
claimed.add(item.to)
out.push(item)
}
return out
}
// The flat navs — the public header and the player portal.
//
// No groups, so `order` is a position in the one list: core rows are keyed by
// their index and a module row by the `order` it asked for. A module row with no
// order appends after the coded ones, in registration order, rather than jumping
// to the front on a 0 default — the same choice buildPublicNav makes for an
// admin-created link.
function mergeFlat(baseNav, items) {
return place([...coreEntries(baseNav), ...items.map((item, i) => entryFor(item, baseNav.length + i))])
}
// The grouped nav — the admin sidebar.
//
// `group` names an existing core group and the row lands inside it: Moderation
// and System, where today's UO rows already sit (§1.4). An unknown group name
// creates a group at the end rather than dropping the row — a typo must cost a
// position, never a link. A row with no `group` at all lands in a trailing
// untitled group, which renders as ungrouped links; core does not invent a
// display title out of a module id.
//
// An ungrouped row is NOT folded into one of core's own untitled groups
// (Dashboard's, Account's): those are furniture pinned to the top and bottom of
// the sidebar, and a module page does not belong beside "Account".
//
// A group created here is a group as far as everything downstream is concerned,
// including as a destination in Admin → Navigation's "move to section" control:
// `readOverrides` builds its set of legal destinations from the base nav it is
// handed, which is this one.
function mergeGrouped(baseNav, items) {
const titles = new Set(baseNav.map((g) => g.title).filter((t) => typeof t === 'string'))
const into = new Map() // existing group title → rows
const fresh = new Map() // new group title (or UNGROUPED) → rows, first-seen order
for (const item of items) {
const named = typeof item.group === 'string' && item.group ? item.group : null
const key = named ?? UNGROUPED
const bucket = named !== null && titles.has(named) ? into : fresh
if (!bucket.has(key)) bucket.set(key, [])
bucket.get(key).push(item)
}
const kept = baseNav.map((g) => {
const incoming = into.get(g.title)
if (!incoming) return g
return {
...g,
items: place([...coreEntries(g.items), ...incoming.map((item, i) => entryFor(item, g.items.length + i))]),
}
})
const created = [...fresh.entries()].map(([key, rows]) => {
const items_ = place(rows.map((item, i) => entryFor(item, i)))
return key === UNGROUPED ? { items: items_ } : { title: key, items: items_ }
})
return [...kept, ...created]
}
/**
* The base nav a layout should render: core's coded array with every installed
* module's rows for this area interleaved into it.
*
* Returns `baseNav` ITSELF when no module registered anything for this area, so
* an instance with no modules installed renders the identical array it renders
* today — the same "untouched path" guarantee applyNavOverrides makes, and what
* makes a `useMemo` with an empty dependency list around this call honest.
*
* Safe to call once per component and cache: registration completes before the
* first render (main.jsx waits for DOMContentLoaded — MODULE_API.md §3.1) and
* there is no unregistering, so this answer cannot change during a session.
*
* @param {Array} baseNav the coded NAV, flat or grouped
* @param {'public'|'admin'|'player'} area
* @returns {Array} a nav of the same shape
*/
export function withModuleNav(baseNav, area) {
if (!Array.isArray(baseNav)) return []
const grouped = isGrouped(baseNav)
const items = withoutCollisions(navFor(area), claimedPaths(baseNav, grouped))
if (items.length === 0) return baseNav
return grouped ? mergeGrouped(baseNav, items) : mergeFlat(baseNav, items)
}
export default withModuleNav

View File

@@ -1,39 +1,23 @@
// ── The client-side module registry ────────────────────────────────────────
//
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7. The normative contract is
// docs/website/MODULE_API.md §3.3; where the two disagree, the contract wins.
// A module's prebuilt chunk registers its routes, nav entries and feature
// provider here, and App.jsx / the nav components read them back. This is the
// client half of docs/website/MODULE_API.md §3.3.
//
// A module's prebuilt chunk registers its routes, its nav entries and its feature
// provider here, and core reads them back. This is the client twin of the
// server's modules/loader.js — with one structural difference worth stating,
// because it is what makes the file this short: core *hands* the registry to the
// module (on `window.__rg`, see shared.js) rather than discovering it. There is
// nothing to scan, nothing to validate a manifest against, and no failure mode
// where half a module is registered.
// Timing is the whole design. Module chunks are `<script type="module" src>`
// tags injected into <head> by the server (utils/htmlShell.js). Module scripts
// are deferred, so they evaluate after the SPA's own bundle has run — which is
// where window.__rg is published — and before DOMContentLoaded. main.jsx waits
// for that same event before calling render(), so registration is complete
// before React reads any of this and there is no re-render to orchestrate.
//
// **Timing is the whole design.** Module chunks are `<script type="module" src>`
// tags the server injects before `</body>` (server/src/utils/htmlShell.js), after
// core's own bundle. Module scripts are deferred, so they evaluate after that
// bundle has run — which is where `window.__rg` is published — and all of them
// finish before DOMContentLoaded. main.jsx waits for that same event before
// calling render(), so registration is complete before React reads any of this.
//
// That is what buys the simplicity here: registration is a plain synchronous
// write with no subscribers, not an observable store, because nothing can
// register after the first render. If that ever stops being true it changes in
// this file and in main.jsx, not in a dozen consumers.
//
// What PR 7 wires up is `routesFor` (App.jsx). `navFor` and `featureProviderFor`
// are stored and returned faithfully but core does not read them yet — PR 8 adds
// the nav interleave and the feature-provider seam. Storing them is not the kind
// of accepting stub the server's registries refused to be: nothing is discarded
// here, so a module that registers nav in this core gets it back from `navFor`.
// Registration is therefore a plain synchronous write with no subscribers, not
// an observable store. If that ever changes, it changes here and not in twelve
// consumers.
const routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
// slot name → { Component, filledBy }.
const slots = new Map()
const featureProviders = new Map()
const registered = new Set()
const AREAS = ['public', 'admin', 'player']
@@ -43,26 +27,18 @@ function assertArea(area, call) {
}
/**
* Route components, by area.
*
* @param {string} id the module id — the URL segment its routes are namespaced under
* Route components for one area.
* @param {string} id the module id, used to namespace the URL segment
* @param {{public?: Array, admin?: Array, player?: Array}} byArea
* each entry `{ path, element, gate? }`. `path` is relative to the module's
* namespace; core prefixes it and mounts it inside the area's existing wrapper
* (`/<id>/…` under MaintenanceGate, `/admin/<id>/…` under RequireAuth +
* AdminLayout, `/player/<id>/…` under RequirePlayer + PlayerPortalLayout).
* `gate` is an optional `{ roles: [...] }` that core applies as its own
* RoleGate — a module cannot supply an auth wrapper, because the sidebar and
* the route table have to agree about who may see what (§3.3).
* each entry `{ path, element, gate? }`; `path` is relative to the module's
* namespace and core prefixes it (`/uo/…`, `/admin/uo/…`, `/player/uo/…`)
*/
export function registerRoutes(id, byArea) {
for (const [area, list] of Object.entries(byArea || {})) {
assertArea(area, 'registerRoutes')
for (const route of list || []) {
// Prefixed HERE rather than by the module: a module cannot claim a path
// outside its own namespace however it spells `path` — a leading `/`, a
// trailing one, or several — because it never gets to write the segment
// its routes hang under.
for (const route of list) {
// Prefixed here rather than by the module, so a module cannot claim a path
// outside its own namespace however it spells `path`.
const path = `${id}/${String(route.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '')
routes[area].push({ ...route, path, moduleId: id })
}
@@ -71,159 +47,52 @@ export function registerRoutes(id, byArea) {
}
/**
* Nav entries, interleaved into CORE groups rather than appended as a block.
*
* Today's UO items sit inside core's own Moderation and System groups; a "UO"
* group at the bottom of the sidebar would be a visible regression on the day
* the module is extracted (MODULE_SYSTEM.md §1.4). `group` names an existing
* core group, `order` sorts within it, and an unknown group name appends rather
* than dropping the item — a mis-typed group must cost a position, never a link.
*
* `icon` is a component core renders exactly as it renders its own rows' icons
* (1.3.0). It exists because without it the six UO rows would have extracted as
* the only text-only entries in a sidebar where every other row has a glyph,
* which reads as breakage rather than as a design. Core does not supply a
* fallback: a module that omits it gets no icon, the same as a core row that
* omits it, and inventing one would be core making a presentation choice for
* content it knows nothing about. Note that `icon` is already among the fields
* an override may not touch (lib/navOverrides.js) — the concept predates a
* module being able to supply one.
*
* Nav entries, interleaved into CORE groups rather than appended as a block —
* today's UO items sit inside core's Moderation and System groups, and a "UO"
* group at the bottom would be a visible regression (MODULE_SYSTEM.md §1.4).
* @param {string} id
* @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?, icon?}>}} spec
* @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?}>}} spec
*/
export function registerNav(id, spec) {
const { area, items } = spec || {}
assertArea(area, 'registerNav')
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
registered.add(id)
}
/**
* The hook that answers "which of this module's features may this viewer see".
*
* Core keeps a generic flag context and owns none of the semantics
* (MODULE_SYSTEM.md §1.5). With no module installed the nav filter is a correct
* no-op, because no core nav item carries a `feature` — which has been literally
* true since Phase 3 slice 3 took the nine shard-gated rows out.
* Core keeps a generic flag context and owns none of the semantics; with no
* module installed the nav filter is a correct no-op, because no core nav item
* carries a `feature` today (MODULE_SYSTEM.md §1.5).
*/
export function registerFeatureProvider(id, namespace, hook) {
providers.set(namespace, { id, hook })
registered.add(id)
featureProviders.set(namespace, { id, hook })
}
// ── Extension slots (§3.7) ─────────────────────────────────────────────────
//
// The client twin of the server's declareSlot/registerExtension, and the same
// rule in both halves: core declares a slot, ONLY core declares one, and at most
// one module fills it. Core renders `<Slot name>` (Slot.jsx) and gets nothing
// back when the slot is unfilled — so an instance with no module installed
// renders exactly what it renders today.
//
// A slot is named for a PLACE, never for a meaning. `site.footer.status` is a
// position in the footer and the styling that goes with it; the label, the
// target, the data and whether anything renders at all are the module's. The
// moment core types a slot by its content it has re-acquired the game semantics
// this whole extraction removes.
/**
* @param {string} name the slot id. Core-only — deliberately not on the
* `registry` object handed to modules.
*/
export function declareSlot(name) {
if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`)
slots.set(name, { Component: null, filledBy: null })
}
/**
* Fill a declared slot with a component.
*
* **This is the one place the client registry is not fail-open**, and the
* asymmetry is deliberate. A dropped nav row costs a link the viewer can reach
* another way; a silently dropped extension is invisible to everyone including
* its author. So an unknown slot, a non-component, and a second fill all throw —
* exactly as the server's checkExtensionShape does.
*
* A throw here is always a programming error and never a race, because
* declaration structurally precedes filling: core declares in main.jsx, inside
* its own bundle, and every module chunk is a deferred script injected after it
* (§3.1).
*/
export function registerExtension(id, slot, Component) {
const entry = slots.get(slot)
if (!entry) throw new Error(`registerExtension: unknown extension slot "${slot}"`)
if (typeof Component !== 'function') throw new Error(`registerExtension: ${slot} is not a component`)
if (entry.filledBy) throw new Error(`extension slot "${slot}" is already filled by "${entry.filledBy}"`)
entry.Component = Component
entry.filledBy = id
registered.add(id)
}
/**
* The filling component, or null.
*
* Read by Slot.jsx and nothing else — deliberately. There is no `hasExtension`
* for a core layout to branch on, because a layout that asks whether a slot is
* filled and then renders its own decoration alongside gets the *failed* case
* wrong: the extension is filled, so the decoration renders, and the component
* then throws into the boundary leaving the decoration behind on its own. Core
* decorates through `<Slot wrap>` instead, which puts the decoration inside the
* boundary where it shares the extension's fate. (Found in a browser, with the
* footer's separator.)
*
* Undeclared and unfilled both read null: reading is fail-safe, and only writing
* is strict.
*/
export const extensionFor = (slot) => (slots.get(slot) || {}).Component || null
export const routesFor = (area) => routes[area] || []
// Sorted by the `order` a module asked for. Array#sort is stable in every engine
// this ships to, so two modules asking for the same slot keep load order —
// which is alphabetical by id, the same order the server scans in (§4.2).
// Sorted by the `order` a module asked for, stable within equal orders so two
// modules registering the same slot stay in load (alphabetical id) order.
export const navFor = (area) =>
[...(nav[area] || [])].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
export const featureProviderFor = (namespace) => providers.get(namespace)
/**
* Every registered provider, for core's feature context to call.
*
* Exported from the module but deliberately NOT a member of the `registry`
* object below: a module asks for a namespace it knows the name of, and has no
* business enumerating what everyone else registered. Core needs the list
* because it has to call each hook — unconditionally, in a fixed order, at the
* top of a component (modules/features.jsx).
*/
export const featureProviders = () =>
[...providers.entries()].map(([namespace, { id, hook }]) => ({ id, namespace, hook }))
export const featureProviderFor = (namespace) => featureProviders.get(namespace)
export const registeredIds = () => [...registered]
/** Test seam. Nothing in the app calls this — there is no unregistering. */
// Test seam.
export function _reset() {
for (const area of AREAS) {
routes[area].length = 0
nav[area].length = 0
}
providers.clear()
// Declarations go too, unlike the server's, where a slot is declared once at
// require time by the router that owns it. Core declares its slots in
// main.jsx — the one file no test loads — so on this side there is nothing
// declared at import time for a surviving declaration to protect.
slots.clear()
featureProviders.clear()
registered.clear()
}
// The object handed to modules on window.__rg.registry. Deliberately the write
// calls plus the read ones: a module reading `routesFor` is how it finds out
// another module is installed, which is the only supported form of module-to-
// module awareness (there is no dependency resolution).
export const registry = {
registerRoutes,
registerNav,
registerFeatureProvider,
registerExtension,
routesFor,
navFor,
featureProviderFor,

View File

@@ -1,31 +1,22 @@
// ── window.__rg — the shared-dependency global ─────────────────────────────
//
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7; the normative shape is
// docs/website/MODULE_API.md §3.2.
// A module's client half is a PREBUILT ESM chunk (the operator never builds
// anything), served same-origin, and loaded under `script-src 'self'` with no
// 'unsafe-inline'. That combination is what rules out an import map: an import
// map has to be an inline <script type="importmap">, and CSP forbids it
// (MODULE_SYSTEM.md §1.14). So the shared dependencies ride on a global and the
// module's externals resolve against it — docs/website/MODULE_API.md §3.2.
//
// A module's client half is a PREBUILT ESM chunk — the operator never builds
// anything (MODULE_SYSTEM.md §1.14) — served same-origin and loaded under
// `script-src 'self'` with no 'unsafe-inline'. That combination is what rules out
// an import map: an import map has to be an inline `<script type="importmap">`,
// and the policy forbids inline scripts outright. So the shared dependencies ride
// on a global, and the module's Rollup externals are aliased to two-line shims
// that re-export from it (§3.6).
//
// **There is exactly one React in the page and core owns it.** A module that
// bundled its own would get a second hook dispatcher and fail at its first
// useState. That is the same rule the server half enforces for `express` and
// `express-validator` on `ctx`, and for the same reason: anything shared between
// core and a module is owned by core and HANDED OVER, never resolved by the
// module.
// There is exactly ONE React in the page and core owns it. A module that bundled
// its own would get a second hook dispatcher and fail at the first useState.
import * as react from 'react'
import * as reactDom from 'react-dom/client'
import * as router from 'react-router-dom'
// The automatic JSX runtime, and it is not decoration. A module's bundler
// compiles every .jsx file to imports from `react/jsx-runtime` under the modern
// default, and those have to resolve to CORE's React like every other import.
// Without it here a module would have to build with `jsxRuntime: 'classic'`;
// with it, a module uses the default its tooling already assumes.
// The automatic JSX runtime. Without this a module would have to build with
// `jsxRuntime: 'classic'` — its bundler emits `react/jsx-runtime` imports by
// default, and those have to resolve to CORE's React like every other one.
// Exposing it here is what lets a module use the modern default.
import * as jsxRuntime from 'react/jsx-runtime'
import { registry } from './registry.js'
@@ -37,24 +28,12 @@ import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
import { useAsync } from '../lib/useAsync.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import { request, ApiError, BASE } from '../api/client.js'
import { request, ApiError } from '../api/client.js'
// The UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These
// seven are what the smallest UO page already needs beyond React and the router:
// without them a module either reaches into core's tree — violating the
// zero-import rule the whole boundary rests on — or ships its own copies, which
// means a module page that does not look like the site it is installed in, and
// that drifts further every time core's layout changes.
//
// Adding a member is a MINOR MODULE_API_VERSION bump; changing a member's props
// is a MAJOR one. That is a real constraint on core's own refactoring and it is
// the price of the boundary being worth anything.
//
// `AdminPage` appears in §3.4's table and is deliberately absent: core has no
// such component — admin views are plain markup inside AdminLayout — and
// inventing one to satisfy a table would be a core change with no consumer until
// Phase 3. The contract is amended rather than the code padded, and adding it
// later costs a minor bump, which is exactly the case the versioning is for.
// The kit is CURATED AND CLOSED, not a re-export of components/ — see §3.4.
// Adding to it is a minor MODULE_API_VERSION bump; changing a member's props is
// a major one. That is a real constraint on core, and it is the price of module
// pages looking like the site they are installed in.
const ui = {
PublicLayout,
PageHeader,
@@ -66,26 +45,12 @@ const ui = {
useSite,
}
// The request PRIMITIVE, not the `api` object (§3.5): a module builds its own
// namespace over `request` and owns the paths it calls, which is right, because
// it owns the routes at the other end.
//
// `BASE` was in §3.5 from the start and missing from this object until slice 3,
// which is when something first needed it. `request` is fetch-only, so an
// EventSource — the shard's live feed is two of them — has to build its own URL,
// and the alternative is a module hardcoding `/api/v1`: an assertion about where
// core mounts its API that core has never promised to keep.
const api = { request, ApiError, BASE }
// The request PRIMITIVE, not the api object: api.atlas and api.shard are module
// bindings that live in core's client today and move out with the module (§3.5).
// A module owns the paths it calls, which is right — it owns the routes at the
// other end.
const api = { request, ApiError }
/**
* Publish `window.__rg`. Called by main.jsx before it renders, and before any
* module chunk evaluates.
*
* Frozen, one level down as well as at the top: the object a module reaches for
* its React is not somewhere a module gets to leave something for the next one.
* Cross-module communication is a thing the contract does not have, and an
* unfrozen global is how a codebase acquires one by accident.
*/
export function publishSharedDependencies() {
window.__rg = Object.freeze({
version: MODULE_API_VERSION,
@@ -97,5 +62,4 @@ export function publishSharedDependencies() {
ui: Object.freeze(ui),
api: Object.freeze(api),
})
return window.__rg
}

View File

@@ -1,29 +1,8 @@
// The client's copy of MODULE_API_VERSION. It must equal the server's
// (server/src/modules/version.js) — the two halves version ONE contract
// (docs/website/MODULE_API.md §1.1), and a module checks whichever half it is
// talking to: `coreApi` against the server's at load time, `window.__rg.version`
// against the client's before it registers anything.
// The client's copy of MODULE_API_VERSION. Must equal the server's
// (server/src/modules/version.js) — they version ONE contract, and a module
// checks whichever half it is talking to.
//
// Duplicated rather than fetched, and that is deliberate. The value has to be on
// `window.__rg` before the first module chunk evaluates, which is earlier than
// any network round trip could answer — a fetched version would mean either an
// await before render or a module reading `undefined`. The cost of the copy is
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
// 1.3.0 — three additions, all from Phase 3 slice 3 needing them: a nav item may
// carry an `icon` component (§3.3), core declares a third slot
// `player.invite.accepted` (§3.7), and `window.__rg.api` gained `BASE`, which
// §3.5 always documented and shared.js never published. Additive throughout: a
// module written against 1.2.0 is unaffected. The server half is untouched and
// bumps anyway, for the reason below.
// 1.2.0 — `registry` gained `registerExtension` and core gained extension slots
// (MODULE_API.md §3.7). The first change to window.__rg since 1.0.0, and an
// addition: a module that never fills a slot is unaffected. The server half is
// untouched and bumps anyway, for the reason below.
// 1.1.0 — the server's ctx gained activity.log, users.getById, site.baseUrl and
// the rate-limit factory (MODULE_API.md §2.3). Nothing on window.__rg changed,
// but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question.
export const MODULE_API_VERSION = '1.3.0'
// Duplicated rather than fetched: the value has to be on window.__rg before the
// first module script evaluates, and that is earlier than any network round trip.
// A test asserts the two files agree.
export const MODULE_API_VERSION = '1.0.0'

View File

@@ -6,9 +6,6 @@ import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js'
import { useNavOverrides } from '../../lib/useNavOverrides.js'
import { withModuleNav } from '../../modules/nav.js'
import { useFeatureGate } from '../../modules/features.jsx'
import { navItemVisibleTo, allowedPathsFor, isAllowedPath } from '../../lib/adminNav.js'
// Small inline stroke icons (16px, currentColor) — same style as ProviderIcon.
// One shared frame keeps them terse; each item just supplies its path(s).
@@ -43,6 +40,7 @@ const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
const IconShard = () => <Icon><path d="M12 2l7 6-7 14-7-14z" /><path d="M5 8h14" /></Icon>
const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
@@ -75,6 +73,8 @@ export const NAV = [
items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] },
{ to: '/admin/houses', label: 'Houses', icon: IconShard, roles: ['admin', 'moderator'] },
],
},
{
@@ -88,11 +88,15 @@ export const NAV = [
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
{ to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] },
{ to: '/admin/shard-visibility', label: 'Shard Visibility', icon: IconShard, roles: ['admin'] },
{ to: '/admin/shard-atlas', label: 'Spawn Atlas', icon: IconShard, roles: ['admin'] },
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
],
},
{
items: [
{ to: '/admin/characters', label: 'My Characters', icon: IconShard },
{ to: '/admin/account', label: 'Account', icon: IconUser },
],
},
@@ -100,6 +104,10 @@ export const NAV = [
const COLLAPSE_KEY = 'admin.nav.collapsed'
// Moderators only get the moderation section (Discord + in-game ops) + their
// own account security.
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
// The one row an override may never hide: the nav editor itself, which is the
// only screen that can un-hide anything. The write path already refuses it
// (server/src/utils/navOverrides.js) and the editor's own toggle is disabled —
@@ -115,11 +123,15 @@ function keepEditorReachable(overrides) {
return { ...overrides, [UNHIDEABLE]: rest }
}
// Who may see a sidebar row, and where that lets them go, both derived from the
// row's own `roles` — lib/adminNav.js, which is where the two hardcoded path
// lists this component used to carry went (MODULE_SYSTEM.md §1.4). Re-exported
// because Admin -> Navigation has always imported it from here.
export { navItemVisibleTo }
// Who may see a sidebar row. The single authority for that question: the layout
// applies it after the override merge (overrides are presentation, this is the
// boundary — §7), and Admin -> Navigation applies it to build its palette, so an
// admin is never offered a row they cannot themselves see (§8.1).
export function navItemVisibleTo(item, role) {
if (item.roles && !item.roles.includes(role)) return false
if (role === 'moderator') return MOD_PATHS.includes(item.to)
return true
}
const TITLES = {
'/admin': 'Dashboard',
@@ -129,34 +141,28 @@ const TITLES = {
'/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation',
'/admin/moderation/appeals': 'Appeals',
'/admin/shard-ops': 'In-Game Ops',
'/admin/houses': 'House Registry',
'/admin/settings': 'Site Settings',
'/admin/appearance': 'Appearance',
'/admin/navigation': 'Navigation',
'/admin/activity': 'Activity Log',
'/admin/bot-activity': 'Web Bot Activity',
'/admin/discord-bot': 'Discord Bot',
'/admin/shard': 'Shard (uo-link)',
'/admin/shard-visibility': 'Shard Visibility',
'/admin/shard-atlas': 'Spawn Atlas',
'/admin/characters': 'My Characters',
'/admin/auth-providers': 'Authentication',
'/admin/users': 'Users',
'/admin/invites': 'Invites',
'/admin/account': 'Account Security',
}
// An installed module's admin pages are not in TITLES and cannot be — core does
// not know what they are called. Their nav row does, so the row is the title:
// the longest matching module row wins, so a detail page under a section titles
// as that section rather than falling through to a bare "Admin". Restricted to
// rows a module registered, which is what keeps every core path resolving
// through TITLES and sectionTitle exactly as it does today.
function moduleTitle(baseNav, pathname) {
return baseNav
.flatMap((g) => g.items)
.filter((i) => i.moduleId && (pathname === i.to || pathname.startsWith(`${i.to}/`)))
.sort((a, b) => b.to.length - a.to.length)[0]?.label
}
// Fallback page title for dynamic sub-routes not in the exact-match TITLES map.
function sectionTitle(pathname) {
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
if (pathname.startsWith('/admin/characters')) return 'My Characters'
if (pathname.startsWith('/admin/users/')) return 'User'
return 'Admin'
}
@@ -180,15 +186,7 @@ export default function AdminLayout() {
const navOverrides = useNavOverrides()
const navigate = useNavigate()
const location = useLocation()
const isVisible = useFeatureGate()
// Core's rows plus every installed module's, before the override merge sees
// them — so a module row is editable in Admin -> Navigation like any other
// (modules/nav.js). Computed once: the registry is fixed before the first
// render and nothing unregisters.
const baseNav = useMemo(() => withModuleNav(NAV, 'admin'), [])
const title =
TITLES[location.pathname] || moduleTitle(baseNav, location.pathname) || sectionTitle(location.pathname)
const title = TITLES[location.pathname] || sectionTitle(location.pathname)
// The hero canvas editor needs room — let it use the full content width.
const wide = location.pathname === '/admin/hero'
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
@@ -202,17 +200,11 @@ export default function AdminLayout() {
// NAV itself and this is exactly the code that ran before the feature.
const navGroups = useMemo(
() =>
applyNavOverrides(baseNav, keepEditorReachable(navOverrides.nav_admin))
.map((g) => ({
...g,
// `isVisible` is a no-op for every core row — none carries a `feature`
// — and is applied here so that a module row which does carry one is
// gated on the sidebar rather than silently advertised.
items: g.items.filter((item) => navItemVisibleTo(item, user?.role) && isVisible(item)),
}))
applyNavOverrides(NAV, keepEditorReachable(navOverrides.nav_admin))
.map((g) => ({ ...g, items: g.items.filter((item) => navItemVisibleTo(item, user?.role)) }))
// Drop any now-empty group so an empty category header never renders.
.filter((g) => g.items.length > 0),
[baseNav, navOverrides.nav_admin, user?.role, isVisible],
[navOverrides.nav_admin, user?.role],
)
// Accordion: track which titled categories are collapsed. Persist across
@@ -239,22 +231,17 @@ export default function AdminLayout() {
g.title && g.items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
)?.title
// Where a moderator may go, from the same `roles` that decide what they see.
// It used to be a third hardcoded list — a prefix check over three paths —
// which disagreed with the sidebar's own five-path allowlist: `/admin/houses`
// was on the sidebar and not in the redirect, so a moderator who clicked
// Houses in their own nav was bounced straight back to Moderation. One
// derivation cannot disagree with itself, which is the point of deriving it.
const allowed = useMemo(() => allowedPathsFor(baseNav, user?.role), [baseNav, user?.role])
// Confine a moderator who deep-links (or is redirected to the index) to a page
// outside their remit — the API would 403 anyway, so send them to their home.
useEffect(() => {
if (!isModerator) return
if (!isAllowedPath(location.pathname, allowed)) {
const p = location.pathname
const allowed =
p.startsWith('/admin/moderation') || p.startsWith('/admin/shard-ops') || p === '/admin/account'
if (!allowed) {
navigate('/admin/moderation', { replace: true })
}
}, [isModerator, location.pathname, navigate, allowed])
}, [isModerator, location.pathname, navigate])
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
useEffect(() => {

View File

@@ -0,0 +1,29 @@
import { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import CharacterSheet from '../../../components/CharacterSheet.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { api } from '../../../api/client.js'
// A staff member's own character sheet inside the admin shell. Owner-checked —
// the endpoint only returns a sheet for a character on the caller's linked account.
export default function AdminCharacter() {
const { serial } = useParams()
const { loading, error, data } = useAsync(() => api.admin.shard.char(serial), [serial])
const restarting = error && error.status === 503
const forbidden = error && error.status === 403
return (
<div style={{ maxWidth: 760 }}>
<p style={{ margin: '0 0 18px' }}>
<Link to="/admin/characters" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
← Back to my characters
</Link>
</p>
{loading && <Loading />}
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
{!loading && !error && data && <CharacterSheet char={data} moderation />}
</div>
)
}

View File

@@ -0,0 +1,18 @@
import CharacterStats from '../../../components/CharacterStats.jsx'
import GameAccounts from '../../../components/GameAccounts.jsx'
import VendorSales from '../../../components/VendorSales.jsx'
import { api } from '../../../api/client.js'
// Staff link their OWN in-game account and view their characters — the same
// shared component players use, pointed at the staff self-service endpoints.
// Sits inside the Admin shell, which supplies the "My Characters" page header;
// stat tiles bring it to parity with the Player Portal's Characters page.
export default function AdminCharacters() {
return (
<section style={{ maxWidth: 760 }}>
<CharacterStats scope={api.admin.shard} />
<GameAccounts scope={api.admin.shard} charTo={(serial) => `/admin/characters/${serial}`} />
<VendorSales fetchSales={api.admin.shard.sales} />
</section>
)
}

View File

@@ -0,0 +1,119 @@
import { useMemo, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { useShardFeed } from '../../../lib/useShardFeed.js'
import { api } from '../../../api/client.js'
// Staff-only FULL house registry (admin + moderator). Owner, price, co-owners and
// decay — everything the public board hides. Loaded from /admin/shard/houses, kept
// live from the admin SSE channel (house.update / house.remove).
const HOUSE_KINDS = new Set(['house.update', 'house.remove', 'house.decay'])
const DECAY_TONE = {
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
}
function DecayBadge({ decay, isIdoc }) {
const label = isIdoc ? 'IDOC' : decay
if (!label) return null
const tone = DECAY_TONE[label] || 'var(--muted)'
return (
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 8px' }}>
{label}
</span>
)
}
function ownerLabel(h) {
return h.ownerName || h.ownerAcct || null
}
function HouseRow({ h }) {
const owner = ownerLabel(h)
return (
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<strong className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{h.name || 'An unnamed house'}
</strong>
<DecayBadge decay={h.decay} isIdoc={h.isIdoc} />
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
{owner ? <>Owned by <span style={{ color: 'var(--ink)' }}>{owner}</span></> : 'No owner'}
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
</div>
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
{h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''}
</div>
</div>
{h.price != null && (
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
<div style={{ fontSize: '0.92rem', color: 'var(--head)', fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()}</div>
<div className="dim" style={{ fontSize: '0.64rem', letterSpacing: '0.04em', textTransform: 'uppercase' }}>placement value</div>
</div>
)}
</div>
)
}
export default function HousesAdmin() {
const { loading, error, data } = useAsync(() => api.admin.shard.houses())
// Full registry deltas ride the admin SSE channel (never the public one).
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, filter: HOUSE_KINDS, max: 80 })
const [q, setQ] = useState('')
const board = useMemo(() => {
const map = new Map()
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (!ev.serial) continue
if (ev.kind === 'house.update') {
map.set(ev.serial, { ...ev, ownerName: ev.owner?.name ?? ev.ownerName, ownerAcct: ev.owner?.acct ?? ev.ownerAcct })
} else if (ev.kind === 'house.remove') {
map.delete(ev.serial)
} else if (ev.kind === 'house.decay') {
const cur = map.get(ev.serial) || { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y }
map.set(ev.serial, { ...cur, isIdoc: String(ev.to).toUpperCase() === 'IDOC' })
}
}
return [...map.values()]
}, [data, events])
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase()
const rows = needle
? board.filter((h) => [h.name, h.region, h.map, ownerLabel(h)].some((v) => v && String(v).toLowerCase().includes(needle)))
: board
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
}, [board, q])
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load the house registry." />
return (
<section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.82rem', margin: 0 }}>
{board.length.toLocaleString()} houses
<span className="dim" style={{ marginLeft: 10, color: connected ? '#7fd0a4' : 'var(--muted)' }}>{connected ? '● live' : '○ offline'}</span>
</p>
<input className="input sans" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by owner, region…" style={{ flex: 'none', width: 230, maxWidth: '55%', fontSize: '0.84rem' }} />
</div>
{board.length === 0 ? (
<div className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No houses are being tracked right now.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map((h) => <HouseRow key={h.serial} h={h} />)}
</div>
)}
{board.length > 0 && filtered.length === 0 && (
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No houses match “{q}”.</p>
)}
</section>
)
}

View File

@@ -13,8 +13,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { useAuth } from '../../../contexts/AuthContext.jsx'
import { useSite } from '../../../contexts/SiteContext.jsx'
import { withModuleNav } from '../../../modules/nav.js'
import { useFeatureGate } from '../../../modules/features.jsx'
import { useShardFeatures, canSee } from '../../../lib/useShardFeatures.js'
import { buildNavRows, buildNavOverrides, buildPublicNav, buildPublicNavOverrides } from '../../../lib/navOverrides.js'
import PublicNavTree from './PublicNavTree.jsx'
import { parseJsonSetting } from '../../../lib/settingsJson.js'
@@ -245,7 +244,7 @@ export function Row({ row, id, destinations, destination, onDestination, onChang
export default function NavEditor() {
const { user } = useAuth()
const { refresh: refreshSite } = useSite()
const isVisible = useFeatureGate()
const shardFeatures = useShardFeatures()
const [tab, setTab] = useState('nav_public')
// Per nav: the editable groups, the overrides as loaded (so a row this admin
// cannot see survives their save), and whether a settings row exists at all.
@@ -256,39 +255,25 @@ export default function NavEditor() {
const [saved, setSaved] = useState('')
const [dirty, setDirty] = useState({})
// The palette: each base nav, filtered to what THIS admin can see (§8.1). The
// public nav's gates are the shard-feature ones; the admin nav's are roles.
// The player portal has no gates at all.
// The nav as coded, unfiltered. The palette below is what this admin may EDIT;
// this is what still EXISTS, and the two are different questions. Saving needs
// both: an entry for a row their palette filtered out must be carried through
// rather than reset, and only an entry for a route the code no longer declares
// at all should be dropped.
//
// Each nav is the coded array with every installed module's rows already
// interleaved (modules/nav.js) — the same array the layout renders, which is
// what makes a module row editable here at all: the override merge is keyed by
// `to` and drops a key the base it is handed does not declare, so a nav built
// from core alone would silently discard every stored override on a module row
// the moment it was saved.
const fullNavs = useMemo(
() => ({
nav_public: withModuleNav(PUBLIC_NAV, 'public'),
nav_admin: withModuleNav(ADMIN_NAV, 'admin'),
nav_player: withModuleNav(PLAYER_NAV, 'player'),
}),
[],
)
const fullNavs = { nav_public: PUBLIC_NAV, nav_admin: ADMIN_NAV, nav_player: PLAYER_NAV }
// The palette: each base nav, filtered to what THIS admin can see (§8.1). Two
// gates, and neither is core's own opinion any more — `roles` on a row, and
// the owning module's answer for a row that names a `feature`.
const palettes = useMemo(
() => ({
nav_public: fullNavs.nav_public.filter(isVisible),
nav_admin: fullNavs.nav_admin
.map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role) && isVisible(i)) }))
.filter((g) => g.items.length > 0),
nav_player: fullNavs.nav_player.filter(isVisible),
nav_public: PUBLIC_NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature)),
nav_admin: ADMIN_NAV.map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role)) })).filter(
(g) => g.items.length > 0,
),
nav_player: PLAYER_NAV,
}),
[fullNavs, isVisible, user?.role],
[shardFeatures, user?.role],
)
useEffect(() => {

View File

@@ -177,15 +177,14 @@ const delStyle = {
}
// ── Announcement status panel ────────────────────────────────────────────────
// Shows each delivery leg's state for a published news post and offers a per-leg
// retry (useful after fixing the sidecar / news channel without re-publishing).
// Only rendered for news posts in edit mode; renders nothing until the post has
// actually been announced (no job row yet → nothing to show).
//
// The legs and their labels come from the JOB, not from a constant here: which
// legs exist is decided by what the server has registered, so an installed module
// brings its own leg and this panel renders it with no client change
// (docs/website/MODULE_SYSTEM.md §1.8).
// Shows the town-crier + Discord delivery state for a published news post and
// offers a per-leg retry (useful after fixing the sidecar / news channel without
// re-publishing). Only rendered for news posts in edit mode; renders nothing
// until the post has actually been announced (no job row yet → nothing to show).
const LEG_META = {
towncrier: { label: 'In-game town crier' },
discord: { label: 'Discord #news' },
}
const STATUS_STYLE = {
done: { color: '#7bbf8f', label: 'delivered' },
pending: { color: '#d9b84a', label: 'pending' },
@@ -228,12 +227,14 @@ function AnnouncePanel({ postId }) {
return (
<div style={panelStyle}>
<span className="field-label" style={{ marginBottom: 2 }}>Announcement</span>
{(job.legs || []).map(({ leg, label, status, last_error: err }) => {
{['towncrier', 'discord'].map((leg) => {
const status = job[`${leg}_status`]
const err = job[`${leg}_last_error`]
const s = STATUS_STYLE[status] || STATUS_STYLE.pending
return (
<div key={leg} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{label}</span>
<span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{LEG_META[leg].label}</span>
<span className="sans" style={{ fontSize: '0.8rem', color: s.color, fontWeight: 600 }}>● {s.label}</span>
{status !== 'done' && (
<button

View File

@@ -35,6 +35,18 @@ const FIELDS = [
],
fallback: 'disabled',
},
{
key: 'game_account_signup',
label: 'Game-account creation',
help: 'Whether players can create a GAME account (for the game client) from the site. The game server’s own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them. When enabled, a “Create a game account” form appears in the player portal.',
options: [
{ value: 'disabled', label: 'Disabled — link an existing account only' },
{ value: 'website', label: 'Website — the site creates game accounts' },
{ value: 'hybrid', label: 'Hybrid — site or in-game (recommended)' },
{ value: 'game', label: 'Game only — created in the game client, not the site' },
],
fallback: 'disabled',
},
]
export default function SettingsAdmin() {

View File

@@ -0,0 +1,248 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useShardFeed } from '../../../lib/useShardFeed.js'
import { describe, kindLabel } from '../../../lib/shardEvents.js'
import { ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// Full live feed from the admin SSE channel — every kind, incl. staff audit,
// cheat detection and login attempts that the public channel never carries.
function AdminLiveFeed() {
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, max: 60 })
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Live feed (all events)</h3>
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{events.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Waiting for shard events…</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 360, overflowY: 'auto' }}>
{events.map((e) => (
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
<span className="sans" style={{ flex: 'none', fontSize: '0.6rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>{kindLabel(e.kind)}</span>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
</li>
))}
</ul>
)}
</section>
)
}
// uo-link sidecar control panel. The auth token is write-only over this API —
// stored encrypted, never returned — same convention as the Discord bot token.
// Saving (re)starts the WS ingest client, so Enabled/URL/token changes take
// effect immediately with no redeploy.
function Toggle({ checked, onChange, label }) {
return (
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
{label}
</label>
)
}
const STATUS_COLOR = {
connected: '#7fd0a4',
reconnecting: '#e0b070',
error: '#d98b84',
disconnected: 'var(--muted)',
}
function StatusPanel({ config }) {
const color = STATUS_COLOR[config.status] || 'var(--muted)'
const ingest = config.ingest || {}
const health = config.health || {}
return (
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
{config.status || 'disconnected'}
</span>
</div>
{config.statusDetail && (
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
)}
<div className="sans dim" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 16px', fontSize: '0.78rem', marginTop: 2 }}>
<span>Shard link: <strong style={{ color: 'var(--ink)' }}>{config.pluginConnected ? 'up' : 'down'}</strong></span>
<span>WS ingest: <strong style={{ color: 'var(--ink)' }}>{ingest.connected ? 'connected' : 'offline'}</strong></span>
<span>Reconnects: <strong style={{ color: 'var(--ink)' }}>{ingest.reconnects ?? 0}</strong></span>
<span>SSE clients: <strong style={{ color: 'var(--ink)' }}>{(config.sse?.publicClients ?? 0) + (config.sse?.adminClients ?? 0)}</strong></span>
{config.lastEventAt && <span style={{ gridColumn: '1 / -1' }}>Last event: {new Date(config.lastEventAt).toLocaleString()}</span>}
{health.uptime && <span style={{ gridColumn: '1 / -1' }}>Sidecar uptime: {health.uptime}</span>}
</div>
</div>
)
}
// ── Town crier ──────────────────────────────────────────────────────────────
function TownCrier() {
const [id, setId] = useState('')
const [text, setText] = useState('')
const [durationSec, setDurationSec] = useState(3600)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function post() {
setBusy(true); setMsg(''); setError('')
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
if (!id.trim() || lines.length === 0) {
setBusy(false)
return setError('An id and at least one line are required.')
}
try {
await api.admin.postTownCrier({ id: id.trim(), lines, durationSec: Number(durationSec) || undefined })
setMsg(`Posted “${id.trim()}”.`)
} catch (err) {
setError(err.message || 'Could not post.')
} finally {
setBusy(false)
}
}
async function remove() {
if (!id.trim()) return setError('Enter the id to remove.')
setBusy(true); setMsg(''); setError('')
try {
await api.admin.deleteTownCrier(id.trim())
setMsg(`Removed “${id.trim()}”.`)
} catch (err) {
setError(err.message || 'Could not remove.')
} finally {
setBusy(false)
}
}
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Town crier</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
Broadcast a message that every in-game town crier announces until it expires. Re-posting the same id replaces it.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Message id</span>
<input type="text" value={id} onChange={(e) => setId(e.target.value)} className="input" placeholder="news-42" autoComplete="off" style={{ maxWidth: 220 }} />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Lines (one per line)</span>
<textarea value={text} onChange={(e) => setText(e.target.value)} className="input" rows={3} placeholder={'Hear ye!\nMarket tax is now 5%.'} style={{ resize: 'vertical' }} />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Duration (seconds)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={1} max={86400} style={{ maxWidth: 160 }} />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={post} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Working…' : 'Post message'}</button>
<button onClick={remove} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>Remove by id</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
</section>
)
}
export default function ShardAdmin() {
const [config, setConfig] = useState(null)
const [error, setError] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [wsUrl, setWsUrl] = useState('')
const [token, setToken] = useState('')
const [protocol, setProtocol] = useState(3)
const [enabled, setEnabled] = useState(false)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [saveError, setSaveError] = useState('')
const pollRef = useRef(null)
const initializedRef = useRef(false)
const load = useCallback(async () => {
try {
const c = await api.admin.getUoLinkConfig()
setConfig(c)
// Seed the editable fields once; later polls only refresh the status panel
// so they never clobber what the admin is mid-typing.
if (!initializedRef.current) {
setBaseUrl(c.baseUrl || '')
setWsUrl(c.wsUrl || '')
setProtocol(c.protocol || 3)
setEnabled(c.enabled)
initializedRef.current = true
}
} catch {
setError('Could not load uo-link config.')
}
}, [])
useEffect(() => {
load()
pollRef.current = setInterval(load, 5000)
return () => clearInterval(pollRef.current)
}, [load])
async function save() {
setBusy(true); setMsg(''); setSaveError('')
try {
const body = { baseUrl, wsUrl, protocol: Number(protocol), enabled }
if (token) body.token = token
const saved = await api.admin.saveUoLinkConfig(body)
setConfig(saved)
setToken('')
setMsg('Saved.')
} catch (err) {
setSaveError(err.message || 'Could not save.')
} finally {
setBusy(false)
}
}
if (error) return <ErrorState message={error} />
if (!config) return <Loading />
return (
<section style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 20 }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Shard (uo-link)</h2>
<StatusPanel config={config} />
<Toggle checked={enabled} onChange={setEnabled} label="Enable the shard integration" />
<label style={{ display: 'block' }}>
<span className="field-label">Base URL (REST)</span>
<input type="text" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} className="input" autoComplete="off" placeholder="http://127.0.0.1:8080" />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">WebSocket URL (feed)</span>
<input type="text" value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} className="input" autoComplete="off" placeholder="ws://127.0.0.1:8080/ws" />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Auth token</span>
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} className="input" autoComplete="new-password" placeholder={config.hasToken ? '•••••••• configured — leave blank to keep' : 'Shared secret from sidecar.toml'} />
</label>
<label style={{ display: 'block', maxWidth: 140 }}>
<span className="field-label">Protocol</span>
<input type="number" value={protocol} onChange={(e) => setProtocol(e.target.value)} className="input" min={1} max={99} />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Saving…' : 'Save changes'}</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{saveError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{saveError}</span>}
</div>
<TownCrier />
<AdminLiveFeed />
</section>
)
}

View File

@@ -0,0 +1,291 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useShardFeed } from '../../../lib/useShardFeed.js'
import { describe } from '../../../lib/shardEvents.js'
import { ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// In-game staff operations: the uo-link write plane (broadcast / kick / ban /
// unban) and the help-page support queue, plus a live audit log. Open to admins
// and moderators. The acting staff member (`actor`) is attached server-side from
// the session — nothing here sends it — so every action is attributable.
function Flash({ ok, err }) {
if (ok) return <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{ok}</span>
if (err) return <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>
return null
}
// ── Broadcast ────────────────────────────────────────────────────────────────
function Broadcast() {
const [text, setText] = useState('')
const [hue, setHue] = useState('')
const [busy, setBusy] = useState(false)
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
async function send() {
if (!text.trim()) return setErr('Enter a message.')
setBusy(true); setOk(''); setErr('')
try {
await api.admin.shardOps.broadcast({ text: text.trim(), hue: hue === '' ? undefined : Number(hue) })
setOk('Broadcast sent.')
setText('')
} catch (e) {
setErr(e.message || 'Could not broadcast.')
} finally {
setBusy(false)
}
}
return (
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Broadcast</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
A system message shown to everyone online right now.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Message</span>
<input type="text" value={text} onChange={(e) => setText(e.target.value)} className="input" maxLength={300} placeholder="Server restart in 5 minutes" autoComplete="off" />
</label>
<label style={{ display: 'block', maxWidth: 140 }}>
<span className="field-label">Hue (optional)</span>
<input type="number" value={hue} onChange={(e) => setHue(e.target.value)} className="input" min={0} max={3000} placeholder="53" />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={send} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Sending…' : 'Broadcast'}</button>
<Flash ok={ok} err={err} />
</div>
</section>
)
}
// ── Account actions (kick / ban / unban) ─────────────────────────────────────
function AccountActions() {
const [account, setAccount] = useState('')
const [durationSec, setDurationSec] = useState('')
const [reason, setReason] = useState('')
const [busy, setBusy] = useState('')
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
const acct = account.trim()
function guard() {
if (!acct) {
setErr('Enter an account name.')
return false
}
return true
}
async function run(label, fn, done) {
if (!guard()) return
setBusy(label); setOk(''); setErr('')
try {
const r = await fn()
setOk(done(r))
} catch (e) {
setErr(e.message || 'Action failed.')
} finally {
setBusy('')
}
}
const kick = () =>
run('kick', () => api.admin.shardOps.kick({ account: acct }), (r) => {
const n = r?.sessions != null ? r.sessions : null
const plural = n === 1 ? '' : 's'
const sessions = n != null ? ` (${n} session${plural})` : ''
return `Kicked ${acct}${sessions}.`
})
const ban = () =>
run(
'ban',
() =>
api.admin.shardOps.ban({
account: acct,
durationSec: durationSec === '' ? undefined : Number(durationSec),
reason: reason.trim() || undefined,
}),
() => {
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
return `Banned ${acct}${when}.`
},
)
const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`)
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Account actions</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
Kick, ban or unban a game account. Bans work even if the account is offline; the shard refuses to act on staff at or above co-owner.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Account</span>
<input type="text" value={account} onChange={(e) => setAccount(e.target.value)} className="input" placeholder="griefer42" autoComplete="off" style={{ maxWidth: 260 }} />
</label>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label style={{ display: 'block', maxWidth: 200 }}>
<span className="field-label">Ban duration (seconds, blank = permanent)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" />
</label>
<label style={{ display: 'block', flex: 1, minWidth: 200 }}>
<span className="field-label">Ban reason (optional)</span>
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
</label>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={kick} disabled={!!busy} className="btn btn-sq">{busy === 'kick' ? 'Kicking…' : 'Kick'}</button>
<button onClick={ban} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'ban' ? 'Banning…' : 'Ban'}</button>
<button onClick={unban} disabled={!!busy} className="btn btn-sq">{busy === 'unban' ? 'Unbanning…' : 'Unban'}</button>
<Flash ok={ok} err={err} />
</div>
</section>
)
}
// ── Support (help-page) queue ────────────────────────────────────────────────
function PageRow({ page, onDone }) {
const [message, setMessage] = useState('')
const [busy, setBusy] = useState('')
const [err, setErr] = useState('')
async function respond(close) {
if (!message.trim()) return setErr('Enter a reply first.')
setBusy(close ? 'respond-close' : 'respond'); setErr('')
try {
await api.admin.shardOps.respondPage(page.pageId, { message: message.trim(), close })
onDone()
} catch (e) {
setErr(e.message || 'Could not send.')
setBusy('')
}
}
async function close() {
setBusy('close'); setErr('')
try {
await api.admin.shardOps.closePage(page.pageId)
onDone()
} catch (e) {
setErr(e.message || 'Could not close.')
setBusy('')
}
}
return (
<div className="panel" style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ minWidth: 0 }}>
<span className="sans" style={{ fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)' }}>{page.type || 'Page'}</span>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{page.sender?.name || page.pageId}
{page.handled && <span className="dim" style={{ fontSize: '0.72rem' }}> · claimed{page.handler ? ` by ${page.handler}` : ''}</span>}
</div>
</div>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{page.sentMs ? ago(page.sentMs) : ''}</span>
</div>
{page.message && <p className="sans" style={{ margin: 0, color: 'var(--ink)', fontSize: '0.88rem', lineHeight: 1.5 }}>{page.message}</p>}
<div className="sans dim" style={{ fontSize: '0.72rem' }}>
{page.map || '—'}{page.x != null ? ` (${page.x}, ${page.y})` : ''}
</div>
<textarea value={message} onChange={(e) => setMessage(e.target.value)} className="input" rows={2} placeholder="A GM is on the way." style={{ resize: 'vertical' }} />
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={() => respond(false)} disabled={!!busy} className="btn btn-sq">{busy === 'respond' ? 'Sending…' : 'Reply'}</button>
<button onClick={() => respond(true)} disabled={!!busy} className="btn btn-primary btn-sq">{busy === 'respond-close' ? 'Sending…' : 'Reply & close'}</button>
<button onClick={close} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'close' ? 'Closing…' : 'Close'}</button>
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
</div>
</div>
)
}
function SupportQueue() {
const [pages, setPages] = useState(null)
const [err, setErr] = useState('')
const pollRef = useRef(null)
const load = useCallback(async () => {
try {
setPages(await api.admin.shardOps.pages())
} catch {
setErr('Could not load the support queue.')
}
}, [])
useEffect(() => {
load()
pollRef.current = setInterval(load, 7000)
return () => clearInterval(pollRef.current)
}, [load])
let queueBody
if (pages == null) {
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading…</p>
} else if (pages.length === 0) {
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>The queue is empty.</p>
} else {
queueBody = (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{pages.map((p) => <PageRow key={p.pageId} page={p} onDone={load} />)}
</div>
)
}
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Support queue</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
Open help pages from players. A reply reaches them in game (or on their next login).
</p>
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>}
{queueBody}
</section>
)
}
// ── Audit log ────────────────────────────────────────────────────────────────
// Seeded from the stored admin.audit history, then kept live from the admin SSE
// channel (which carries every kind — we filter to admin.audit here).
function AuditLog() {
const [seed, setSeed] = useState([])
const { events } = useShardFeed({ url: api.adminShardStreamUrl, filter: new Set(['admin.audit']), max: 50 })
useEffect(() => {
api.admin.shardOps
.audit(50)
.then((rows) => setSeed(rows.map((r) => ({ ...r, _id: `seed-${r.id}` }))))
.catch(() => setSeed([]))
}, [])
// Live events on top; fall back to the seed for anything older than the live tail.
const oldestLive = events.length ? Math.min(...events.map((e) => e.t || 0)) : Infinity
const rows = [...events, ...seed.filter((s) => (s.t || 0) < oldestLive)].slice(0, 60)
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)', marginBottom: 12 }}>Audit log</h3>
{rows.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No moderation actions recorded yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 320, overflowY: 'auto' }}>
{rows.map((e) => (
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
</li>
))}
</ul>
)}
</section>
)
}
export default function ShardOps() {
return (
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 22 }}>
<Broadcast />
<AccountActions />
<SupportQueue />
<AuditLog />
</section>
)
}

View File

@@ -0,0 +1,325 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// ── Admin · Shard visibility ────────────────────────────────────────────────
//
// Who may see which shard surface, and which sensitive fields within it.
// Admin-only, because this decides what ANONYMOUS visitors get.
//
// Two things the UI must communicate honestly, because they are not negotiable
// server-side (see docs/link/v3.md §3.4):
// • acct / webId are admin-only always and are not listed as editable fields.
// • an event kind the server doesn't know about never reaches anyone below
// admin, whatever is set here.
//
// Defaults reproduce the behavior the site had before this panel existed, so a
// fresh install shows "everything as it was" rather than an empty form.
const RUNG_LABEL = {
anonymous: 'Everyone',
logged_in: 'Signed in',
player: 'Linked players',
staff: 'Staff',
admin: 'Admins only',
}
const RUNG_HINT = {
anonymous: 'Visible to anyone, signed in or not.',
logged_in: 'Any signed-in account, linked or not.',
player: 'Accounts with a linked game account. Staff always qualify.',
staff: 'Admins and moderators.',
admin: 'Admins only.',
}
const FEATURE_LABEL = {
status: 'Shard status',
activity: 'Activity feed',
champs: 'Champion spawns',
guilds: 'Guilds',
governors: 'Town governors',
houses: 'Houses / IDOC',
presence: 'Players online',
ruleset: 'Shard rules',
atlas: 'Spawn atlas',
leaderboards: 'Leaderboards',
market: 'Marketplace',
}
const FEATURE_HINT = {
status: 'Connection state, online count, gold-supply series.',
activity: 'Deaths, kills, skill gains, quests, logins.',
champs: 'The live champion / mini-champ / sea-boss board.',
guilds: 'Guild rosters, alliances and leaders.',
governors: 'City Loyalty governors, elections and term history.',
houses: 'Houses in danger (IDOC). Owner and price are separate fields below.',
presence: 'Population aggregate and the staff-online widget.',
ruleset: 'Skill/stat caps, house limits, vet rewards and the rest of the ruleset.',
atlas: 'The spawn atlas and bestiary. Static shard content, not live state.',
leaderboards: 'Point and loyalty standings across every points system.',
market: 'The shard-wide player-vendor index.',
}
const FIELD_LABEL = {
owner: 'House owner',
price: 'House price',
location: 'In-game location (map + coordinates)',
connect: 'Server connect address',
// Keyed on the WIRE field, which for a leaderboard entry is `name` — the
// projection matches literal JSON keys, so the rule cannot be spelled after the
// field's meaning. The label is what carries the meaning to the admin.
name: 'Character names on leaderboards',
ownerName: 'Vendor owner name',
// One rule, one key — `location` is a nested object on both the wire frame and
// the stored read model precisely so that hiding it takes the facet, the
// coordinates, the region and the house together.
ownerSerial: 'Vendor owner character id',
}
function RungSelect({ value, onChange, ladder, disabled }) {
return (
<select
className="input"
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
style={{ maxWidth: 200 }}
>
{ladder.map((rung) => (
<option key={rung} value={rung}>
{RUNG_LABEL[rung] || rung}
</option>
))}
</select>
)
}
function FeatureRow({ name, settings, defaults, ladder, onPatch }) {
const fields = Object.entries(settings.fields || {})
const changed =
defaults &&
(settings.enabled !== defaults.enabled ||
settings.audience !== defaults.audience ||
settings.stream !== defaults.stream ||
JSON.stringify(settings.fields) !== JSON.stringify(defaults.fields))
return (
<div
style={{
border: '1px solid var(--line)',
borderRadius: 10,
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 12,
opacity: settings.enabled ? 1 : 0.62,
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<div style={{ minWidth: 0 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
{FEATURE_LABEL[name] || name}
{changed && (
<span
className="sans"
style={{ marginLeft: 8, fontSize: '0.62rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)' }}
>
changed
</span>
)}
</h3>
<p className="sans" style={{ margin: '4px 0 0', fontSize: '0.82rem', color: 'var(--muted)', lineHeight: 1.5 }}>
{FEATURE_HINT[name]}
</p>
</div>
<label
className="sans"
style={{ flex: 'none', display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)' }}
>
<input
type="checkbox"
checked={settings.enabled}
onChange={(e) => onPatch(name, { enabled: e.target.checked })}
/>
Enabled
</label>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 20, alignItems: 'flex-end' }}>
<label style={{ display: 'block' }}>
<span className="field-label">Who can see it</span>
<RungSelect
value={settings.audience}
ladder={ladder}
disabled={!settings.enabled}
onChange={(audience) => onPatch(name, { audience })}
/>
<span className="sans dim" style={{ display: 'block', marginTop: 4, fontSize: '0.75rem' }}>
{RUNG_HINT[settings.audience]}
</span>
</label>
<label
className="sans"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)', paddingBottom: 22 }}
>
<input
type="checkbox"
checked={settings.stream}
disabled={!settings.enabled}
onChange={(e) => onPatch(name, { stream: e.target.checked })}
/>
Live updates
</label>
</div>
{fields.length > 0 && (
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 12 }}>
<span className="field-label" style={{ display: 'block', marginBottom: 8 }}>
Sensitive fields
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
{fields.map(([field, rung]) => (
<label key={field} style={{ display: 'block' }}>
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginBottom: 4 }}>
{FIELD_LABEL[field] || field}
</span>
<RungSelect
value={rung}
ladder={ladder}
disabled={!settings.enabled}
onChange={(level) =>
onPatch(name, { fieldRules: { ...settings.fields, [field]: level } })
}
/>
</label>
))}
</div>
</div>
)}
</div>
)
}
export default function ShardVisibility() {
const [config, setConfig] = useState(null)
const [defaults, setDefaults] = useState(null)
const [ladder, setLadder] = useState([])
const [lockedFields, setLockedFields] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const data = await api.admin.getShardVisibility()
setConfig(data.features)
setDefaults(data.defaults)
setLadder(data.ladder || [])
setLockedFields(data.lockedFields || [])
} catch (err) {
setError(err.message || 'Could not load visibility settings.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
function patch(name, changes) {
setMsg('')
setConfig((prev) => {
const next = { ...prev[name], ...changes }
// `fieldRules` in the API is `fields` in the effective config.
if (changes.fieldRules) {
next.fields = changes.fieldRules
delete next.fieldRules
}
return { ...prev, [name]: next }
})
}
async function save() {
setSaving(true)
setMsg('')
setError('')
try {
const body = {}
for (const [name, s] of Object.entries(config)) {
body[name] = {
enabled: s.enabled,
audience: s.audience,
stream: s.stream,
fieldRules: s.fields || {},
}
}
const data = await api.admin.saveShardVisibility(body)
setConfig(data.features)
setMsg('Saved. Changes take effect within a few seconds, including on open live streams.')
} catch (err) {
setError(err.message || 'Could not save.')
} finally {
setSaving(false)
}
}
function resetToDefaults() {
setMsg('')
setConfig(structuredClone(defaults))
}
if (loading) return <Loading />
if (error && !config) return <ErrorState message={error} onRetry={load} />
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<header>
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
Shard visibility
</h2>
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
Choose who can see each shard surface on the public site, and how much detail they get.
Turning a feature off hides it entirely — its pages return “not found” rather than
revealing that it exists. “Live updates” controls whether the feature streams changes in
real time; the pages still work without it, they just refresh on load.
</p>
{lockedFields.length > 0 && (
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.82rem', lineHeight: 1.6, maxWidth: 760 }}>
Not configurable: <strong style={{ color: 'var(--ink)' }}>{lockedFields.join(', ')}</strong> —
game account names and website user ids are never shown below admin, on any surface. They
aren’t visible in game either, so publishing them would disclose something the shard
itself doesn’t.
</p>
)}
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{Object.entries(config).map(([name, settings]) => (
<FeatureRow
key={name}
name={name}
settings={settings}
defaults={defaults?.[name]}
ladder={ladder}
onPatch={patch}
/>
))}
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={saving} className="btn btn-primary btn-sq">
{saving ? 'Saving…' : 'Save changes'}
</button>
<button onClick={resetToDefaults} disabled={saving} className="btn btn-sq">
Restore defaults
</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
</div>
)
}

View File

@@ -0,0 +1,285 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// ── Admin · Spawn atlas ─────────────────────────────────────────────────────
//
// The atlas re-derives itself from the shard's ServUO tree on every boot, so
// this panel exists for the three things a restart cannot do:
//
// • point it at a different tree,
// • apply a map change without restarting, and
// • answer a refresh that was parsed but deliberately NOT applied because it
// would remove a facet.
//
// That last one is the reason the panel is worth building. Losing a facet looks
// exactly like a half-copied or mid-update tree, and boot cannot tell them
// apart — so it stages the decision for a human instead of guessing. Until
// someone decides here, the site keeps serving the atlas it already had.
// A refresh reports its outcome rather than throwing (the boot path must never
// be stopped by a bad tree), so these are answers, not errors — the panel says
// what happened in the shard's terms instead of showing a failure box.
const OUTCOME = {
imported: (r) =>
`Imported — ${r.counts?.points?.toLocaleString() ?? '?'} spawners, ${r.counts?.creatures?.toLocaleString() ?? '?'} creatures.`,
unchanged: (r) =>
r.reason === 'refresh previously rejected'
? 'Unchanged — this exact tree was already reviewed and declined.'
: 'Unchanged — the tree matches what is already loaded.',
needsReview: () => 'Staged for review: this refresh would remove a facet, so it was not applied.',
unavailable: (r) => `The tree could not be read: ${r.reason || 'unknown reason'}`,
skipped: () => 'No ServUO path is configured, so there is nothing to import.',
failed: (r) => `Refresh failed: ${r.reason || 'unknown reason'}`,
rejected: () => 'Declined. It will not be offered again until the tree changes.',
}
const describe = (result) => (OUTCOME[result?.status] || (() => `Result: ${result?.status}`))(result)
function Row({ label, children }) {
return (
<div
className="sans"
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 16,
padding: '7px 0',
borderBottom: '1px solid var(--line)',
fontSize: '0.86rem',
}}
>
<span className="dim">{label}</span>
<span style={{ color: 'var(--head)', textAlign: 'right', wordBreak: 'break-all' }}>{children}</span>
</div>
)
}
function PendingReview({ pending, busy, onApprove, onReject }) {
const declined = pending.status === 'rejected'
return (
<section
style={{
border: `1px solid ${declined ? 'var(--line)' : '#c58f4a'}`,
borderRadius: 10,
padding: 16,
background: declined ? 'transparent' : 'rgba(197,143,74,0.08)',
}}
>
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
{declined ? 'A refresh was declined' : 'A refresh is waiting for you'}
</h3>
<p className="sans" style={{ margin: '6px 0 12px', fontSize: '0.86rem', color: 'var(--muted)', lineHeight: 1.6 }}>
{declined ? (
<>
This tree was reviewed and declined, so it is not offered again until the files change.
Approving now applies it anyway.
</>
) : (
<>
The tree parses cleanly but would <strong>remove {pending.removedFacets?.length || 0} facet
</strong>
{(pending.removedFacets?.length || 0) === 1 ? '' : 's'} the site is currently serving. That
is what a half-copied or mid-update tree looks like as well as a real map change, so it was
not applied. Approving re-parses the tree as it is right now — if you have since fixed the
mount, what lands is the corrected import.
</>
)}
</p>
<Row label="Would remove">{(pending.removedFacets || []).join(', ') || '—'}</Row>
<Row label="Would add">{(pending.addedFacets || []).join(', ') || '—'}</Row>
<Row label="Detected">{pending.detectedAt ? new Date(pending.detectedAt).toLocaleString() : '—'}</Row>
<div style={{ display: 'flex', gap: 10, marginTop: 14, flexWrap: 'wrap' }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={onApprove}>
Approve and import
</button>
{!declined && (
<button type="button" className="btn btn-sq" disabled={busy} onClick={onReject}>
Keep the current atlas
</button>
)}
</div>
</section>
)
}
export default function SpawnAtlas() {
const [status, setStatus] = useState(null)
const [path, setPath] = useState('')
const [force, setForce] = useState(false)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const data = await api.admin.atlas.status()
setStatus(data)
setPath(data.path || '')
} catch (err) {
setError(err.message || 'Could not load atlas status.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
// Every mutating action shares this: run it, report what it said, then reload
// status so the panel reflects the world rather than what we assumed happened.
async function run(action, fn) {
setBusy(true)
setMsg('')
setError('')
try {
const result = await fn()
setMsg(describe(result))
const fresh = await api.admin.atlas.status()
setStatus(fresh)
setPath(fresh.path || '')
} catch (err) {
setError(err.message || `Could not ${action}.`)
} finally {
setBusy(false)
}
}
async function savePath() {
setBusy(true)
setMsg('')
setError('')
try {
const fresh = await api.admin.atlas.setPath(path.trim())
setStatus(fresh)
setPath(fresh.path || '')
setMsg(
fresh.path === ''
? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.'
: fresh.treeReadable
? 'Saved. The tree is readable — import when you are ready.'
: 'Saved, but the tree could not be read from here. Check the mount and permissions.',
)
} catch (err) {
setError(err.message || 'Could not save the path.')
} finally {
setBusy(false)
}
}
if (loading) return <Loading />
if (error && !status) return <ErrorState message={error} />
const counts = status?.counts || null
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<header>
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
Spawn atlas
</h2>
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
The bestiary and spawn map on the public site, parsed from the shard’s own ServUO files.
It refreshes itself on every server start; everything here is for the times you don’t want
to wait for one. Nothing on this page touches the sidecar — the atlas is shard content, not
shard state, and stays complete while the shard is down.
</p>
</header>
{status?.pending && (
<PendingReview
pending={status.pending}
busy={busy}
onApprove={() => run('approve the refresh', () => api.admin.atlas.approve())}
onReject={() => run('decline the refresh', () => api.admin.atlas.reject())}
/>
)}
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 10px', fontSize: '1rem', color: 'var(--head)' }}>
What is loaded
</h3>
<Row label="Imported">
{status?.importedAt ? new Date(status.importedAt).toLocaleString() : 'Never'}
</Row>
<Row label="Facets">{status?.facets?.length ? status.facets.join(', ') : '—'}</Row>
{counts && (
<>
<Row label="Spawners">{counts.points?.toLocaleString() ?? '—'}</Row>
<Row label="Creatures">{counts.creatures?.toLocaleString() ?? '—'}</Row>
<Row label="Regions / landmarks">
{`${counts.regions?.toLocaleString() ?? '—'} / ${counts.landmarks?.toLocaleString() ?? '—'}`}
</Row>
<Row label="Champion altars">{counts.champions?.toLocaleString() ?? '—'}</Row>
</>
)}
<Row label="Tree readable">
{!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'}
</Row>
<Row label="Tree changed since import">
{status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'}
</Row>
</section>
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
ServUO tree
</h3>
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
Where the website reads the shard’s spawn files from — the same host, a bind mount or a
shared volume. This setting wins over the <code>SERVUO_PATH</code> deploy default, so the
mount can move without a redeploy. Leave it blank to turn the atlas off.
</p>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
<input
className="input"
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="/srv/servuo"
style={{ flex: '1 1 320px', minWidth: 0 }}
/>
<button type="button" className="btn btn-sq" disabled={busy} onClick={savePath}>
Save path
</button>
</div>
</section>
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
Re-import
</h3>
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
Applies a map change without restarting. An unchanged tree costs nothing — the source files
are hashed first and skipped when they match. A refresh that would remove a facet still
comes back here for approval rather than being applied.
</p>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<button
type="button"
className="btn btn-primary btn-sq"
disabled={busy || !status?.configured}
onClick={() => run('import the atlas', () => api.admin.atlas.import(force))}
>
{busy ? 'Working…' : 'Import now'}
</button>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: '0.85rem', cursor: 'pointer' }}>
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} />
Re-import even if the tree is unchanged
</label>
</div>
</section>
{(msg || error) && (
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
)}
</div>
)
}

View File

@@ -1,16 +1,17 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { dateTime } from '../../../lib/format.js'
import { dateTime, ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import Slot from '../../../modules/Slot.jsx'
import CharacterStats from '../../../components/CharacterStats.jsx'
import GameAccounts from '../../../components/GameAccounts.jsx'
import VendorSales from '../../../components/VendorSales.jsx'
// Admin view of one user: who they are, their security posture (trusted devices
// and MFA), and then whatever the installed module contributes about them —
// today core's own UO footprint, via the `admin.users.detail` extension slot
// (MODULE_API.md §3.7). Reached from the Users table's "View" action; Edit stays
// a separate modal.
// Admin read-only view of one user's shard (uo-link) footprint: linked game
// accounts + character rosters, currently-online characters, houses (incl.
// IDOC) and recent vendor sales — everything scoped to that user's accounts.
// Reached from the Users table's "View" action; Edit stays a separate modal.
const ROLE_BADGE = {
admin: 'badge-admin',
@@ -27,6 +28,114 @@ function SectionTitle({ children }) {
)
}
// Currently-online characters on the user's accounts, with where they are. The
// per-character Online/Offline badge lives in the roster; this adds location.
function OnlineNow({ scope }) {
const { data } = useAsync(() => scope.online(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Online now</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map((c) => (
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
</span>
</li>
))}
</ul>
)}
</section>
)
}
// Shard "standing": city governorships held and guilds led by this user's
// accounts (both reliable current-state lookups). Renders nothing when empty.
function Standing({ scope }) {
const { data } = useAsync(() => scope.standing(), [scope])
if (!data) return null
const govs = data.governorOf || []
const guilds = data.guildsLed || []
if (govs.length === 0 && guilds.length === 0) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Standing</SectionTitle>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{govs.map((g) => (
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
Governor of {g.city}
</span>
))}
{guilds.map((g) => (
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
</span>
))}
</div>
</section>
)
}
// One house row — the many optional detail fields are gathered here so the
// Houses list stays a simple map.
function HouseRow({ house: h }) {
const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown')
const coords = h.x != null ? ` · ${h.x}, ${h.y}` : ''
const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : ''
const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''
return (
<li
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
>
<div style={{ minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{h.name || 'Unnamed house'}
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
{location}
{coords}
{owner}
{shares}
</div>
</div>
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
</div>
</li>
)
}
// Houses owned by the user's accounts, IDOC first (flagged).
function Houses({ scope }) {
const { data } = useAsync(() => scope.houses(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Houses</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this user’s accounts.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => (
<HouseRow key={h.serial} house={h} />
))}
</ul>
)}
</section>
)
}
// Admin security controls for one user: their trusted devices (view + revoke) and
// an MFA reset for a locked-out user. Every action is audit-logged server-side.
function SecurityAdmin({ userId }) {
@@ -135,8 +244,25 @@ function SecurityAdmin({ userId }) {
)
}
function ShardSections({ scope }) {
return (
<>
<CharacterStats scope={scope} />
<SectionTitle>Linked accounts &amp; characters</SectionTitle>
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/characters/${serial}`} />
<Standing scope={scope} />
<OnlineNow scope={scope} />
<Houses scope={scope} />
<VendorSales fetchSales={scope.sales} />
</>
)
}
export default function UserDetail() {
const { id } = useParams()
// Memoize so the child components' effects (keyed on `scope`) don't refetch
// on every render.
const scope = useMemo(() => api.admin.userShard(id), [id])
const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id])
if (loading) return <Loading />
@@ -170,11 +296,7 @@ export default function UserDetail() {
</div>
<SecurityAdmin userId={id} />
{/* Whatever the installed module has to say about this user, or nothing
at all — core filled this with its own UO sections until Phase 3 slice
3, and now nothing does unless a module is installed
(MODULE_API.md §3.7). */}
<Slot name="admin.users.detail" userId={id} />
<ShardSections scope={scope} />
</section>
)
}

View File

@@ -1,22 +1,14 @@
import { useCallback, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
import Slot from '../../modules/Slot.jsx'
import { extensionFor } from '../../modules/registry.js'
import CreateGameAccountForm from '../../components/CreateGameAccountForm.jsx'
// Public, token-gated invite acceptance (/invite/:token). Validates the invite,
// lets the invitee set a username + password (their email + role are pre-assigned),
// creates the account at that role and logs them in.
//
// For a PLAYER invite there may then be one more step, supplied by an installed
// module through the `player.invite.accepted` slot: core rendered a UO
// game-account form here itself until Phase 3 slice 3, reading a
// `gameAccountSignup` flag out of its own settings and posting to a shard route.
// Neither of those is core's. What core keeps is the shell, the skip control and
// the destination; whether there is a step at all is the module's call, made
// from data core does not have.
// creates the account at that role and logs them in. For a player invite it then
// offers the built-in "create game account" step before sending them to the portal.
export default function AcceptInvite() {
const { token } = useParams()
const navigate = useNavigate()
@@ -24,6 +16,7 @@ export default function AcceptInvite() {
const [invite, setInvite] = useState(null) // fields email and role
const [loadErr, setLoadErr] = useState('')
const [signupOk, setSignupOk] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
@@ -37,20 +30,14 @@ export default function AcceptInvite() {
api.getInvite(token)
.then((iv) => active && setInvite(iv))
.catch((err) => active && setLoadErr(err.status === 404 ? 'This invitation is invalid or has expired.' : 'Could not load this invitation.'))
api.publicSettings()
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
.catch(() => {})
return () => { active = false }
}, [token])
const dest = invite && invite.role === 'player' ? '/player' : '/admin'
// Whether anything is installed that wants the post-acceptance step. Read
// rather than rendered blind because it decides a NAVIGATION, not just what
// appears: with nothing filled there is no screen to show, so the invitee goes
// straight to their destination. This is the one legitimate reason to ask
// whether a slot is filled — the answer changes control flow, not decoration
// (decoration goes inside `<Slot wrap>`, which is why `hasExtension` is gone).
const hasNextStep = Boolean(extensionFor('player.invite.accepted'))
const finish = useCallback(() => navigate('/player', { replace: true }), [navigate])
async function onSubmit(e) {
e.preventDefault()
setError('')
@@ -61,9 +48,8 @@ export default function AcceptInvite() {
await api.acceptInvite(token, username.trim(), password, { company })
await refresh() // pull the freshly-issued session into context
setAccepted(true)
// Staff invites go straight in, and so does a player invite when nothing
// is installed that has a step to offer.
if (!(invite.role === 'player' && hasNextStep)) navigate(dest, { replace: true })
// Staff invites are web-only — no game step; go straight in.
if (!(invite.role === 'player' && signupOk)) navigate(dest, { replace: true })
} catch (err) {
if (err.status === 409) setError('That username is already taken, or the invite was already used.')
else if (err.status === 404) setError('This invitation is invalid or has expired.')
@@ -92,22 +78,19 @@ export default function AcceptInvite() {
)
}
// ── Accepted: a module's optional next step (player invites) ───────────────
//
// Only reachable when the slot is filled — `onSubmit` navigates away otherwise
// — so there is no empty-shell case to guard here.
//
// The subtitle is core's and says nothing about what the step is: naming it
// would be core describing content it does not own, and the wrong description
// is worse than a general one. "Skip" stays core's too, because where it goes
// is core's decision, and it is rendered outside the slot deliberately — an
// extension that throws must not take the way out with it.
// ── Accepted: optional game-account step (player invites) ──────────────────
if (accepted) {
return (
<PlayerShell subtitle="One more step">
<Slot name="player.invite.accepted" onDone={finish} />
<PlayerShell subtitle="Set up your game account">
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
Your account is ready. Create a game account now to play, or skip and do it later from your portal.
</p>
<CreateGameAccountForm
submit={api.player.shard.createAccount}
onCreated={() => navigate('/player', { replace: true })}
/>
<p className="sans" style={{ textAlign: 'center', margin: '18px 0 0' }}>
<button type="button" onClick={finish} className="btn" style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer' }}>
<button type="button" onClick={() => navigate('/player', { replace: true })} className="btn" style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer' }}>
Skip for now →
</button>
</p>

View File

@@ -0,0 +1,29 @@
import { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import CharacterSheet from '../../components/CharacterSheet.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
// A player's character sheet inside the portal. Owner-checked: the endpoint only
// returns a sheet for a character on an account linked to the caller.
export default function PlayerCharacter() {
const { serial } = useParams()
const { loading, error, data } = useAsync(() => api.player.shard.char(serial), [serial])
const restarting = error && error.status === 503
const forbidden = error && error.status === 403
return (
<div>
<p style={{ margin: '0 0 18px' }}>
<Link to="/player" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
← Back to characters
</Link>
</p>
{loading && <Loading />}
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
{!loading && !error && data && <CharacterSheet char={data} />}
</div>
)
}

View File

@@ -0,0 +1,58 @@
import GameAccounts from '../../components/GameAccounts.jsx'
import VendorSales from '../../components/VendorSales.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
// The logged-in player's characters. Shows the link prompt when no game account
// is linked, otherwise their characters grouped by account (shared component),
// plus their own home status and recent vendor sales.
const DECAY_TONE = {
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
}
// The caller's own houses (home status). Only their own — never anyone else's.
function MyHouses() {
const { data } = useAsync(() => api.player.shard.houses(), [])
if (!data || data.length === 0) return null
return (
<section style={{ marginTop: 30 }}>
<div className="field-label" style={{ marginBottom: 12 }}>My houses</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => {
const label = h.isIdoc ? 'IDOC' : (h.decay || h.stage)
const tone = h.isIdoc ? '#e05a5a' : (DECAY_TONE[label] || 'var(--muted)')
return (
<div key={h.serial} className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>{h.name || 'An unnamed house'}</div>
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{h.region || h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
</div>
</div>
{label && (
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 9px' }}>
{label}
</span>
)}
</div>
)
})}
</div>
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.76rem' }}>
Keep an eye on the decay status — refresh a house in game before it reaches IDOC.
</p>
</section>
)
}
export default function PlayerCharacters() {
return (
<div>
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/char/${serial}`} />
<MyHouses />
<VendorSales fetchSales={api.player.shard.sales} />
</div>
)
}

View File

@@ -1,14 +1,11 @@
import { useMemo } from 'react'
import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom'
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js'
import { firstDestinationFor } from '../../lib/adminNav.js'
import { useNavOverrides } from '../../lib/useNavOverrides.js'
import { withModuleNav } from '../../modules/nav.js'
import { useFeatureGate } from '../../modules/features.jsx'
// Shared shell for the logged-in player portal. Uses the same sidebar shell as
// Admin (icon nav, sticky content header, footer sign-out) so the two logged-in
@@ -33,37 +30,28 @@ function Icon({ children, size = 16 }) {
</svg>
)
}
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
// Exported because Admin -> Navigation edits this list. It stays declared here;
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
// carries a gate — every player sees both — but an installed module's rows join
// this list before the merge and may carry a `feature`, so the filter after it
// is not dead code.
//
// "Characters" was the first row and left with the client half in slice 3; the
// UO module registers it again at `/player/uo/characters`, in this position,
// with `order: 0`.
// the editor may only relabel, reorder and hide what it finds (§7). No row
// carries a gate — every player sees all three — so the merged result is what
// renders, with no filter after it.
export const NAV = [
{ to: '/player', label: 'Characters', end: true, icon: IconUser },
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
{ to: '/account', label: 'Account', end: true, icon: IconGear },
]
// The sticky content header mirrors the active page. A module's pages are not
// here and cannot be — core does not know what they are called — so they title
// from their own nav row, the same rule AdminLayout's `moduleTitle` follows.
// The sticky content header mirrors the active page. Character sheets live under
// /player/char/:serial and keep their own in-page back link.
const TITLES = {
'/player': 'Characters',
'/account': 'Account',
'/account/appeals': 'Appeals',
}
function moduleTitle(baseNav, pathname) {
return baseNav
.filter((i) => i.moduleId && (pathname === i.to || pathname.startsWith(`${i.to}/`)))
.sort((a, b) => b.to.length - a.to.length)[0]?.label
}
const navBtnBase = {
textAlign: 'left',
borderRadius: 8,
@@ -81,15 +69,12 @@ export default function PlayerPortalLayout() {
const { user, logout } = useAuth()
const { siteTitle } = useSite()
const navOverrides = useNavOverrides()
const isVisible = useFeatureGate()
const baseNav = useMemo(() => withModuleNav(NAV, 'player'), [])
const nav = useMemo(
() => applyNavOverrides(baseNav, navOverrides.nav_player).filter(isVisible),
[baseNav, navOverrides.nav_player, isVisible],
)
const nav = useMemo(() => applyNavOverrides(NAV, navOverrides.nav_player), [navOverrides.nav_player])
const navigate = useNavigate()
const location = useLocation()
const title = TITLES[location.pathname] || moduleTitle(baseNav, location.pathname) || 'Player Portal'
const title =
TITLES[location.pathname] ||
(location.pathname.startsWith('/player/char/') ? 'Character' : 'Player Portal')
async function signOut() {
await logout()
@@ -137,13 +122,7 @@ export default function PlayerPortalLayout() {
borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
})}
>
{/* Guarded, like AdminLayout's. `icon` is optional in the nav
contract (§3.3) and every CORE row here has always had one, so
an unguarded `<n.icon />` was fine right up until a module
registered a row without — and then it was not a missing glyph,
it was React error #130 and a blank portal. Found by the §7.7
browser smoke; no DOM-less test can see it. */}
{n.icon && <n.icon />}
<n.icon />
<span>{n.label}</span>
</NavLink>
))}
@@ -195,28 +174,3 @@ export default function PlayerPortalLayout() {
</div>
)
}
/**
* What `/player` renders.
*
* It used to be `PlayerCharacters`, a UO page, which left the portal with no
* index at all when the client half was extracted (slice 3). Rather than pick a
* fixed destination or invent a core landing page, the index resolves to the
* first row of the portal nav this viewer can actually reach — so with the UO
* module installed a player still arrives at their characters, exactly as
* before, and with nothing installed they arrive at Account.
*
* Resolved from the BASE nav, before overrides: where everybody lands is
* behaviour, and an override is presentation (`firstDestinationFor`). `replace`
* so the back button leaves the portal rather than bouncing off this redirect.
*
* The same question exists one area over — the admin index is a hardcoded
* Dashboard — and if the two logged-in areas ever become one, this is the shape
* that answers for both. Nothing here assumes a portal separate from admin.
*/
export function PlayerIndex() {
const { user } = useAuth()
const baseNav = useMemo(() => withModuleNav(NAV, 'player'), [])
const to = firstDestinationFor(baseNav, user?.role, '/account')
return <Navigate to={to} replace />
}

View File

@@ -0,0 +1,202 @@
import { useMemo } from 'react'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { api } from '../../api/client.js'
// The champion-spawn board. Loaded once from /public/shard/champs, then kept live
// by merging champ.update / champ.remove deltas from the public SSE feed. Three
// families share the board, split by category into their own sections.
const CHAMP_KINDS = new Set(['champ.update', 'champ.remove'])
const SECTIONS = [
{ id: 'champion', title: 'Champion altars', blurb: 'Felucca-style altar spawns.' },
{ id: 'mini', title: 'Mini champs', blurb: 'TerMur controllers — they re-arm on their own.' },
{ id: 'sea', title: 'Sea bosses', blurb: 'High Seas world bosses, alive only while summoned.' },
]
const STATUS_STYLE = {
active: { bg: 'rgba(95,185,138,0.16)', fg: '#8fdcae', border: 'rgba(95,185,138,0.45)', label: 'Active' },
cooldown: { bg: 'rgba(230,194,106,0.14)', fg: '#e6c26a', border: 'rgba(230,194,106,0.4)', label: 'Cooldown' },
dormant: { bg: 'rgba(140,150,165,0.14)', fg: '#aab3c0', border: 'rgba(140,150,165,0.35)', label: 'Dormant' },
}
// A short "in 4m" / "in 2h" for a future ISO timestamp (restartAt / expireAt).
function until(iso) {
if (!iso) return ''
const ms = new Date(iso).getTime() - Date.now()
if (!Number.isFinite(ms)) return ''
if (ms <= 0) return 'due'
const mins = Math.round(ms / 60000)
if (mins < 60) return `in ${mins}m`
const hrs = Math.round(mins / 60)
return `in ${hrs}h`
}
function StatusBadge({ status }) {
const s = STATUS_STYLE[status] || STATUS_STYLE.dormant
return (
<span
className="sans"
style={{
flex: 'none',
fontSize: '0.68rem',
letterSpacing: '0.08em',
textTransform: 'uppercase',
padding: '3px 9px',
borderRadius: 999,
color: s.fg,
background: s.bg,
border: `1px solid ${s.border}`,
}}
>
{s.label}
</span>
)
}
// A slim progress bar (kills toward the next level, or a sea boss's hit points).
function Meter({ value, max, tone = 'var(--accent)' }) {
if (!max) return null
const pct = Math.max(0, Math.min(100, (Number(value) / Number(max)) * 100))
return (
<div style={{ height: 6, borderRadius: 4, background: 'rgba(255,255,255,0.07)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: tone, borderRadius: 4 }} />
</div>
)
}
// Category-specific middle line + meter for one spawn.
function ChampDetail({ s }) {
const line = { display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.8rem', color: 'var(--muted)', marginTop: 8 }
if (s.category === 'sea') {
return (
<>
<div className="sans" style={line}>
<span>{s.boss || s.type}</span>
{s.hitsMax != null && <span>{Number(s.hits).toLocaleString()} / {Number(s.hitsMax).toLocaleString()} hp</span>}
</div>
<div style={{ marginTop: 6 }}><Meter value={s.hits} max={s.hitsMax} tone="#d9736f" /></div>
</>
)
}
if (s.category === 'mini') {
return (
<div className="sans" style={line}>
<span>Level {s.level ?? 0}{s.maxLevel != null ? ` / ${s.maxLevel}` : ''}</span>
<span>{s.status === 'active' ? 'Running' : 'Re-arming'}</span>
</div>
)
}
// champion
let progress = ''
if (s.status === 'cooldown') progress = until(s.restartAt) || 'restarting'
else if (s.status === 'active') {
progress = `${Number(s.kills || 0).toLocaleString()} / ${Number(s.maxKills || 0).toLocaleString()} kills`
}
return (
<>
<div className="sans" style={line}>
<span>
Level {s.level ?? 0}
{s.bossUp && s.boss ? ` — ${s.boss}` : ''}
</span>
<span>{progress}</span>
</div>
{s.status === 'active' && (
<div style={{ marginTop: 6 }}><Meter value={s.kills} max={s.maxKills} /></div>
)}
</>
)
}
function ChampCard({ s }) {
return (
<div className="panel" style={{ padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<strong className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s.name || s.type || 'Spawn'}
</strong>
<StatusBadge status={s.status} />
</div>
<ChampDetail s={s} />
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.74rem' }}>
{s.map || '—'}{s.x != null ? ` (${s.x}, ${s.y})` : ''}
</div>
</div>
)
}
export default function ChampSpawns() {
const { loading, error, data } = useAsync(() => api.shard.champs())
const { events, connected } = useShardFeed({ filter: CHAMP_KINDS, max: 60 })
// Merge the initial snapshot with live deltas: seed a map by serial, then apply
// buffered events oldest → newest (the buffer is newest-first) so live wins.
const board = useMemo(() => {
const map = new Map()
for (const s of data || []) if (s && s.serial) map.set(s.serial, s)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (!ev || !ev.serial) continue
if (ev.kind === 'champ.update') map.set(ev.serial, ev)
else if (ev.kind === 'champ.remove') map.delete(ev.serial)
}
return [...map.values()]
}, [data, events])
const byCategory = (id) =>
board.filter((s) => (s.category || 'champion') === id).sort((a, b) => (a.name || '').localeCompare(b.name || ''))
const activeCount = board.filter((s) => s.status === 'active').length
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader eyebrow="Live" title="Champion spawns" lead="Every altar, mini-champ and sea boss across the shard, updating in real time." />
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the champion board right now." />}
{!loading && !error && (
<>
{board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No champion spawns are being tracked right now.</p>
</section>
) : (
<>
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', marginTop: -12, marginBottom: 24 }}>
{activeCount} active · {board.length} tracked
</p>
{SECTIONS.map((sec) => {
const rows = byCategory(sec.id)
if (rows.length === 0) return null
return (
<section key={sec.id} style={{ marginBottom: 28 }}>
<div style={{ marginBottom: 12 }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.1rem', color: 'var(--head)' }}>{sec.title}</h2>
<p className="sans dim" style={{ margin: '2px 0 0', fontSize: '0.8rem' }}>{sec.blurb}</p>
</div>
<div className="grid-2" style={{ gap: 12 }}>
{rows.map((s) => <ChampCard key={s.serial} s={s} />)}
</div>
</section>
)
})}
</>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,187 @@
import { useMemo, useState } from 'react'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { crestFor } from '../../data/cityCrests.js'
import { api } from '../../api/client.js'
// The town-governor board (City Loyalty). Loaded from /public/shard/governors,
// kept live by merging city.update deltas by city. Empty on shards without the
// City Loyalty system. Each city card links to its term history (look-back).
const GOV_KINDS = new Set(['city.update'])
const PHASE = {
none: null,
nominate: { label: 'Nominations open', color: '#7f8fd0' },
vote: { label: 'Voting', color: '#e6c26a' },
pending: { label: 'Result pending', color: '#c9a24b' },
}
// A short "in 3d" / "in 5h" for a future ISO timestamp (autoPickAt).
function until(iso) {
if (!iso) return ''
const ms = new Date(iso).getTime() - Date.now()
if (!Number.isFinite(ms) || ms <= 0) return ''
const mins = Math.round(ms / 60000)
if (mins < 60) return `in ${mins}m`
const hrs = Math.round(mins / 60)
if (hrs < 24) return `in ${hrs}h`
return `in ${Math.round(hrs / 24)}d`
}
function fmtDate(ms) {
if (ms == null) return ''
return new Date(Number(ms)).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
function CityCrest({ city, size = 44 }) {
const c = crestFor(city)
return (
<span
aria-hidden="true"
style={{
flex: 'none', width: size, height: size, borderRadius: '50%',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.5, background: 'rgba(255,255,255,0.04)',
border: `2px solid ${c.color}`, boxShadow: `0 0 10px ${c.color}22`,
}}
>
{c.sigil}
</span>
)
}
// Collapsible term history for one city, fetched on demand from the ledger.
function TermHistory({ city }) {
const [open, setOpen] = useState(false)
const { loading, error, data } = useAsync(
() => (open ? api.shard.governorHistory(city, 25) : Promise.resolve(null)),
[open, city],
)
return (
<div style={{ marginTop: 12 }}>
<button
type="button"
className="sans"
onClick={() => setOpen((v) => !v)}
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0, fontSize: '0.76rem' }}
>
{open ? 'Hide past governors' : 'Past governors →'}
</button>
{open && (
<div style={{ marginTop: 8 }}>
{loading && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Loading…</p>}
{error && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Could not load history.</p>}
{data && data.length === 0 && (
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>No recorded terms yet.</p>
)}
{data && data.length > 0 && (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
{data.map((t) => (
<li key={`${t.startedAt}-${t.governor?.name ?? 'vacant'}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.8rem', color: 'var(--ink)' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.governor?.name || 'Vacant'}
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.72rem' }}>
{fmtDate(t.startedAt)}{t.endedAt ? ` – ${fmtDate(t.endedAt)}` : ' – present'}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
function CityCard({ c }) {
const phase = PHASE[c.electionPhase] || null
const gov = c.governor
const candidatePlural = c.candidates === 1 ? '' : 's'
return (
<div className="panel" style={{ padding: 18 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<CityCrest city={c.city} />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<strong className="display" style={{ fontSize: '1.05rem', color: 'var(--head)' }}>
{crestFor(c.city).label || c.city}
</strong>
{phase && (
<span className="sans" style={{ flex: 'none', fontSize: '0.66rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: phase.color, border: `1px solid ${phase.color}66`, borderRadius: 999, padding: '2px 8px' }}>
{phase.label}
</span>
)}
</div>
<div className="sans" style={{ marginTop: 3, fontSize: '0.9rem', color: gov ? 'var(--ink)' : 'var(--muted)' }}>
{gov ? (
<>Governor <strong style={{ color: 'var(--head)' }}>{gov.name}</strong></>
) : (
'Seat vacant'
)}
</div>
</div>
</div>
{c.electionPhase && c.electionPhase !== 'none' && (
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.78rem' }}>
{c.candidates ? `${c.candidates} candidate${candidatePlural}` : 'No candidates yet'}
{c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''}
</div>
)}
<TermHistory city={c.city} />
</div>
)
}
export default function Governors() {
const { loading, error, data } = useAsync(() => api.shard.governors())
const { events, connected } = useShardFeed({ filter: GOV_KINDS, max: 30 })
const board = useMemo(() => {
const map = new Map()
for (const c of data || []) if (c && c.city) map.set(c.city, c)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (ev.kind === 'city.update' && ev.city) map.set(ev.city, ev)
}
return [...map.values()].sort((a, b) => (a.city || '').localeCompare(b.city || ''))
}, [data, events])
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader eyebrow="Live" title="Governors of Britannia" lead="Who rules each city, and where the next election stands." />
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the governor board right now." />}
{!loading && !error && (
<>
{board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>
City Loyalty governance is not enabled on this shard.
</p>
</section>
) : (
<div className="grid-2" style={{ gap: 12 }}>
{board.map((c) => <CityCard key={c.city} c={c} />)}
</div>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,169 @@
import { useMemo, useState } from 'react'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { api } from '../../api/client.js'
// The guild board. Loaded once from /public/shard/guilds, then kept live by
// merging guild.update / guild.remove deltas; guild.join drives a small "recently
// joined" strip on top of the board.
const GUILD_KINDS = new Set(['guild.update', 'guild.remove', 'guild.join'])
function Leader({ leader }) {
if (!leader || !leader.name) return <span className="dim">—</span>
return <span>{leader.name}</span>
}
function GuildRow({ g }) {
return (
<div
className="panel"
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}
>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
{g.abbr && (
<span
className="sans"
style={{
flex: 'none',
fontSize: '0.72rem',
letterSpacing: '0.06em',
color: 'var(--accent)',
border: '1px solid rgba(201,162,75,0.4)',
borderRadius: 5,
padding: '1px 6px',
}}
>
{g.abbr}
</span>
)}
<strong
className="display"
style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{g.name || 'A guild'}
</strong>
</div>
{g.alliance && (
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{g.alliance}
</div>
)}
</div>
<div className="sans" style={{ flex: 'none', textAlign: 'right', fontSize: '0.84rem', color: 'var(--ink)' }}>
<div>
<span style={{ color: '#7fd0a4' }}>{g.online ?? 0}</span>
<span className="dim"> / {g.members ?? 0}</span>
</div>
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
<Leader leader={g.leader} />
</div>
</div>
</div>
)
}
export default function Guilds() {
const { loading, error, data } = useAsync(() => api.shard.guilds())
const { events, connected } = useShardFeed({ filter: GUILD_KINDS, max: 60 })
const [q, setQ] = useState('')
// Merge snapshot + live deltas by guild id (apply oldest → newest so live wins).
const board = useMemo(() => {
const map = new Map()
for (const g of data || []) if (g && g.id != null) map.set(g.id, g)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (ev.kind === 'guild.update' && ev.id != null) map.set(ev.id, ev)
else if (ev.kind === 'guild.remove' && ev.id != null) map.delete(ev.id)
}
return [...map.values()]
}, [data, events])
// Recent joins strip (newest first, deduped, capped).
const joins = useMemo(
() => events.filter((e) => e.kind === 'guild.join' && e.who).slice(0, 6),
[events],
)
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase()
const rows = needle
? board.filter((g) =>
[g.name, g.abbr, g.alliance].some((v) => v && v.toLowerCase().includes(needle)),
)
: board
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
}, [board, q])
const totalMembers = board.reduce((n, g) => n + (Number(g.members) || 0), 0)
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader eyebrow="Live" title="Guilds" lead="Every guild on the shard — rosters, alliances and who's online, updating in real time." />
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the guild board right now." />}
{!loading && !error && (
<>
{board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No guilds are being tracked right now.</p>
</section>
) : (
<>
{joins.length > 0 && (
<section className="panel" style={{ padding: '12px 16px', marginBottom: 18 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.66rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 8 }}>
Recently joined
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
{joins.map((j) => (
<div key={j._id} className="sans" style={{ fontSize: '0.84rem', color: 'var(--ink)' }}>
<strong style={{ color: 'var(--head)' }}>{j.who.name}</strong>
<span className="dim"> joined </span>
{j.abbr ? `[${j.abbr}] ` : ''}{j.name}
</div>
))}
</div>
</section>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', margin: 0 }}>
{board.length} guilds · {totalMembers.toLocaleString()} members
</p>
<input
className="input sans"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search guilds…"
style={{ flex: 'none', width: 190, maxWidth: '50%', fontSize: '0.84rem' }}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map((g) => <GuildRow key={g.id} g={g} />)}
</div>
{filtered.length === 0 && (
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No guilds match “{q}”.</p>
)}
</>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,91 @@
import { useMemo } from 'react'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { api } from '../../api/client.js'
// PUBLIC houses board: only houses in danger (IDOC), shown by location. Owner,
// price, decay detail and the full registry are staff-only (admin Houses view).
// Loaded from /public/shard/houses (IDOC-only), kept live by house.decay: a
// house entering IDOC appears, one leaving it drops off.
const HOUSE_KINDS = new Set(['house.decay'])
function HouseRow({ h }) {
return (
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<span
aria-hidden="true"
style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#e05a5a', boxShadow: '0 0 8px rgba(224,90,90,0.7)' }}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{h.region || 'The wilderness'}
</div>
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
</div>
</div>
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', letterSpacing: '0.06em', color: '#e05a5a', border: '1px solid #e05a5a66', borderRadius: 999, padding: '2px 9px' }}>
IDOC
</span>
</div>
)
}
export default function Houses() {
const { loading, error, data } = useAsync(() => api.shard.houses())
const { events, connected } = useShardFeed({ filter: HOUSE_KINDS, max: 60 })
// Merge the IDOC snapshot with live house.decay deltas by serial: entering IDOC
// adds/updates the row; anything else (refreshed, collapsed) drops it.
const board = useMemo(() => {
const map = new Map()
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (ev.kind !== 'house.decay' || !ev.serial) continue
if (String(ev.to).toUpperCase() === 'IDOC') {
map.set(ev.serial, { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y, z: ev.z, isIdoc: true })
} else {
map.delete(ev.serial)
}
}
return [...map.values()].sort((a, b) => (a.region || '').localeCompare(b.region || ''))
}, [data, events])
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader eyebrow="Live" title="Houses in danger" lead="Homes that have fallen into IDOC — where to find them before they collapse." />
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the houses board right now." />}
{!loading && !error && (
board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No houses are collapsing right now.</p>
</section>
) : (
<>
<p className="sans" style={{ color: '#e0928a', fontSize: '0.8rem', marginTop: -12, marginBottom: 20 }}>
{board.length} in danger
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{board.map((h) => <HouseRow key={h.serial} h={h} />)}
</div>
</>
)
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,240 @@
import { useMemo, useState } from 'react'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { api } from '../../api/client.js'
import { useSite } from '../../contexts/SiteContext.jsx'
// Points / loyalty leaderboards (Protocol 3.0 §7). The shard carries ~25 separate
// point currencies — Queen's Loyalty, Void Pool, Clean Up Britannia, the nine city
// loyalties, the Doom/Khaldun/Kotl treasure systems — every one of them a standing
// players build over months, and none of them visible anywhere but an in-game gump
// until now.
//
// Loaded from /public/shard/points, then kept current from the live feed. Unlike
// the ruleset (one frame = the whole thing), a points.board frame describes ONE
// system, so live frames are merged over the fetched set by system key rather than
// replacing it.
const POINTS_KINDS = new Set(['points.board'])
// A board's display name may arrive as a literal (`nameString`), a cliloc id
// (`nameNumber`), or both — Name is a ServUO TextDefinition. We have no cliloc
// table on the site, so a cliloc-only board falls back to humanising its own
// PointsType key, which is already close to a display name ("CleanUpBritannia" →
// "Clean Up Britannia"). Better than showing a bare number.
const humanise = (key) =>
String(key || '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/^./, (c) => c.toUpperCase())
const boardTitle = (b) => b.nameString || humanise(b.system)
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
// Merge live frames over the fetched boards. Newest frame per system wins; a
// system that has never appeared in either is simply absent.
function mergeBoards(fetched, events) {
const bySystem = new Map()
for (const b of Array.isArray(fetched) ? fetched : []) {
if (b && b.system) bySystem.set(b.system, b)
}
// Events arrive newest-first, so walk backwards and let the newest land last.
for (let i = events.length - 1; i >= 0; i--) {
const ev = events[i]
if (ev && ev.system) bySystem.set(ev.system, ev)
}
return [...bySystem.values()].sort((a, b) => boardTitle(a).localeCompare(boardTitle(b)))
}
function Medal({ rank }) {
// Gold / silver / bronze for the podium, plain for the rest.
const tone = rank === 1 ? '#c9a24b' : rank === 2 ? '#b6bcc6' : rank === 3 ? '#b3805a' : 'var(--muted)'
return (
<span
className="display"
style={{
flex: 'none', width: 26, textAlign: 'right', color: tone,
fontSize: rank <= 3 ? '1rem' : '0.86rem',
}}
>
{rank}
</span>
)
}
// One ranked player. `name` is absent rather than empty when an admin has gated
// the leaderboards `name` field above this viewer's rung — the row still renders,
// because the standing itself is the point.
function Entry({ entry, best }) {
const pct = best > 0 ? Math.max(2, Math.round((entry.points / best) * 100)) : 0
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
<Medal rank={entry.rank} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
<span
className="sans"
style={{
color: entry.name ? 'var(--ink)' : 'var(--muted)',
fontSize: '0.86rem', fontStyle: entry.name ? 'normal' : 'italic',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{entry.name || 'Name hidden'}
</span>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
{num(entry.points)}
</span>
</div>
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden', marginTop: 3 }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
</div>
)
}
function Board({ board }) {
const { siteTitle } = useSite()
const top = Array.isArray(board.top) ? board.top : []
// Bars are relative to the board leader, not to maxPoints: most systems have no
// cap (maxPoints 0), and where there is one the leader is often nowhere near it,
// which would render every bar as a stub.
const best = top.reduce((m, e) => Math.max(m, e.points || 0), 0)
return (
<section className="panel" style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10 }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.02rem', color: 'var(--head)' }}>
{boardTitle(board)}
</h2>
{Number.isFinite(board.players) && (
<span className="sans dim" style={{ fontSize: '0.72rem', flex: 'none' }}>
{num(board.players)} ranked
</span>
)}
</div>
{top.length === 0 ? (
// A board nobody has scored on still gets a row, so the page reads as a set
// of standings waiting to be filled rather than a stack of blanks. It is
// deliberately NOT shaped like an Entry — no medal, no bar, an em dash where
// a score goes — because a placeholder that looked like a real standing would
// be a fabricated one. The first real entry replaces it.
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10, padding: '6px 0' }}>
<span
className="sans"
style={{
color: 'var(--muted)', fontSize: '0.86rem',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{siteTitle}
</span>
<span className="sans dim" style={{ fontSize: '0.82rem', flex: 'none' }}>&mdash;</span>
</div>
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
Nobody has earned points here yet.
</p>
</div>
) : (
<div>
{top.map((entry) => (
<Entry key={`${board.system}-${entry.rank}-${entry.serial}`} entry={entry} best={best} />
))}
</div>
)}
{Number.isFinite(board.maxPoints) && board.maxPoints > 0 && (
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
Maximum {num(board.maxPoints)} points
</span>
)}
</section>
)
}
export default function Leaderboards() {
const { loading, error, data } = useAsync(() => api.shard.points())
// Buffer generously: a single sweep can emit a frame for every system at once,
// and a board dropped from the buffer would silently revert to its fetched copy.
const { events, connected } = useShardFeed({ filter: POINTS_KINDS, max: 60 })
const [query, setQuery] = useState('')
const boards = useMemo(() => mergeBoards(data, events), [data, events])
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return boards
// Match the board name, the raw system key, or any ranked player on it — the
// last is what makes the filter useful ("where do I appear?").
return boards.filter(
(b) =>
boardTitle(b).toLowerCase().includes(q) ||
String(b.system).toLowerCase().includes(q) ||
(b.top || []).some((e) => e.name && e.name.toLowerCase().includes(q)),
)
}, [boards, query])
return (
<PublicLayout section="website">
<div className="shell page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader
eyebrow="Live"
title="Leaderboards"
lead="Loyalty and points standings, straight from the shard — every currency the server tracks, updated as players climb."
/>
<span
className="sans"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem',
color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6,
}}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the leaderboards right now." />}
{!loading && !error && boards.length === 0 && (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>
The shard has not published any leaderboards yet.
</p>
</section>
)}
{!loading && !error && boards.length > 0 && (
<>
<input
className="input"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter by board or player name…"
aria-label="Filter leaderboards"
style={{ maxWidth: 340, marginBottom: 14 }}
/>
{shown.length === 0 ? (
<p className="sans dim">No board or ranked player matches “{query}”.</p>
) : (
<div className="grid-2" style={{ gap: 12, alignItems: 'start' }}>
{shown.map((board) => (
<Board key={board.system} board={board} />
))}
</div>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,325 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
// ── The player-vendor marketplace ───────────────────────────────────────────
//
// What every player vendor on the shard is selling, for how much, and where it
// is standing — the same index the in-game Vendor Search gump reads, honouring
// the same per-vendor opt-out, reachable without logging in to the game.
//
// Three things this page must be honest about, all of them consequences of how
// the data is gathered (docs/link/v3.md §8):
//
// • **The prices are not live.** The shard sweeps vendors round-robin, so a
// shop can be a full cycle behind. The banner says how far, from `staleAt`.
// A page that implied live prices would send people across the world to a
// vendor whose item sold twenty minutes ago.
// • **A shop can be truncated.** A commodity reseller with thousands of stacks
// publishes only the first N, and saying so beats presenting a partial shop
// as complete.
// • **An item may have no name.** On a shard whose operator has not converted
// a cliloc table, `displayName` is null and the honest render is the item id
// — not an invented name.
//
// There is deliberately no live feed here. The market feature's SSE stream ships
// disabled: a firehose of whole vendor inventories would be the site's single
// biggest bandwidth consumer, and nothing on this page needs it.
const PAGE = 50
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
const SORTS = [
{ key: 'price_asc', label: 'Cheapest' },
{ key: 'price_desc', label: 'Priciest' },
{ key: 'recent', label: 'Recently seen' },
]
// How old the index may be, in words. `staleAt` is the OLDEST vendor row, so
// this is a worst case rather than an average — which is the number worth
// showing, because the one stale shop is the one that wastes a trip.
function staleness(staleAt) {
if (!staleAt) return null
const ms = Date.now() - new Date(staleAt).getTime()
if (!Number.isFinite(ms) || ms < 0) return null
const mins = Math.round(ms / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`
const hours = Math.round(mins / 60)
if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
return `${Math.round(hours / 24)} days ago`
}
// The item's name, or an honest statement that we do not have one. Never a
// fabricated label — "Item 3922" would be indistinguishable from a real name.
const itemLabel = (l) => l.displayName || l.name || `id ${l.itemId}`
function Chip({ active, onClick, children }) {
return (
<button
type="button"
onClick={onClick}
className="sans"
style={{
fontSize: '0.78rem',
padding: '5px 12px',
borderRadius: 999,
cursor: 'pointer',
color: active ? 'var(--bg-deep)' : 'var(--muted)',
background: active ? 'var(--accent)' : 'transparent',
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
}}
>
{children}
</button>
)
}
function ListingRow({ listing }) {
const v = listing.vendor || {}
// `location` is one field the admin can gate away wholesale, so everything
// that reads from it has to tolerate its absence rather than assuming a map.
const loc = v.location || null
const where = loc ? [loc.region, loc.map].filter(Boolean).join(', ') : null
return (
<div className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div
className="display"
style={{ fontSize: '0.98rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{listing.amount > 1 ? `${num(listing.amount)} × ` : ''}
{itemLabel(listing)}
</div>
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
{v.serial ? (
<Link to={`/site/market/vendors/${encodeURIComponent(v.serial)}`} style={{ color: 'inherit' }}>
{v.shopName || 'an unnamed shop'}
</Link>
) : (
v.shopName || 'an unnamed shop'
)}
{v.ownerName ? ` · ${v.ownerName}` : ''}
{where ? ` · ${where}` : ''}
{/* Priced by the container it sits in, exactly as the in-game search
reports it — the price buys the whole container, not this item. */}
{listing.child ? ' · sold with its container' : ''}
</div>
</div>
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(listing.price)}</div>
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>gold</div>
</div>
</div>
)
}
export default function Market() {
const [input, setInput] = useState('')
const [q, setQ] = useState('')
const [map, setMap] = useState('')
const [region, setRegion] = useState('')
const [sort, setSort] = useState('price_asc')
const [minPrice, setMinPrice] = useState('')
const [maxPrice, setMaxPrice] = useState('')
// Applied prices are separate from the typed ones so the search fires when the
// user is done, not on every digit of "250000".
const [prices, setPrices] = useState({ min: '', max: '' })
const [state, setState] = useState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
const [more, setMore] = useState(false)
const meta = useAsync(() => api.shard.marketMeta())
// Debounced: typing "vanquishing" should be one request, not eleven — and the
// endpoint is rate-limited, so an undebounced box would 429 a fast typist.
useEffect(() => {
const timer = setTimeout(() => setQ(input.trim()), 300)
return () => clearTimeout(timer)
}, [input])
useEffect(() => {
const timer = setTimeout(() => setPrices({ min: minPrice, max: maxPrice }), 500)
return () => clearTimeout(timer)
}, [minPrice, maxPrice])
const load = useCallback(
(offset) =>
api.shard.market({
q,
map,
region,
sort,
minPrice: prices.min,
maxPrice: prices.max,
limit: PAGE,
offset,
}),
[q, map, region, sort, prices],
)
useEffect(() => {
let alive = true
setState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
load(0)
.then((page) => {
if (!alive) return
setState({
loading: false,
error: null,
listings: page.listings || [],
total: page.total || 0,
staleAt: page.staleAt || null,
})
})
.catch((error) => alive && setState({ loading: false, error, listings: [], total: 0, staleAt: null }))
return () => {
alive = false
}
}, [load])
const loadMore = async () => {
setMore(true)
try {
const page = await load(state.listings.length)
setState((s) => ({
...s,
listings: [...s.listings, ...(page.listings || [])],
total: page.total ?? s.total,
staleAt: page.staleAt ?? s.staleAt,
}))
} catch {
// A failed "load more" leaves what is on screen alone; the button stays
// available to retry.
} finally {
setMore(false)
}
}
const maps = meta.data?.maps || []
const regions = meta.data?.regions || []
const age = staleness(state.staleAt)
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader
eyebrow="Marketplace"
title="Player vendors"
lead="Every shop on the shard, searchable from here — the same index the in-game vendor search reads, and it honours the same per-vendor opt-out."
/>
{/* Not decoration. The sweep is round-robin, so the index is inherently
up to one full cycle old and the page has to say so. */}
{age && (
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
Prices last refreshed {age}
{meta.data?.vendors ? ` · ${num(meta.data.vendors)} shops` : ''}
{meta.data?.items ? ` · ${num(meta.data.items)} listings` : ''}
</p>
)}
<input
className="input"
type="search"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Search listings…"
style={{ width: '100%', marginBottom: 10 }}
/>
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<input
className="input"
type="number"
min="0"
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
placeholder="Min price"
style={{ maxWidth: 140 }}
/>
<input
className="input"
type="number"
min="0"
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
placeholder="Max price"
style={{ maxWidth: 140 }}
/>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
{SORTS.map((s) => (
<Chip key={s.key} active={sort === s.key} onClick={() => setSort(s.key)}>
{s.label}
</Chip>
))}
</div>
{/* Facet and region names come from the shard's own data, never a list in
this file — a shard running custom maps gets its own names here with
no code change (docs/link/v3.md §6.1 R2). */}
{maps.length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
<Chip active={map === ''} onClick={() => setMap('')}>All facets</Chip>
{maps.map((m) => (
<Chip key={m} active={map === m} onClick={() => setMap(m)}>{m}</Chip>
))}
</div>
)}
{regions.length > 0 && (
<select
className="input"
value={region}
onChange={(e) => setRegion(e.target.value)}
style={{ width: '100%', marginBottom: 18 }}
>
<option value="">Anywhere</option>
{regions.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
)}
{state.loading && <Loading />}
{state.error && <ErrorState message="Could not load the marketplace right now." />}
{!state.loading && !state.error && state.listings.length === 0 && (
<EmptyState>
{meta.data?.vendors
? 'Nothing on the shard matches that.'
: 'No player vendors have been indexed yet.'}
</EmptyState>
)}
{!state.loading && !state.error && state.listings.length > 0 && (
<>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
Showing {num(state.listings.length)} of {num(state.total)}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{state.listings.map((l) => (
<ListingRow key={`${l.vendor?.serial}:${l.serial}`} listing={l} />
))}
</div>
{state.listings.length < state.total && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<button type="button" className="btn" onClick={loadMore} disabled={more}>
{more ? 'Loading…' : 'Load more'}
</button>
</div>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,102 @@
import { Link, useParams } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
// One player vendor: where to find it and everything it is selling.
//
// The page a search result points at. Two states it has to render honestly and
// which the search list cannot (docs/link/v3.md §8):
//
// • `truncated` — the shop holds more than the shard publishes per frame. A
// commodity reseller with thousands of stacks is a real thing, and showing
// 250 of 3,104 as if it were the whole shop would be a lie about the shard.
// • a gated `location` — an admin may put vendor whereabouts behind a rung, in
// which case there is nothing to render and the page says so rather than
// showing an empty coordinate.
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
const itemLabel = (i) => i.displayName || i.name || `id ${i.itemId}`
export default function MarketVendor() {
const { serial } = useParams()
const { loading, error, data } = useAsync(() => api.shard.marketVendor(serial), [serial])
if (loading) {
return (
<PublicLayout section="website">
<div className="shell-narrow page-body"><Loading /></div>
</PublicLayout>
)
}
if (error || !data) {
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<ErrorState message="That shop is not in the index — it may have been dismissed or hidden." />
<p style={{ marginTop: 16 }}>
<Link to="/site/market" className="sans">← Back to the marketplace</Link>
</p>
</div>
</PublicLayout>
)
}
const loc = data.location || null
const items = data.items || []
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader
eyebrow={data.ownerName ? `Run by ${data.ownerName}` : 'Player vendor'}
title={data.shopName || 'An unnamed shop'}
lead={
loc
? [loc.house, loc.region, loc.map].filter(Boolean).join(' · ') +
(Number.isFinite(loc.x) ? ` — ${loc.x}, ${loc.y}` : '')
: 'This shard does not publish vendor locations.'
}
/>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '-12px 0 18px' }}>
{data.truncated
? `Showing ${num(data.count)} of ${num(data.total)} listings — this shop holds more than the shard publishes.`
: `${num(data.total)} listing${data.total === 1 ? '' : 's'}`}
{data.updatedAt ? ` · last seen ${new Date(data.updatedAt).toLocaleString()}` : ''}
</p>
{items.length === 0 ? (
<EmptyState>This shop has nothing priced for sale.</EmptyState>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{items.map((i) => (
<div
key={i.serial}
className="panel"
style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}
>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>
{i.amount > 1 ? `${num(i.amount)} × ` : ''}
{itemLabel(i)}
{i.child ? <span className="dim"> · sold with its container</span> : null}
</span>
<span className="sans" style={{ flex: 'none', color: 'var(--head)', fontSize: '0.88rem' }}>
{num(i.price)}
</span>
</div>
))}
</div>
)}
<p style={{ marginTop: 20 }}>
<Link to="/site/market" className="sans">← Back to the marketplace</Link>
</p>
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,341 @@
import { useMemo } from 'react'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { api } from '../../api/client.js'
// The shard ruleset. Loaded from /public/shard/ruleset, replaced wholesale by any
// world.ruleset frame on the live feed (the shard re-emits the entire ruleset, so
// there is nothing to merge — latest wins).
//
// Everything on this page is published BY THE SHARD from its own Config/*.cfg, so
// it cannot drift the way a hand-written rules page does. That is the whole point
// of the feature, and the page says so.
const RULESET_KINDS = new Set(['world.ruleset'])
// Skill and stat caps arrive in tenths, the way ServUO stores them: 1000 is 100.0
// skill. Showing the raw number would be actively misleading.
const tenths = (v) => (Number.isFinite(v) ? (v / 10).toFixed(1) : null)
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : null)
const pct = (v) => (Number.isFinite(v) ? `${v}%` : null)
// The systems block is a flat bag of booleans; these are their display names, and
// the order here is the order they render. A key the shard sends that we don't
// know about still renders, humanised, rather than being silently dropped — a new
// plugin must not go invisible against an older client.
const SYSTEM_LABELS = {
cityLoyalty: 'City Loyalty (governors)',
vvv: 'Vice vs Virtue',
factions: 'Factions',
siege: 'Siege ruleset',
chat: 'In-game chat',
store: 'Ultima Store',
dailyRares: 'Daily rares',
honesty: 'Honesty virtue',
shadowguard: 'Shadowguard',
treasureMaps: 'Treasure maps',
vetRewards: 'Veteran rewards',
testCenter: 'Test Center',
}
const humanise = (key) =>
key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())
function Panel({ title, children }) {
return (
<section className="panel" style={{ padding: 18 }}>
<h2
className="display"
style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}
>
{title}
</h2>
{children}
</section>
)
}
// A label/value row. Rows whose value is null are dropped by the caller, so a
// block never renders a dangling label for something the shard didn't publish.
function Row({ label, value }) {
return (
<div
className="sans"
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 12,
padding: '5px 0',
borderBottom: '1px solid var(--line)',
fontSize: '0.86rem',
}}
>
<span className="dim" style={{ minWidth: 0 }}>{label}</span>
<strong style={{ flex: 'none', color: 'var(--head)' }}>{value}</strong>
</div>
)
}
function Rows({ items }) {
const rows = items.filter(([, value]) => value !== null && value !== undefined)
if (rows.length === 0) return null
return (
<div>
{rows.map(([label, value]) => (
<Row key={label} label={label} value={value} />
))}
</div>
)
}
function SystemPill({ label, on }) {
const color = on ? '#8fdcae' : 'var(--muted)'
return (
<span
className="sans"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 7,
fontSize: '0.8rem',
padding: '5px 11px',
borderRadius: 999,
color,
background: on ? 'rgba(95,185,138,0.12)' : 'rgba(140,150,165,0.1)',
border: `1px solid ${on ? 'rgba(95,185,138,0.4)' : 'var(--line)'}`,
}}
>
<span
aria-hidden="true"
style={{ width: 7, height: 7, borderRadius: '50%', background: color, flex: 'none' }}
/>
{label}
</span>
)
}
function Systems({ systems }) {
// Known keys first in their declared order, then anything the shard added that
// this build doesn't know about.
const known = Object.keys(SYSTEM_LABELS).filter((k) => k in systems)
const extra = Object.keys(systems).filter((k) => !(k in SYSTEM_LABELS))
const keys = [...known, ...extra]
if (keys.length === 0) return null
return (
<Panel title="Systems">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{keys.map((k) => (
<SystemPill key={k} label={SYSTEM_LABELS[k] || humanise(k)} on={!!systems[k]} />
))}
</div>
</Panel>
)
}
function Caps({ caps }) {
return (
<Panel title="Skill & stat caps">
<Rows
items={[
['Individual skill cap', tenths(caps.skill)],
['Total skill cap', tenths(caps.totalSkill)],
['Total stat cap', num(caps.stat)],
['Strength cap', num(caps.str)],
['Dexterity cap', num(caps.dex)],
['Intelligence cap', num(caps.int)],
['Strength max', num(caps.strMax)],
['Dexterity max', num(caps.dexMax)],
['Intelligence max', num(caps.intMax)],
]}
/>
</Panel>
)
}
function AccountsAndHousing({ accounts, housing, vetRewards }) {
const items = []
if (accounts) {
items.push(['Accounts per IP', num(accounts.perIp)])
items.push(['Character slots', num(accounts.charSlots)])
items.push([
'In-game account creation',
accounts.autoCreate === undefined ? null : accounts.autoCreate ? 'Enabled' : 'Website only',
])
}
if (housing) items.push(['Houses per account', num(housing.accountHouseLimit)])
if (vetRewards?.enabled) {
items.push(['Veteran reward interval', vetRewards.rewardIntervalDays
? `${vetRewards.rewardIntervalDays} days`
: null])
}
if (items.length === 0) return null
return (
<Panel title="Accounts & housing">
<Rows items={items} />
</Panel>
)
}
function Champions({ champions }) {
const t = champions.rankThresholds
return (
<Panel title="Champion spawns">
<Rows
items={[
['Power scrolls per spawn', num(champions.powerScrolls)],
['Stat scrolls per spawn', num(champions.statScrolls)],
['Scroll drop chance', pct(champions.scrollChance)],
['Transcendence chance', pct(champions.transcendenceChance)],
[
'Red skulls per rank',
Array.isArray(t) && t.length > 0 ? t.join(' · ') : null,
],
]}
/>
</Panel>
)
}
function Felucca({ loot }) {
return (
<Panel title="Felucca bonuses">
<Rows
items={[
['Luck bonus', num(loot.feluccaLuckBonus)],
['Loot budget bonus', num(loot.feluccaBudgetBonus)],
['Max item properties', num(loot.feluccaMaxProps)],
]}
/>
</Panel>
)
}
function Vendors({ vendors }) {
return (
<Panel title="Vendors">
<Rows
items={[
['Restock delay', vendors.restockDelayMinutes
? `${vendors.restockDelayMinutes} min`
: null],
['Max items sold at once', num(vendors.maxSell)],
['Economy stock amount', num(vendors.economyStockAmount)],
]}
/>
</Panel>
)
}
function Pvp({ vvv }) {
return (
<Panel title="Vice vs Virtue">
<Rows
items={[
['Starting silver', num(vvv.startSilver)],
['Enhanced rules', vvv.enhancedRules === undefined
? null
: vvv.enhancedRules ? 'On' : 'Off'],
]}
/>
</Panel>
)
}
function Schedule({ schedule }) {
const items = []
if (schedule.autoSaveEnabled && schedule.autoSaveFrequencyMinutes) {
items.push(['World save', `every ${schedule.autoSaveFrequencyMinutes} min`])
} else if (schedule.autoSaveEnabled === false) {
items.push(['World save', 'Disabled'])
}
if (schedule.autoRestartEnabled) {
const h = String(schedule.autoRestartHour ?? 0).padStart(2, '0')
const m = String(schedule.autoRestartMinute ?? 0).padStart(2, '0')
items.push(['Automatic restart', `${h}:${m} server time`])
if (schedule.autoRestartFrequencyHours) {
items.push(['Restart interval', `every ${schedule.autoRestartFrequencyHours}h`])
}
}
if (items.length === 0) return null
return (
<Panel title="Save & restart schedule">
<Rows items={items} />
</Panel>
)
}
export default function Rules() {
const { loading, error, data } = useAsync(() => api.shard.ruleset())
const { events, connected } = useShardFeed({ filter: RULESET_KINDS, max: 4 })
// The newest world.ruleset on the feed wins outright over the fetched copy —
// the frame is a complete ruleset, not a delta.
const ruleset = useMemo(() => events[0] || data || null, [data, events])
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader
eyebrow="Live"
title="Shard ruleset"
lead="Published by the server itself, straight from its configuration — so it cannot drift from how the shard actually plays."
/>
<span
className="sans"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem',
color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6,
}}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the shard ruleset right now." />}
{!loading && !error && !ruleset && (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>
The shard has not published its ruleset yet.
</p>
</section>
)}
{!loading && !error && ruleset && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Panel title="Shard">
<Rows
items={[
['Name', ruleset.shard || null],
['Expansion', ruleset.expansion || null],
['Connect', ruleset.connect || null],
]}
/>
</Panel>
{ruleset.systems && <Systems systems={ruleset.systems} />}
{ruleset.caps && <Caps caps={ruleset.caps} />}
<AccountsAndHousing
accounts={ruleset.accounts}
housing={ruleset.housing}
vetRewards={ruleset.vetRewards}
/>
{ruleset.champions && <Champions champions={ruleset.champions} />}
{ruleset.loot && <Felucca loot={ruleset.loot} />}
{ruleset.vendors && <Vendors vendors={ruleset.vendors} />}
{ruleset.vvv?.enabled && <Pvp vvv={ruleset.vvv} />}
{ruleset.schedule && <Schedule schedule={ruleset.schedule} />}
</div>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,254 @@
import { Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { describe } from '../../lib/shardEvents.js'
import { ago } from '../../lib/format.js'
import { api } from '../../api/client.js'
import PlayersOnline from '../../components/PlayersOnline.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
// Flavor line under the online/offline banner: online, configured-but-down, or
// not configured yet.
function statusMessage(online, enabled) {
if (online) return 'The gate to Britannia stands open.'
if (enabled) return 'The link to the game world is down — checking back automatically.'
return 'Live shard data is not configured yet.'
}
// ── Gold-supply sparkline ───────────────────────────────────────────────────
function Sparkline({ series }) {
if (!series || series.length < 2) return null
const w = 320
const h = 56
const golds = series.map((s) => Number(s.gold) || 0)
const min = Math.min(...golds)
const max = Math.max(...golds)
const span = max - min || 1
const pts = series
.map((s, i) => {
const x = (i / (series.length - 1)) * w
const y = h - ((Number(s.gold) || 0) - min) / span * h
return `${x.toFixed(1)},${y.toFixed(1)}`
})
.join(' ')
return (
<svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} preserveAspectRatio="none" aria-hidden="true">
<polyline points={pts} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
</svg>
)
}
// ── Stat tile (matches Status.jsx) ──────────────────────────────────────────
function Stat({ value, label }) {
return (
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 6 }}>
{label}
</div>
</div>
)
}
export default function Shard() {
const { loading, error, data } = useAsync(() =>
Promise.all([
api.shard.status(),
api.shard.idoc(),
api.shard.economy(60),
api.shard.online(),
]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
)
const { events, connected } = useShardFeed({ max: 30 })
const { user } = useAuth()
// Staff in-game location is privileged: only admins/moderators see it. Players
// and the public see that staff are online but not where. The server enforces
// this too (it omits the location fields entirely for non-privileged callers).
const canSeeLocation = user?.role === 'admin' || user?.role === 'moderator'
const status = data?.status
const online = status?.pluginConnected
const gold = status?.economy?.gold
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader eyebrow="Live" title="Shard" />
{loading && <Loading />}
{error && <ErrorState message="Could not load shard data right now." />}
{!loading && !error && data && (
<>
<ConnectionBanner online={online} status={status} />
{/* Stat tiles */}
<section className="grid-2" style={{ gap: 14, marginBottom: 24 }}>
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
</section>
{/* Live players-online breakdown (total + region buckets) */}
<div style={{ marginBottom: 24 }}>
<PlayersOnline />
</div>
<StaffOnline list={data.online} canSeeLocation={canSeeLocation} />
{/* Economy sparkline */}
{data.economy && data.economy.length > 1 && (
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 10 }}>
Gold supply over time
</div>
<Sparkline series={data.economy} />
</section>
)}
<div style={{ marginBottom: 24 }}>
{/* Latest IDOC */}
<FeedList
title="Houses in danger (IDOC)"
empty="No houses are collapsing right now."
items={data.idoc.map((h) => {
const region = h.region ? ` — ${h.region}` : ''
return {
id: h.serial,
text: `${h.name || 'A house'}${region}`,
when: h.updatedAt,
}
})}
/>
</div>
{/* Live ticker */}
<section className="panel" style={{ padding: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
Live feed
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<Link to="/site/shard/activity" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.78rem' }}>
View all activity →
</Link>
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
</div>
{events.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
Waiting for something to happen in the world…
</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{events.map((ev) => (
<li key={ev._id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(ev)}</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(ev.t)}</span>
</li>
))}
</ul>
)}
</section>
</>
)}
</div>
</PublicLayout>
)
}
// Online/offline banner with the flavor line under it.
function ConnectionBanner({ online, status }) {
return (
<section
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '24px 26px',
border: `1px solid ${online ? 'rgba(95,185,138,0.45)' : '#5a4a2a'}`,
borderRadius: 10,
background: online
? 'linear-gradient(180deg,rgba(22,46,34,0.5),rgba(16,26,20,0.4))'
: 'linear-gradient(180deg,rgba(58,46,22,0.5),rgba(30,26,16,0.4))',
marginBottom: 24,
}}
>
<span
style={{
flex: 'none',
width: 12,
height: 12,
borderRadius: '50%',
background: online ? 'var(--mode-live)' : 'var(--mode-maint)',
boxShadow: `0 0 12px ${online ? 'rgba(95,185,138,0.7)' : 'rgba(230,194,106,0.7)'}`,
}}
/>
<div>
<strong className="display" style={{ display: 'block', fontSize: '1.2rem', color: online ? '#bfe6cf' : '#f0e3c4' }}>
{online ? 'The shard is online' : 'The shard is offline'}
</strong>
<span className="sans" style={{ color: online ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
{statusMessage(online, status?.enabled)}
</span>
</div>
</section>
)
}
// Linked staff accounts currently online; in-game location is admin/mod-only.
function StaffOnline({ list, canSeeLocation }) {
return (
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
Staff online
</div>
{(!list || list.length === 0) ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No staff are online right now.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{list.map((p) => (
<div key={p.serial} className="sans" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
{p.name || p.serial}
</span>
{canSeeLocation && (
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>
{p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
</span>
)}
</div>
))}
</div>
)}
</section>
)
}
function FeedList({ title, items, empty }) {
return (
<section className="panel" style={{ padding: 20 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
{title}
</div>
{items.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>{empty}</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{items.map((it) => (
<li key={it.id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.text}</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(it.when)}</span>
</li>
))}
</ul>
)}
</section>
)
}

View File

@@ -0,0 +1,81 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { describe, categoryOf, kindLabel, CATEGORIES } from '../../lib/shardEvents.js'
import { ago } from '../../lib/format.js'
import { api } from '../../api/client.js'
// Public activity feed: the full shard event log, filterable by category, with a
// live tail that prepends new events as they happen.
export default function ShardActivity() {
const { loading, error, data } = useAsync(() => api.shard.feed({ limit: 150 }))
const { events: live } = useShardFeed({ max: 60 })
const [cat, setCat] = useState('all')
// Merge the live tail with the loaded history, de-duped by kind+t, newest first.
const merged = useMemo(() => {
const seen = new Set()
const out = []
for (const e of [...live, ...(data || [])]) {
const key = `${e.kind}-${e.t}`
if (seen.has(key)) continue
seen.add(key)
out.push(e)
}
return out.sort((a, b) => (b.t || 0) - (a.t || 0))
}, [live, data])
const filtered = cat === 'all' ? merged : merged.filter((e) => categoryOf(e.kind) === cat)
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader eyebrow="Live" title="Shard Activity" />
<p style={{ marginTop: -8, marginBottom: 18 }}>
<Link to="/site/shard" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>← Back to shard</Link>
</p>
{/* Category tabs */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 18 }}>
{CATEGORIES.map((c) => (
<button
key={c.id}
onClick={() => setCat(c.id)}
className="pill"
style={cat === c.id ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : undefined}
>
{c.label}
</button>
))}
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the activity feed right now." />}
{!loading && !error && (
filtered.length === 0 ? (
<div className="panel" style={{ padding: 22 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.9rem' }}>Nothing here yet — events will appear as they happen in the world.</p>
</div>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{filtered.map((e) => (
<li key={e._id || `${e.kind}-${e.t}`} className="panel" style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
<span className="sans" style={{ flex: 'none', fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>
{kindLabel(e.kind)}
</span>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', fontSize: '0.92rem' }}>{describe(e)}</span>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.76rem' }}>{ago(e.t)}</span>
</li>
))}
</ul>
)
)}
</div>
</PublicLayout>
)
}

View File

@@ -1,172 +0,0 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { navItemVisibleTo, allowedPathsFor, isAllowedPath, firstDestinationFor } from '../src/lib/adminNav.js'
// Moderator confinement, derived from each row's `roles` (Phase 2 PR 8 —
// MODULE_SYSTEM.md §1.4). This replaced two hardcoded path lists that had
// drifted apart from each other, so the tests worth having are the ones that
// pin what a moderator may now see and reach, and the shape of the match.
// The real sidebar, trimmed to the rows that decide something here.
const NAV = [
{ items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'editor', 'moderator'] }] },
{
title: 'Moderation',
items: [
{ to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', roles: ['admin', 'moderator'] },
{ to: '/admin/shard-ops', label: 'In-Game Ops', roles: ['admin', 'moderator'] },
{ to: '/admin/houses', label: 'Houses', roles: ['admin', 'moderator'] },
],
},
{
title: 'System',
items: [
{ to: '/admin/users', label: 'Users', roles: ['admin'] },
{ to: '/admin/settings', label: 'Settings', roles: ['admin'] },
],
},
{
items: [
{ to: '/admin/characters', label: 'My Characters' },
{ to: '/admin/account', label: 'Account' },
],
},
]
const visibleTo = (role) =>
NAV.flatMap((g) => g.items)
.filter((i) => navItemVisibleTo(i, role))
.map((i) => i.to)
test('a row with no roles is visible to every staff role', () => {
// Self-service: staff are a superset of players, so a moderator reaching their
// own characters is not a privilege, it is the thing every account has.
for (const role of ['admin', 'editor', 'moderator']) {
assert.equal(navItemVisibleTo({ to: '/admin/account' }, role), true)
}
})
test('a role not named on the row cannot see it', () => {
assert.equal(navItemVisibleTo({ to: '/admin/users', roles: ['admin'] }, 'moderator'), false)
assert.equal(navItemVisibleTo({ to: '/admin/users', roles: ['admin'] }, 'admin'), true)
// An unknown or absent role sees only the ungated rows.
assert.equal(navItemVisibleTo({ to: '/admin/users', roles: ['admin'] }, undefined), false)
assert.equal(navItemVisibleTo({ to: '/admin/account' }, undefined), true)
})
test('what a moderator sees is exactly the moderation section, plus self-service', () => {
// The two additions the derivation makes over the old MOD_PATHS list are
// Dashboard — whose roles have always named moderator, so the two lists
// disagreed — and My Characters. Both are already permitted server-side.
assert.deepEqual(visibleTo('moderator'), [
'/admin',
'/admin/moderation',
'/admin/moderation/appeals',
'/admin/shard-ops',
'/admin/houses',
'/admin/characters',
'/admin/account',
])
})
test('an admin still sees everything and an editor still sees nothing extra', () => {
assert.equal(visibleTo('admin').length, NAV.flatMap((g) => g.items).length)
assert.deepEqual(visibleTo('editor'), ['/admin', '/admin/characters', '/admin/account'])
})
test('a row with `end` matches exactly — the dashboard is not a prefix', () => {
// The bug this shape exists to prevent: treating `/admin` as a prefix would
// make every path in the admin area allowed for anyone who can see Dashboard.
const allowed = allowedPathsFor(NAV, 'moderator')
assert.equal(isAllowedPath('/admin', allowed), true)
assert.equal(isAllowedPath('/admin/users', allowed), false)
assert.equal(isAllowedPath('/admin/users/12', allowed), false)
})
test('every other row covers its own sub-routes', () => {
const allowed = allowedPathsFor(NAV, 'moderator')
assert.equal(isAllowedPath('/admin/moderation/appeals/12', allowed), true)
assert.equal(isAllowedPath('/admin/characters/0x4001', allowed), true)
})
test('a sibling path that merely shares a prefix is NOT covered', () => {
const allowed = allowedPathsFor(NAV, 'moderator')
// `/admin/houses-secret` starts with `/admin/houses` as a string; the match is
// on path segments, so it does not start with `/admin/houses/`.
assert.equal(isAllowedPath('/admin/houses-secret', allowed), false)
assert.equal(isAllowedPath('/admin/houses/42', allowed), true)
})
test('Houses is reachable, which is the defect the derivation fixed', () => {
// The redirect used to allow only /admin/moderation*, /admin/shard-ops* and
// /admin/account, while the sidebar showed Houses — so a moderator clicking a
// row in their own nav was bounced back to Moderation.
const allowed = allowedPathsFor(NAV, 'moderator')
assert.equal(isAllowedPath('/admin/houses', allowed), true)
})
test('a module row a moderator may see is reachable without core listing it', () => {
// The reason this is derived at all: core cannot hardcode a path it has never
// heard of, and a module row arrives with `roles` like any other.
const withModule = [
...NAV,
{ title: 'Shard', items: [{ to: '/admin/uo/shard-ops', label: 'Ops', roles: ['admin', 'moderator'], moduleId: 'uo' }] },
]
const allowed = allowedPathsFor(withModule, 'moderator')
assert.equal(isAllowedPath('/admin/uo/shard-ops', allowed), true)
assert.equal(isAllowedPath('/admin/uo/shard-ops/queue', allowed), true)
})
test('a nav that is not there does not throw', () => {
assert.deepEqual(allowedPathsFor(null, 'moderator'), [])
assert.equal(isAllowedPath('/admin', undefined), false)
})
// ── firstDestinationFor: where an area's index goes (Phase 3 slice 3) ────────
//
// `/player` used to be `PlayerCharacters`, a UO page. When the client half was
// extracted the portal had no index at all, and rather than pick a fixed page or
// invent a core landing screen, the index resolves to the first row this viewer
// can reach. The interesting properties are that it follows the ROLE and that it
// reads the base nav, not the merged one.
const PORTAL_NAV = [
{ to: '/account/appeals', label: 'Appeals' },
{ to: '/account', label: 'Account', end: true },
]
test('the index is the first row a viewer can actually reach', () => {
assert.equal(firstDestinationFor(PORTAL_NAV, 'player', '/account'), '/account/appeals')
})
test('a module row registered at the front becomes the index', () => {
// The behaviour that makes this a non-regression: with module-uo installed,
// Characters is the first row again and a player still lands on it.
const withModule = [{ to: '/player/uo/characters', label: 'Characters', moduleId: 'uo' }, ...PORTAL_NAV]
assert.equal(firstDestinationFor(withModule, 'player', '/account'), '/player/uo/characters')
})
test('a row this role cannot see is skipped, not landed on', () => {
const gated = [{ to: '/player/uo/staff', label: 'Staff', roles: ['admin'] }, ...PORTAL_NAV]
assert.equal(firstDestinationFor(gated, 'player', '/account'), '/account/appeals')
assert.equal(firstDestinationFor(gated, 'admin', '/account'), '/player/uo/staff')
})
test('an empty or all-gated nav falls back rather than resolving to nothing', () => {
assert.equal(firstDestinationFor([], 'player', '/account'), '/account')
assert.equal(firstDestinationFor(null, 'player', '/account'), '/account')
const allGated = [{ to: '/x', label: 'X', roles: ['admin'] }]
assert.equal(firstDestinationFor(allGated, 'player', '/account'), '/account')
})
test('it reads the grouped admin nav too, flattening in order', () => {
// Same function for both areas, which is the point: the admin index is a
// hardcoded Dashboard today, and if the two logged-in areas ever merge this is
// what answers for the result.
assert.equal(firstDestinationFor(NAV, 'moderator', '/admin/account'), '/admin')
// An editor cannot see Dashboard's neighbours in Moderation, so they land on
// the first row they can see wherever it is.
assert.equal(firstDestinationFor(NAV, 'editor', '/admin/account'), '/admin')
})

View File

@@ -1,85 +0,0 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { buildFeatureGate, OPEN_GATE } from '../src/modules/featureGate.js'
// The feature seam's decision logic (MODULE_SYSTEM.md §1.5, MODULE_API.md §3.3).
// Every branch here fails OPEN, and that is the property under test as much as
// the happy path: this is presentation, the server is the gate, and a UI mistake
// that hides a page from someone entitled to it is worse in every case than one
// that shows a link which then 403s.
const flags = (...names) => new Set(names)
test('a row with no feature is always visible', () => {
const gate = buildFeatureGate(new Map([['uo', flags()]]))
assert.equal(gate({ to: '/site/news' }), true)
})
test('a core row resolves against the owner id `core`', () => {
// Core's ten shard-gated rows carry no moduleId, and core registers its
// provider under `core` (main.jsx) precisely so they resolve without one.
const gate = buildFeatureGate(new Map([['core', flags('atlas')]]))
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
assert.equal(gate({ to: '/site/market', feature: 'market' }), false)
})
test('a module row resolves against ITS module, not another one', () => {
const gate = buildFeatureGate(
new Map([
['uo', flags('atlas')],
['rust', flags('market')],
]),
)
assert.equal(gate({ to: '/uo/atlas', feature: 'atlas', moduleId: 'uo' }), true)
// `market` is a flag the OTHER module grants. Resolution is by registration,
// so there is no string a module can write to borrow it.
assert.equal(gate({ to: '/uo/market', feature: 'market', moduleId: 'uo' }), false)
assert.equal(gate({ to: '/rust/market', feature: 'market', moduleId: 'rust' }), true)
})
test('no provider for the owner shows the row', () => {
// The no-module-installed case, and the reason the filter is a correct no-op
// on a bare core rather than a nav that renders nothing.
const gate = buildFeatureGate(new Map())
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
assert.equal(gate({ to: '/uo/atlas', feature: 'atlas', moduleId: 'uo' }), true)
})
test('a provider still loading shows the row', () => {
// useShardFlags returns null until its fetch lands. Blanking the nav on every
// page load and filling it in a moment later is the behaviour this avoids.
const gate = buildFeatureGate(new Map([['core', null]]))
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
})
test('a provider that returned something unusable shows the row', () => {
for (const bad of [undefined, 42, 'atlas', {}, []]) {
const gate = buildFeatureGate(new Map([['core', bad]]))
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true, `failed closed on ${JSON.stringify(bad)}`)
}
})
test('an array-backed provider is not silently treated as a Set', () => {
// `[].has` does not exist, so this is the unusable case above rather than a
// membership test that quietly always fails. Asserted so that a future
// "helpful" normalisation knows it changed a documented behaviour.
const gate = buildFeatureGate(new Map([['core', ['atlas']]]))
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
})
test('a missing map, or a junk row, shows rather than throws', () => {
assert.equal(buildFeatureGate(null)({ feature: 'atlas' }), true)
assert.equal(buildFeatureGate(new Map())(null), true)
assert.equal(buildFeatureGate(new Map())(undefined), true)
})
test('any Set-like satisfies a provider — core does not require a Set', () => {
const gate = buildFeatureGate(new Map([['uo', { has: (name) => name === 'ruleset' }]]))
assert.equal(gate({ feature: 'ruleset', moduleId: 'uo' }), true)
assert.equal(gate({ feature: 'champs', moduleId: 'uo' }), false)
})
test('the open gate is what a component outside the provider gets', () => {
assert.equal(OPEN_GATE({ feature: 'anything' }), true)
})

View File

@@ -1,217 +0,0 @@
import { test, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { withModuleNav } from '../src/modules/nav.js'
import { registerNav, _reset } from '../src/modules/registry.js'
import { applyNavOverrides, buildPublicNav } from '../src/lib/navOverrides.js'
// The interleave of module nav rows into core's nav (MODULE_API.md §3.3, Phase 2
// PR 8). Tested against the real merge next door rather than in isolation,
// because the property that matters is a relationship between the two: a module
// row has to be indistinguishable from a core row to everything downstream, and
// the way to prove that is to run the downstream thing on it.
const PUBLIC = [
{ label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' },
{ label: 'About', to: '/site/about' },
]
const ADMIN = [
{ items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'moderator'] }] },
{ title: 'Moderation', items: [{ to: '/admin/moderation', label: 'Moderation' }] },
{ title: 'System', items: [{ to: '/admin/users', label: 'Users' }, { to: '/admin/settings', label: 'Settings' }] },
{ items: [{ to: '/admin/account', label: 'Account' }] },
]
beforeEach(() => _reset())
test('with no module installed the base array is returned unchanged', () => {
// Identity, not a copy: this is what makes the useMemo in each layout honest,
// and what guarantees an instance with no modules renders what it renders now.
assert.equal(withModuleNav(PUBLIC, 'public'), PUBLIC)
assert.equal(withModuleNav(ADMIN, 'admin'), ADMIN)
})
test('a flat nav places a module row by the order it asked for', () => {
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', order: 1 }] })
assert.deepEqual(
withModuleNav(PUBLIC, 'public').map((i) => i.label),
['Home', 'Atlas', 'News', 'About'],
)
})
test('a flat row with no order appends rather than jumping to the front', () => {
// The 0-default trap: `order ?? 0` would put an unordered row first, which is
// the one place a module could take over the nav without asking for anything.
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }] })
assert.deepEqual(
withModuleNav(PUBLIC, 'public').map((i) => i.label),
['Home', 'News', 'About', 'Atlas'],
)
})
test('an explicit order beats a core row that merely sits at that index', () => {
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', order: 2 }] })
const labels = withModuleNav(PUBLIC, 'public').map((i) => i.label)
assert.deepEqual(labels, ['Home', 'News', 'Atlas', 'About'])
})
test('an admin row lands INSIDE the core group it names', () => {
registerNav('uo', {
area: 'admin',
items: [
{ label: 'In-Game Ops', to: '/admin/uo/shard-ops', group: 'Moderation', order: 30 },
{ label: 'Shard', to: '/admin/uo/link', group: 'System', order: 0 },
],
})
const nav = withModuleNav(ADMIN, 'admin')
assert.deepEqual(nav.map((g) => g.title), [undefined, 'Moderation', 'System', undefined])
assert.deepEqual(nav[1].items.map((i) => i.label), ['Moderation', 'In-Game Ops'])
// order 0 puts it above both core rows, which is the whole point of the field.
assert.deepEqual(nav[2].items.map((i) => i.label), ['Shard', 'Users', 'Settings'])
})
test('an unknown group appends a new group instead of dropping the row', () => {
// A typo must cost a position, never a link.
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Moderaton' }] })
const nav = withModuleNav(ADMIN, 'admin')
assert.equal(nav.length, ADMIN.length + 1)
assert.deepEqual(nav.at(-1), { title: 'Moderaton', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Moderaton', moduleId: 'uo' }] })
})
test('an admin row with no group gets a trailing untitled group of its own', () => {
// NOT folded into one of core's untitled groups: those are Dashboard at the
// top and Account at the bottom, and a module page belongs beside neither.
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas' }] })
const nav = withModuleNav(ADMIN, 'admin')
assert.equal(nav.length, ADMIN.length + 1)
assert.equal(nav.at(-1).title, undefined)
assert.deepEqual(nav.at(-1).items.map((i) => i.label), ['Atlas'])
assert.deepEqual(nav[0].items.map((i) => i.label), ['Dashboard'])
assert.deepEqual(nav[3].items.map((i) => i.label), ['Account'])
})
test('a row whose `to` collides with a core row is dropped, not rendered twice', () => {
// `to` is the key the override layer stores under and React renders by. Two
// rows sharing one would give an admin a single editor row that moves both.
const warnings = []
const warn = console.warn
console.warn = (msg) => warnings.push(msg)
try {
registerNav('uo', {
area: 'public',
items: [{ label: 'Not News', to: '/site/news' }, { label: 'Atlas', to: '/uo/atlas' }],
})
const nav = withModuleNav(PUBLIC, 'public')
assert.deepEqual(nav.map((i) => i.label), ['Home', 'News', 'About', 'Atlas'])
assert.equal(warnings.length, 1)
assert.match(warnings[0], /\/site\/news.*collides/)
} finally {
console.warn = warn
}
})
test('two modules cannot claim the same path either', () => {
const warn = console.warn
console.warn = () => {}
try {
registerNav('aa', { area: 'public', items: [{ label: 'First', to: '/shared' }] })
registerNav('zz', { area: 'public', items: [{ label: 'Second', to: '/shared' }] })
const labels = withModuleNav(PUBLIC, 'public').map((i) => i.label)
assert.deepEqual(labels, ['Home', 'News', 'About', 'First'])
} finally {
console.warn = warn
}
})
test('a module row carries its moduleId through, which is how the gate finds it', () => {
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas' }] })
const row = withModuleNav(PUBLIC, 'public').at(-1)
assert.equal(row.moduleId, 'uo')
assert.equal(row.feature, 'atlas')
})
test('a module row carries its icon through, and an override cannot touch it', () => {
// 1.3.0. Without an `icon` the six extracted UO rows would have been the only
// text-only entries in a sidebar where every other row has a glyph. Core
// renders whatever component the row carries and supplies no fallback — an
// invented one would be core making a presentation choice for content it knows
// nothing about.
const Glyph = () => null
registerNav('uo', {
area: 'admin',
items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System', icon: Glyph }],
})
const merged = withModuleNav(ADMIN, 'admin')
const row = merged.find((g) => g.title === 'System').items.find((i) => i.to === '/admin/uo/link')
assert.equal(row.icon, Glyph)
// `icon` was already on navOverrides' list of fields an override may not
// touch, from long before a module could supply one. It still is.
const overridden = applyNavOverrides(merged, { '/admin/uo/link': { label: 'Renamed', icon: 'nope' } })
const after = overridden.find((g) => g.title === 'System').items.find((i) => i.to === '/admin/uo/link')
assert.equal(after.label, 'Renamed')
assert.equal(after.icon, Glyph)
})
test('a row with no icon simply has none, the same as a core row with none', () => {
registerNav('uo', { area: 'player', items: [{ label: 'Characters', to: '/player/uo/characters', order: 0 }] })
const row = withModuleNav([{ to: '/account', label: 'Account' }], 'player')[0]
assert.equal(row.to, '/player/uo/characters')
assert.equal(row.icon, undefined)
})
test('areas do not leak into one another', () => {
registerNav('uo', { area: 'admin', items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System' }] })
assert.equal(withModuleNav(PUBLIC, 'public'), PUBLIC)
})
// ── The relationship that is the actual requirement ───────────────────────
test('an admin override applies to a module row exactly as to a core row', () => {
// The reason the interleave happens BEFORE the merge and not after: the merge
// drops any key its base array does not declare, so appending module rows
// afterwards would make every one of them unorderable, unrelabellable and
// unhideable — a visible regression the day the UO rows leave core.
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }] })
const base = withModuleNav(PUBLIC, 'public')
const merged = applyNavOverrides(base, {
'/uo/atlas': { label: 'Bestiary', order: 0 },
'/site/news': { order: 3 },
})
assert.deepEqual(merged.map((i) => i.label), ['Bestiary', 'Home', 'About', 'News'])
})
test('an override can hide a module row, and the public tree can section it', () => {
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }, { label: 'Market', to: '/uo/market' }] })
const base = withModuleNav(PUBLIC, 'public')
const hidden = buildPublicNav(base, { '/uo/atlas': { hidden: true } })
assert.equal(hidden.some((n) => n.to === '/uo/atlas'), false)
const sectioned = buildPublicNav(base, {
items: { '/uo/market': { section: 'sec_shard' } },
sections: [{ id: 'sec_shard', label: 'Shard', order: 0 }],
})
assert.equal(sectioned[0].kind, 'section')
assert.deepEqual(sectioned[0].items.map((i) => i.to), ['/uo/market'])
})
test('a module row can be moved between admin groups by an override', () => {
registerNav('uo', { area: 'admin', items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System' }] })
const base = withModuleNav(ADMIN, 'admin')
const merged = applyNavOverrides(base, { '/admin/uo/link': { group: 'Moderation' } })
assert.deepEqual(merged[1].items.map((i) => i.to), ['/admin/moderation', '/admin/uo/link'])
assert.deepEqual(merged[2].items.map((i) => i.to), ['/admin/users', '/admin/settings'])
})
test('a group a module created is itself a legal override destination', () => {
// Falls out of building the destination set from the base nav it is handed —
// recorded because it is the kind of thing that would otherwise be discovered
// by an admin finding a section they cannot move anything into.
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Shard' }] })
const base = withModuleNav(ADMIN, 'admin')
const merged = applyNavOverrides(base, { '/admin/users': { group: 'Shard' } })
assert.deepEqual(merged.at(-1).items.map((i) => i.to), ['/admin/uo/atlas', '/admin/users'])
})

View File

@@ -1,188 +0,0 @@
import { test, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
registry,
registerRoutes,
registerNav,
registerFeatureProvider,
routesFor,
navFor,
featureProviderFor,
featureProviders,
registeredIds,
_reset,
} from '../src/modules/registry.js'
import { MODULE_API_VERSION } from '../src/modules/version.js'
// The client-side module registry (docs/website/MODULE_API.md §3.3). Tested in
// isolation from React, like the nav-override merge next door, because the
// property worth proving has nothing to do with rendering: a module gets exactly
// the URL namespace core gave it, however it spells the paths it registers.
//
// window.__rg itself (modules/shared.js) is not tested here — it imports .jsx and
// there is no DOM in this runner. What it publishes is React, the router and
// core components: a wiring test would assert that an import statement imported
// something. Phase 1's spike proved the half that can actually fail, which is a
// real chunk resolving its externals against the global in a browser under an
// enforced CSP.
beforeEach(() => _reset())
test('a module route is namespaced under the module id', () => {
registerRoutes('uo', { public: [{ path: 'atlas', element: 'ATLAS' }] })
assert.deepEqual(
routesFor('public').map((r) => r.path),
['uo/atlas'],
)
})
test('a module cannot spell its way out of its namespace', () => {
// Whatever the module writes, the segment it lands under is core's to choose:
// leading slashes, several of them, a trailing one, or nothing at all.
registerRoutes('uo', {
public: [
{ path: '/atlas' },
{ path: '//atlas/creatures' },
{ path: 'atlas/' },
{ path: '' },
],
})
assert.deepEqual(
routesFor('public').map((r) => r.path),
['uo/atlas', 'uo/atlas/creatures', 'uo/atlas', 'uo'],
)
})
test('a path is namespaced, not sanitised — traversal stays a literal segment', () => {
// `..` is not stripped, and does not need to be: React Router matches path
// patterns literally, so `/uo/../admin` is a route nothing navigates to rather
// than a route that resolves somewhere else. Asserted so that a future
// "cleanup" that starts resolving these knows it changed a behaviour.
registerRoutes('uo', { public: [{ path: '../admin' }] })
assert.deepEqual(routesFor('public')[0].path, 'uo/../admin')
})
test('routes keep their gate and carry the owning module id', () => {
registerRoutes('uo', {
admin: [{ path: 'shard-ops', element: 'OPS', gate: { roles: ['admin', 'moderator'] } }],
})
const [route] = routesFor('admin')
assert.deepEqual(route.gate, { roles: ['admin', 'moderator'] })
assert.equal(route.moduleId, 'uo')
assert.equal(route.element, 'OPS')
})
test('the three areas are kept apart', () => {
registerRoutes('uo', {
public: [{ path: 'atlas' }],
admin: [{ path: 'link' }],
player: [{ path: 'chars' }],
})
assert.equal(routesFor('public').length, 1)
assert.equal(routesFor('admin').length, 1)
assert.equal(routesFor('player').length, 1)
// An area nobody registered is an empty list, never undefined: App.jsx maps
// over all three unconditionally.
_reset()
for (const area of ['public', 'admin', 'player']) assert.deepEqual(routesFor(area), [])
})
test('an unknown area throws rather than being dropped', () => {
// Loudly, because the alternative is a module whose pages simply never appear
// and no indication anywhere of why.
assert.throws(() => registerRoutes('uo', { publik: [{ path: 'atlas' }] }), /unknown area/)
assert.throws(() => registerNav('uo', { area: 'sidebar', items: [] }), /unknown area/)
assert.equal(registeredIds().length, 0)
})
test('nav items sort by order, and equal orders keep load order', () => {
registerNav('aa', { area: 'admin', items: [{ label: 'Second', to: '/a', order: 30 }] })
registerNav('zz', { area: 'admin', items: [{ label: 'Third', to: '/z', order: 30 }] })
registerNav('mm', { area: 'admin', items: [{ label: 'First', to: '/m', order: 10 }] })
assert.deepEqual(
navFor('admin').map((i) => i.label),
['First', 'Second', 'Third'],
)
})
test('a nav item with no order sorts after the ones that asked for a place', () => {
registerNav('uo', {
area: 'public',
items: [{ label: 'Unordered', to: '/u' }, { label: 'Early', to: '/e', order: 5 }],
})
assert.deepEqual(
navFor('public').map((i) => i.label),
['Early', 'Unordered'],
)
})
test('a feature provider is stored under its namespace, with its owner', () => {
const hook = () => ({ atlas: true })
registerFeatureProvider('uo', 'shard', hook)
assert.deepEqual(featureProviderFor('shard'), { id: 'uo', hook })
assert.equal(featureProviderFor('nothing'), undefined)
})
test('providers can be enumerated in registration order, with their owner', () => {
// Core's feature context has to CALL each of these, as a hook, in a fixed
// order — so it needs the list, and it needs the owner id to resolve a nav
// row whose `moduleId` says who it belongs to (modules/features.jsx).
const uo = () => null
const rust = () => null
registerFeatureProvider('uo', 'shard', uo)
registerFeatureProvider('rust', 'server', rust)
assert.deepEqual(featureProviders(), [
{ id: 'uo', namespace: 'shard', hook: uo },
{ id: 'rust', namespace: 'server', hook: rust },
])
})
test('enumerating providers is NOT part of the module-facing surface', () => {
// A module asks for a namespace it knows the name of; enumerating what
// everyone else registered is core's business, so `featureProviders` is a
// module export and not a member of window.__rg.registry.
assert.equal(registry.featureProviders, undefined)
assert.equal(typeof featureProviders, 'function')
})
test('every registration marks the module registered', () => {
registerRoutes('a', { public: [{ path: 'x' }] })
registerNav('b', { area: 'public', items: [] })
registerFeatureProvider('c', 'ns', () => {})
assert.deepEqual(registeredIds().sort(), ['a', 'b', 'c'])
})
test('the registry object handed to modules exposes the whole surface', () => {
// window.__rg.registry is the ONLY way a module reaches any of this, so a
// member missing from the object is a member that does not exist.
assert.deepEqual(Object.keys(registry).sort(), [
'featureProviderFor',
'navFor',
'registerExtension',
'registerFeatureProvider',
'registerNav',
'registerRoutes',
'registeredIds',
'routesFor',
])
})
test('the client and server halves declare the same MODULE_API_VERSION', () => {
// The value is duplicated because it has to be on window.__rg before the first
// module chunk evaluates, which is earlier than a fetch could answer. This is
// the test that pays for the copy: a bump that edits one file fails here
// instead of shipping a core whose two halves disagree about the contract they
// implement.
const here = path.dirname(fileURLToPath(import.meta.url))
const server = fs.readFileSync(
path.join(here, '..', '..', 'server', 'src', 'modules', 'version.js'),
'utf8',
)
const match = server.match(/MODULE_API_VERSION\s*=\s*'([^']+)'/)
assert.ok(match, 'server/src/modules/version.js no longer declares MODULE_API_VERSION as a literal')
assert.equal(MODULE_API_VERSION, match[1])
})

View File

@@ -1,94 +0,0 @@
import { test, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import {
registry,
declareSlot,
registerExtension,
extensionFor,
registeredIds,
_reset,
} from '../src/modules/registry.js'
// Client extension slots (docs/website/MODULE_API.md §3.7) — the client twin of
// the server's declareSlot/registerExtension.
//
// The registry half only. `<Slot>` itself renders, and there is no DOM in this
// runner, so what it does with what these functions return — including the error
// boundary — is proved by the §7.7 browser smoke instead. Everything below is a
// rule that can be stated without rendering anything, and every one of them can
// be got wrong in a way a browser check would not obviously catch.
beforeEach(() => _reset())
const Fake = () => null
const Other = () => null
test('an unfilled slot reads as nothing', () => {
// The guarantee core's layouts rest on: place a slot, install no module, and
// the page renders what it rendered before.
declareSlot('site.footer.status')
assert.equal(extensionFor('site.footer.status'), null)
})
test('an undeclared slot reads as nothing rather than throwing', () => {
// Reading is core's side and stays fail-safe: a typo in a layout costs that
// spot, not the page. Only WRITING is strict, which is the next test.
assert.equal(extensionFor('nope'), null)
})
test('a module fills a declared slot and core reads it back', () => {
declareSlot('admin.users.detail')
registerExtension('uo', 'admin.users.detail', Fake)
assert.equal(extensionFor('admin.users.detail'), Fake)
assert.deepEqual(registeredIds(), ['uo'])
})
test('filling an unknown slot throws, naming the slot', () => {
// This is the one place the client registry is NOT fail-open, and the reason
// is asymmetry of consequence: a dropped nav row costs a link the viewer can
// reach another way, a silently dropped extension is invisible to everyone
// including its author. Declaration structurally precedes filling (§3.1), so
// this can only ever be a typo or a version skew.
assert.throws(() => registerExtension('uo', 'site.footer.sttaus', Fake), /unknown extension slot "site\.footer\.sttaus"/)
})
test('a non-component fill throws', () => {
declareSlot('site.footer.status')
assert.throws(() => registerExtension('uo', 'site.footer.status', { render: true }), /is not a component/)
})
test('a second module cannot take a filled slot, and the first keeps it', () => {
// Matches the server's rule exactly (registries.js): first fill wins, second
// is an error. The second half of the assertion is the one that matters — a
// rejected fill must not have half-replaced the incumbent.
declareSlot('admin.users.detail')
registerExtension('uo', 'admin.users.detail', Fake)
assert.throws(() => registerExtension('other', 'admin.users.detail', Other), /already filled by "uo"/)
assert.equal(extensionFor('admin.users.detail'), Fake)
})
test('declaring a slot twice throws', () => {
// Core-side programming error: two owners for one position means whichever
// module registered first wins by file order.
declareSlot('site.footer.status')
assert.throws(() => declareSlot('site.footer.status'), /already declared/)
})
test('core fills a slot through the same seam a module uses', () => {
// The client twin of registries.registerCore(). Core is a registrant with an
// id like any other, which is what makes slice 3 a deletion: the module
// registers the same slot and core drops its line.
declareSlot('site.footer.status')
registerExtension('core', 'site.footer.status', Fake)
assert.deepEqual(registeredIds(), ['core'])
})
test('declareSlot and extensionFor are not on the module-facing registry', () => {
// Declaring is core's alone (§3.7), and reading who filled a slot is core's
// too — the same line featureProviders() draws. registerExtension IS on the
// object, because filling is the whole point.
assert.equal(registry.declareSlot, undefined)
assert.equal(registry.extensionFor, undefined)
assert.equal(typeof registry.registerExtension, 'function')
})

View File

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

View File

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

View File

@@ -35,10 +35,6 @@ services:
DB_HOST: db
UPLOAD_DIR: /app/uploads
LOG_DIR: /app/logs
# Where the loader scans for installed modules. Same path the code already
# defaults to (<repo>/modules, and the repo is /app in the image), set
# explicitly because the bind mount below is what makes it meaningful.
MODULES_DIR: /app/modules
depends_on:
db:
condition: service_healthy
@@ -51,25 +47,6 @@ services:
# the image, so this mount only matters for custom brand images. Create
# ./brand/ on the host and drop assets in; read-only in the container.
- ./brand:/app/brand:ro
# Installed modules (docs/website/MODULE_SYSTEM.md). Modules live on a
# mount, NEVER in the image: that is what lets an operator add one to a
# pull-only deployment without building anything. A bind mount rather than
# a named volume because placing a module directory by hand is a supported
# install — `tar -xf uo-1.0.0.tgz -C ./modules` then restart — and that has
# to be doable from the host, not through `docker cp`.
#
# Read-WRITE: the admin panel's install/uninstall unpacks and removes
# directories here from inside the container.
#
# `modules/` is tracked (it ships a README) so the directory exists in the
# checkout with the operator's own ownership. Do not delete it — Docker
# would recreate a missing bind-mount source as root:root and the container
# user could no longer write it. If the app runs as a uid that does not own
# ./modules, `chown 1000:1000 modules` on the host.
#
# Adding or removing a module takes a RESTART: the scan is synchronous at
# require time (MODULE_API.md §4.1), so nothing here is picked up live.
- ./modules:/app/modules
# Only the PUBLIC API port (3000) is published. The internal server<->bot
# port (INTERNAL_PORT, default 3001) is deliberately NOT listed here, so it
# stays reachable only over the private compose network — Pangolin/the public

View File

@@ -1,75 +0,0 @@
# Installed modules
This directory is bind-mounted into the container at `/app/modules` (see
`docker-compose.yml`). It is where **installed modules** live — the game-specific
routes, tables, screens and nav that are not part of core. Design of record:
[`docs/website/MODULE_SYSTEM.md`](../../docs/website/MODULE_SYSTEM.md); the
normative contract module authors build against is
[`docs/website/MODULE_API.md`](../../docs/website/MODULE_API.md).
**Core ships no module.** This directory is empty in a fresh checkout, and the
site runs cleanly that way — an empty `modules/` is the normal state for bare
core, not a misconfiguration. Everything below is ignored by git except this
README, which exists so the directory itself is tracked: `docker-compose.yml`
bind-mounts it, and Docker recreates a *missing* bind-mount source as a
root-owned directory the container user cannot write.
## Layout
One directory per module, named for its id, each holding a prebuilt bundle:
```
modules/
uo/
module.json # the manifest the loader reads
server/index.js # registers routes, streams, hooks
server/db/schema.sql # tables, replayed every boot
server/db/purge.sql # only ever run by an explicit purge
client/dist/entry.js # prebuilt ESM chunk, served at /modules/uo/
```
**Nothing here is compiled by the operator.** A module arrives already built —
that is the whole point of the design. There is no install step that runs a
bundler, and none that needs one.
## Installing a module
Two supported paths, both writing the same `installed_modules` row:
- **The admin panel** downloads the bundle from the module's release, verifies it
against its `sha256`, and unpacks it here.
- **By hand**, for a compose-managed host: unpack the bundle into a directory
named for the module id, e.g. `tar -xf uo-1.0.0.tgz -C ./modules`.
Either way, **adding or removing a module takes a restart.** The loader scans
this directory synchronously at startup (`MODULE_API.md` §4.1); nothing placed
here is picked up by a running server.
```
docker compose restart app
```
On boot each module is validated, mounted, its schema fragment replayed and its
`onBoot` hook run — reaching `started`, or `startup_failed` with the stage and
reason recorded. A module that fails to start does not stop the site: core, and
every other module, carry on without it.
## Uninstalling
Removing a directory and restarting is enough to stop a module serving. Note that
this is *not* the same as an uninstall through the admin panel, which also marks
the row `disabled` — a directory that simply vanishes leaves a row claiming to be
enabled, which the loader records as `startup_failed`.
A module's **tables and data are retained** in both cases. Dropping them is a
separate, explicit, destructive purge; it is never bundled into an uninstall.
## Ownership
The container runs as uid 1000 (`node`) and the admin panel writes here, so the
app must be able to write this directory. It is created by your checkout, with
your ownership. If they differ:
```
chown -R 1000:1000 modules
```

50
modules/uo/SPIKE.md Normal file
View File

@@ -0,0 +1,50 @@
# module-uo — the Phase 1 spike
**This branch is evidence, not implementation.** `spike/module-atlas` is cut from `edge` and is
never merged. Phase 2 rebuilds the loader properly, with the `installed_modules` table, the full
state machine and the admin panel behind it; Phase 3 does the real extraction.
What it demonstrates, and the results, are written up in
[`docs/website/MODULE_API.md`](../../../docs/website/MODULE_API.md) Part 7. In one line: the six
public spawn-atlas routes now live in a module, at byte-identical URLs, with the client half loading
as a prebuilt ESM chunk under `script-src 'self'`.
## Reproducing it
```bash
# 1. build the module's client chunk (its CI would do this and ship the result)
cd modules/uo/client && npm install && npm run build # → dist/entry.js
# 2. build core's client
cd ../../../client && npm install && npm run build
# 3. run the server against the local MariaDB
cd ../server && npm start
```
Then:
- `/uo/atlas` and `/uo/atlas/lizardman` render from the module's chunk.
- `GET /api/v1/public/atlas/*` answers exactly as before — `npm run routes:manifest -- --check`
reports the surface unchanged.
- `npm test` in `server/` (core, 729) and `node --test` in `modules/uo/server/` (module, 81).
`dist/entry.js` is committed here **only** because this branch is the evidence for a design
decision and a reviewer should be able to inspect the built artifact without a toolchain. A real
module publishes it from CI into its release bundle and never commits it.
## What in here is not design
Three things are consequences of stopping at six routes, spelled out in MODULE_API.md §7.5:
1. **Core reaches into this module twice** — `server/src/router/v1/admin/shardAtlas.controller.js`
and `server/test/atlasController.test.js`. The five admin atlas routes sit inside the `/shard`
admin prefix core still owns, so they cannot move until the whole prefix does.
2. **`server/utils/visibility.js` is a copy** of core's `utils/shardVisibility.js`, which core still
needs for the shard routes not yet extracted. Two caches over one table, briefly.
3. **There is no `swagger-fragment.json`** — it needs core's merge helper on the other side, which
is Phase 2.
Also note the table names here are `shard_*`, not `uo_*`. That is deliberate and grandfathered by an
allowlist in the loader: renaming twenty-seven live tables is a data migration this workstream does
not do. Every module written after this one carries its id as a table prefix.

409
modules/uo/client/dist/entry.js vendored Normal file
View File

@@ -0,0 +1,409 @@
const B = window.__rg.jsxRuntime, { jsx: t, jsxs: l, Fragment: A } = B, U = window.__rg.react, {
useState: h,
useEffect: I,
useMemo: j,
useCallback: W,
useRef: ne,
useContext: se,
useReducer: re,
createElement: ie,
cloneElement: le,
createContext: oe,
forwardRef: ce,
memo: de,
Fragment: me,
Children: pe,
isValidElement: ue,
StrictMode: he,
Suspense: ge,
lazy: fe
} = U, E = window.__rg.router, {
Link: z,
NavLink: ye,
Navigate: xe,
Outlet: we,
Route: be,
Routes: ve,
useParams: M,
useNavigate: Se,
useLocation: Ne,
useSearchParams: $e,
createBrowserRouter: Ce,
RouterProvider: ke
} = E, { PublicLayout: F, PageHeader: D, Loading: C, ErrorState: v, EmptyState: S, useAsync: k, useAuth: Re, useSite: ze } = window.__rg.ui, { request: f } = window.__rg.api, w = (e) => e ? `?${e}` : "", O = {
creatures: (e = {}) => {
const a = new URLSearchParams();
return e.q && a.set("q", e.q), e.facet && a.set("facet", e.facet), e.limit && a.set("limit", e.limit), e.offset && a.set("offset", e.offset), f(`/public/atlas/creatures${w(a.toString())}`);
},
creature: (e, a = {}) => {
const s = new URLSearchParams();
return a.facet && s.set("facet", a.facet), a.points && s.set("points", a.points), f(`/public/atlas/creatures/${encodeURIComponent(e)}${w(s.toString())}`);
},
regions: (e = {}) => {
const a = new URLSearchParams();
return e.facet && a.set("facet", e.facet), e.q && a.set("q", e.q), f(`/public/atlas/regions${w(a.toString())}`);
},
landmarks: (e = {}) => {
const a = new URLSearchParams();
return e.facet && a.set("facet", e.facet), e.q && a.set("q", e.q), f(`/public/atlas/landmarks${w(a.toString())}`);
},
champions: (e = {}) => {
const a = new URLSearchParams();
return e.facet && a.set("facet", e.facet), f(`/public/atlas/champions${w(a.toString())}`);
},
meta: () => f("/public/atlas/meta")
}, y = { atlas: O }, H = 50, x = (e) => Number.isFinite(e) ? e.toLocaleString() : "—", Q = [
{ key: "creatures", label: "Creatures" },
{ key: "champions", label: "Champion altars" },
{ key: "places", label: "Places" }
];
function R({ active: e, onClick: a, children: s }) {
return /* @__PURE__ */ t(
"button",
{
type: "button",
onClick: a,
className: "sans",
style: {
fontSize: "0.78rem",
padding: "5px 12px",
borderRadius: 999,
cursor: "pointer",
color: e ? "var(--bg-deep)" : "var(--muted)",
background: e ? "var(--accent)" : "transparent",
border: `1px solid ${e ? "var(--accent)" : "var(--line)"}`
},
children: s
}
);
}
function G({ creature: e }) {
const a = Object.entries(e.facets || {}).sort((s, r) => r[1] - s[1]);
return /* @__PURE__ */ l(
z,
{
to: `/uo/atlas/${encodeURIComponent(e.slug)}`,
className: "panel",
style: {
padding: "13px 15px",
display: "flex",
alignItems: "center",
gap: 14,
textDecoration: "none",
color: "inherit"
},
children: [
/* @__PURE__ */ l("div", { style: { minWidth: 0, flex: 1 }, children: [
/* @__PURE__ */ t(
"div",
{
className: "display",
style: {
fontSize: "0.98rem",
color: "var(--head)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
},
children: e.name
}
),
/* @__PURE__ */ t("div", { className: "sans dim", style: { fontSize: "0.74rem", marginTop: 3 }, children: a.length === 0 ? "—" : a.map(([s, r]) => `${s} (${r})`).join(" · ") })
] }),
/* @__PURE__ */ l("div", { className: "sans", style: { flex: "none", textAlign: "right" }, children: [
/* @__PURE__ */ t("div", { style: { color: "var(--head)", fontSize: "0.92rem" }, children: x(e.total) }),
/* @__PURE__ */ l("div", { className: "dim", style: { fontSize: "0.68rem", letterSpacing: "0.05em" }, children: [
x(e.points),
" spawners"
] })
] })
]
}
);
}
function V({ q: e, facet: a }) {
const [s, r] = h({ loading: !0, error: null, items: [], total: 0 }), [i, p] = h(!1), o = W(
async (n) => await y.atlas.creatures({ q: e, facet: a, limit: H, offset: n }),
[e, a]
);
I(() => {
let n = !0;
return r({ loading: !0, error: null, items: [], total: 0 }), o(0).then((d) => {
n && r({ loading: !1, error: null, items: d.creatures || [], total: d.total || 0 });
}).catch((d) => n && r({ loading: !1, error: d, items: [], total: 0 })), () => {
n = !1;
};
}, [o]);
const c = async () => {
p(!0);
try {
const n = await o(s.items.length);
r((d) => ({ ...d, items: [...d.items, ...n.creatures || []], total: n.total ?? d.total }));
} catch {
} finally {
p(!1);
}
};
return s.loading ? /* @__PURE__ */ t(C, {}) : s.error ? /* @__PURE__ */ t(v, { message: "Could not load the bestiary right now." }) : s.items.length === 0 ? /* @__PURE__ */ t(S, { children: "Nothing in the atlas matches that." }) : /* @__PURE__ */ l(A, { children: [
/* @__PURE__ */ l("p", { className: "sans dim", style: { fontSize: "0.78rem", margin: "0 0 12px" }, children: [
"Showing ",
x(s.items.length),
" of ",
x(s.total)
] }),
/* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: s.items.map((n) => /* @__PURE__ */ t(G, { creature: n }, n.slug)) }),
s.items.length < s.total && /* @__PURE__ */ t("div", { style: { textAlign: "center", marginTop: 16 }, children: /* @__PURE__ */ t("button", { type: "button", className: "btn", onClick: c, disabled: i, children: i ? "Loading…" : "Load more" }) })
] });
}
function X({ facet: e }) {
const { loading: a, error: s, data: r } = k(() => y.atlas.champions(e), [e]);
return a ? /* @__PURE__ */ t(C, {}) : s ? /* @__PURE__ */ t(v, { message: "Could not load the champion altars right now." }) : !r || r.length === 0 ? /* @__PURE__ */ t(S, { children: "No champion altars are configured." }) : /* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: r.map((i) => /* @__PURE__ */ l("div", { className: "panel", style: { padding: "13px 15px", display: "flex", gap: 14, alignItems: "center" }, children: [
/* @__PURE__ */ l("div", { style: { minWidth: 0, flex: 1 }, children: [
/* @__PURE__ */ t("div", { className: "display", style: { fontSize: "0.98rem", color: "var(--head)" }, children: i.label || i.name }),
/* @__PURE__ */ l("div", { className: "sans dim", style: { fontSize: "0.74rem", marginTop: 3 }, children: [
i.facet,
i.group ? ` · ${i.group}` : "",
" · ",
i.x,
", ",
i.y
] })
] }),
/* @__PURE__ */ t("span", { className: "sans", style: { flex: "none", fontSize: "0.76rem", color: "var(--muted)" }, children: i.randomType ? "Random champion" : i.type || "—" })
] }, i.slug)) });
}
function J({ q: e, facet: a }) {
const { loading: s, error: r, data: i } = k(
() => Promise.all([y.atlas.regions({ q: e, facet: a }), y.atlas.landmarks({ q: e, facet: a })]),
[e, a]
), p = j(() => {
if (!i) return [];
const [o, c] = i;
return [
...o.map((n) => ({ key: `r:${n.facet}:${n.name}`, name: n.name, facet: n.facet, detail: n.parent || n.type || "Region", kind: "Region" })),
...c.map((n) => ({ key: `l:${n.facet}:${n.group || ""}:${n.name}:${n.x}:${n.y}`, name: n.group ? `${n.group} — ${n.name}` : n.name, facet: n.facet, detail: `${n.x}, ${n.y}`, kind: "Landmark" }))
].sort((n, d) => n.name.localeCompare(d.name));
}, [i]);
return s ? /* @__PURE__ */ t(C, {}) : r ? /* @__PURE__ */ t(v, { message: "Could not load places right now." }) : p.length === 0 ? /* @__PURE__ */ t(S, { children: "No regions or landmarks match that." }) : /* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: p.map((o) => /* @__PURE__ */ l("div", { className: "panel", style: { padding: "10px 14px", display: "flex", gap: 12, alignItems: "baseline" }, children: [
/* @__PURE__ */ t("span", { className: "sans", style: { flex: 1, minWidth: 0, color: "var(--head)", fontSize: "0.88rem" }, children: o.name }),
/* @__PURE__ */ l("span", { className: "sans dim", style: { fontSize: "0.72rem" }, children: [
o.facet,
" · ",
o.detail
] }),
/* @__PURE__ */ t("span", { className: "sans dim", style: { fontSize: "0.66rem", letterSpacing: "0.06em", flex: "none" }, children: o.kind })
] }, o.key)) });
}
function K() {
var L, _, q;
const [e, a] = h("creatures"), [s, r] = h(""), [i, p] = h(""), [o, c] = h(""), n = k(() => y.atlas.meta());
I(() => {
const m = setTimeout(() => p(s.trim()), 250);
return () => clearTimeout(m);
}, [s]);
const d = ((L = n.data) == null ? void 0 : L.facets) || [], u = ((_ = n.data) == null ? void 0 : _.counts) || null, N = (q = n.data) != null && q.importedAt ? new Date(n.data.importedAt) : null;
return /* @__PURE__ */ t(F, { section: "website", children: /* @__PURE__ */ l("div", { className: "shell-narrow page-body", children: [
/* @__PURE__ */ t(
D,
{
eyebrow: "Bestiary",
title: "Spawn atlas",
lead: "Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
}
),
u && /* @__PURE__ */ l("p", { className: "sans dim", style: { fontSize: "0.76rem", margin: "-12px 0 18px" }, children: [
x(u.creatures),
" creatures across ",
x(u.points),
" spawners",
Number.isFinite(u.unresolvedPoints) && u.points ? ` · ${Math.round((u.points - u.unresolvedPoints) / u.points * 100)}% placed to a named region or landmark` : "",
N ? ` · parsed ${N.toLocaleDateString()}` : ""
] }),
/* @__PURE__ */ t("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }, children: Q.map((m) => /* @__PURE__ */ t(R, { active: e === m.key, onClick: () => a(m.key), children: m.label }, m.key)) }),
e !== "champions" && /* @__PURE__ */ t(
"input",
{
className: "input",
type: "search",
value: s,
onChange: (m) => r(m.target.value),
placeholder: e === "creatures" ? "Search creatures…" : "Search regions and landmarks…",
style: { width: "100%", marginBottom: 12 }
}
),
d.length > 0 && /* @__PURE__ */ l("div", { style: { display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 18 }, children: [
/* @__PURE__ */ t(R, { active: o === "", onClick: () => c(""), children: "All facets" }),
d.map((m) => /* @__PURE__ */ t(R, { active: o === m, onClick: () => c(m), children: m }, m))
] }),
n.error && /* @__PURE__ */ t(v, { message: "Could not load the atlas right now." }),
!n.error && !n.loading && !N && /* @__PURE__ */ t(S, { children: "The spawn atlas has not been imported yet." }),
!n.error && N && /* @__PURE__ */ l(A, { children: [
e === "creatures" && /* @__PURE__ */ t(V, { q: i, facet: o }),
e === "champions" && /* @__PURE__ */ t(X, { facet: o }),
e === "places" && /* @__PURE__ */ t(J, { q: i, facet: o })
] })
] }) });
}
const g = (e) => Number.isFinite(e) ? e.toLocaleString() : "—";
function Y(e, a) {
const s = (r) => r >= 60 ? `${Math.round(r / 60)}m` : `${r}s`;
return !Number.isFinite(e) || !Number.isFinite(a) ? null : e === a ? s(e) : `${s(e)}–${s(a)}`;
}
function P({ title: e, right: a, children: s }) {
return /* @__PURE__ */ l("section", { className: "panel", style: { padding: 18 }, children: [
/* @__PURE__ */ l("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 12 }, children: [
/* @__PURE__ */ t("h2", { className: "display", style: { margin: "0 0 12px", fontSize: "1.02rem", color: "var(--head)" }, children: e }),
a
] }),
s
] });
}
function Z({ places: e }) {
return e.length === 0 ? /* @__PURE__ */ t("p", { className: "sans dim", style: { margin: 0 }, children: "No placed spawners." }) : /* @__PURE__ */ t("div", { children: e.map((a) => /* @__PURE__ */ l(
"div",
{
className: "sans",
style: {
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 12,
padding: "6px 0",
borderBottom: "1px solid var(--line)",
fontSize: "0.86rem"
},
children: [
/* @__PURE__ */ t("span", { style: { minWidth: 0, color: "var(--head)" }, children: a.label }),
/* @__PURE__ */ l("span", { className: "dim", style: { flex: "none" }, children: [
a.facet,
" · ",
g(a.spawners),
" spawner",
a.spawners === 1 ? "" : "s",
" · up to",
" ",
g(a.maxAlive),
" at once"
] })
]
},
`${a.facet}:${a.label}`
)) });
}
function ee({ spawners: e, truncated: a }) {
const [s, r] = h(!1);
return e.length === 0 ? null : /* @__PURE__ */ t(
P,
{
title: "Individual spawners",
right: /* @__PURE__ */ t(
"button",
{
type: "button",
className: "sans",
onClick: () => r((i) => !i),
style: { background: "none", border: "none", color: "var(--accent)", cursor: "pointer", fontSize: "0.78rem" },
children: s ? "Hide" : `Show ${g(e.length)}`
}
),
children: s && /* @__PURE__ */ l("div", { style: { overflowX: "auto" }, children: [
/* @__PURE__ */ l("table", { className: "sans", style: { width: "100%", borderCollapse: "collapse", fontSize: "0.8rem" }, children: [
/* @__PURE__ */ t("thead", { children: /* @__PURE__ */ l("tr", { style: { textAlign: "left", color: "var(--muted)" }, children: [
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Place" }),
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Facet" }),
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Coords" }),
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Max" }),
/* @__PURE__ */ t("th", { style: { padding: "4px 0 8px 0" }, children: "Respawn" })
] }) }),
/* @__PURE__ */ t("tbody", { children: e.map((i) => /* @__PURE__ */ l("tr", { style: { borderTop: "1px solid var(--line)" }, children: [
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0", color: "var(--head)" }, children: i.label }),
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: i.facet }),
/* @__PURE__ */ l("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: [
i.x,
", ",
i.y
] }),
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: g(i.maxCount) }),
/* @__PURE__ */ t("td", { style: { padding: "6px 0" }, className: "dim", children: Y(i.minDelay, i.maxDelay) || "—" })
] }, i.id)) })
] }),
a && /* @__PURE__ */ t("p", { className: "sans dim", style: { fontSize: "0.74rem", margin: "10px 0 0" }, children: "Only the largest spawners are listed." })
] })
}
);
}
function te() {
var o;
const { slug: e } = M(), { loading: a, error: s, data: r } = k(() => y.atlas.creature(e), [e]), i = (s == null ? void 0 : s.status) === 404 || (s == null ? void 0 : s.message) === "Not Found", p = j(
() => Object.entries((r == null ? void 0 : r.facets) || {}).sort((c, n) => n[1] - c[1]),
[r]
);
return /* @__PURE__ */ t(F, { section: "website", children: /* @__PURE__ */ l("div", { className: "shell-narrow page-body", children: [
/* @__PURE__ */ t("p", { className: "sans", style: { marginBottom: 8 }, children: /* @__PURE__ */ t(z, { to: "/uo/atlas", style: { color: "var(--accent)", fontSize: "0.78rem" }, children: "← Spawn atlas" }) }),
a && /* @__PURE__ */ t(C, {}),
s && !i && /* @__PURE__ */ t(v, { message: "Could not load that creature right now." }),
i && /* @__PURE__ */ t(S, { children: "Nothing by that name spawns on this shard." }),
!a && !s && r && /* @__PURE__ */ l(A, { children: [
/* @__PURE__ */ t(
D,
{
eyebrow: "Bestiary",
title: r.name,
lead: `Up to ${g(r.total)} alive at once across ${g(r.points)} spawner${r.points === 1 ? "" : "s"}.`
}
),
/* @__PURE__ */ l("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [
/* @__PURE__ */ t(
P,
{
title: "Where it spawns",
right: /* @__PURE__ */ t("span", { className: "sans dim", style: { fontSize: "0.74rem" }, children: p.map(([c, n]) => `${c} (${n})`).join(" · ") }),
children: /* @__PURE__ */ t(Z, { places: r.places || [] })
}
),
/* @__PURE__ */ t(ee, { spawners: r.spawners || [], truncated: !!r.spawnersTruncated }),
((o = r.alsoHere) == null ? void 0 : o.length) > 0 && /* @__PURE__ */ t(P, { title: "Shares a spawner with", children: /* @__PURE__ */ t("div", { style: { display: "flex", flexWrap: "wrap", gap: 8 }, children: r.alsoHere.map((c) => /* @__PURE__ */ l(
z,
{
to: `/uo/atlas/${encodeURIComponent(c.slug)}`,
className: "sans",
style: {
fontSize: "0.78rem",
padding: "4px 11px",
borderRadius: 999,
border: "1px solid var(--line)",
color: "var(--muted)",
textDecoration: "none"
},
children: [
c.name,
" ",
/* @__PURE__ */ l("span", { className: "dim", children: [
"×",
g(c.shared)
] })
]
},
c.slug
)) }) })
] })
] })
] }) });
}
const $ = "uo", T = "^1.0.0";
function ae(e) {
const [a] = String(e || "").split(".");
return a === T.replace(/^\^/, "").split(".")[0];
}
const b = window.__rg;
b ? ae(b.version) ? (b.registry.registerRoutes($, {
// Paths are relative to the module's namespace; core prefixes them, so these
// render at /uo/atlas and /uo/atlas/:slug (MODULE_SYSTEM.md §2.8).
public: [
{ path: "atlas", element: /* @__PURE__ */ t(K, {}) },
{ path: "atlas/:slug", element: /* @__PURE__ */ t(te, {}) }
]
}), b.registry.registerNav($, {
area: "public",
items: [{ label: "Atlas", to: "/uo/atlas", feature: "atlas", order: 12 }]
})) : console.error(`[module-${$}] needs core API ${T}, this core is ${b.version} — not registering`) : console.error(`[module-${$}] window.__rg is missing — core did not publish its shared dependencies`);

1691
modules/uo/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"name": "module-uo-client",
"private": true,
"version": "0.1.0-spike",
"type": "module",
"scripts": {
"build": "vite build"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.2",
"vite": "^5.4.8"
}
}

View File

@@ -0,0 +1,47 @@
// module-uo's API bindings.
//
// These used to be `api.atlas` inside core's client/src/api/client.js — a module
// namespace living in core (MODULE_API.md §3.5). The module owns the paths
// because it owns the routes at the other end; core hands over only the request
// primitive: same-origin /api/v1, cookies included, JSON in/out, ApiError on a
// non-2xx.
const { request } = window.__rg.api
const withQs = (s) => (s ? `?${s}` : '')
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 request(`/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 request(`/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 request(`/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 request(`/public/atlas/landmarks${withQs(qs.toString())}`)
},
champions: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
return request(`/public/atlas/champions${withQs(qs.toString())}`)
},
meta: () => request('/public/atlas/meta'),
}
export const api = { atlas }

View File

@@ -0,0 +1,53 @@
// ── module-uo · client entry point ─────────────────────────────────────────
//
// The prebuilt ESM chunk core loads as
// `<script type="module" src="/modules/uo/entry.js">`. Same-origin, so
// `script-src 'self'` admits it with no nonce and no inline — which is the
// entire reason the client half is shaped this way (MODULE_SYSTEM.md §1.14).
//
// It evaluates AFTER core's bundle (deferred module scripts run in document
// order) and BEFORE core renders (main.jsx waits for DOMContentLoaded), so
// registering synchronously here is enough — there is no loading state to
// coordinate and no re-render to trigger.
import Atlas from './pages/Atlas.jsx'
import AtlasCreature from './pages/AtlasCreature.jsx'
const ID = 'uo'
const CORE_API = '^1.0.0'
// The client-side twin of the server's coreApi check. A module built against a
// contract this core does not implement must refuse to register rather than
// half-work: a missing kit member is a blank page three clicks in, and the
// version is knowable now.
function compatible(version) {
const [major] = String(version || '').split('.')
return major === CORE_API.replace(/^\^/, '').split('.')[0]
}
const rg = window.__rg
if (!rg) {
// Not an exception: throwing from a module script is an uncaught error in the
// page, and a module failing to load must never be the site failing to load.
console.error(`[module-${ID}] window.__rg is missing — core did not publish its shared dependencies`)
} else if (!compatible(rg.version)) {
console.error(`[module-${ID}] needs core API ${CORE_API}, this core is ${rg.version} — not registering`)
} else {
rg.registry.registerRoutes(ID, {
// Paths are relative to the module's namespace; core prefixes them, so these
// render at /uo/atlas and /uo/atlas/:slug (MODULE_SYSTEM.md §2.8).
public: [
{ path: 'atlas', element: <Atlas /> },
{ path: 'atlas/:slug', element: <AtlasCreature /> },
],
})
// Interleaves into core's public nav rather than appending a "UO" group.
// `order: 12` puts it where the Atlas link already sat — after Wiki and the
// shard boards, before About. `feature` is resolved by the provider below.
rg.registry.registerNav(ID, {
area: 'public',
items: [{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas', order: 12 }],
})
}

View File

@@ -0,0 +1,307 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync } from '../ui.js'
import { api } from '../api.js'
// ── The spawn atlas ─────────────────────────────────────────────────────────
//
// What the shard CONTAINS, as opposed to what it is doing: which creatures
// spawn, where, and which champion altars are configured. There is no live feed
// here and no `connected` indicator, deliberately — this is parsed from the
// shard's own files and stays complete while the shard is down.
//
// Facet names come from the shard's data, never from a list in this file. A
// shard running custom maps gets its own names in the filter with no code
// change (docs/link/v3.md §6.1 R2).
const PAGE = 50
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
const TABS = [
{ key: 'creatures', label: 'Creatures' },
{ key: 'champions', label: 'Champion altars' },
{ key: 'places', label: 'Places' },
]
function Chip({ active, onClick, children }) {
return (
<button
type="button"
onClick={onClick}
className="sans"
style={{
fontSize: '0.78rem',
padding: '5px 12px',
borderRadius: 999,
cursor: 'pointer',
color: active ? 'var(--bg-deep)' : 'var(--muted)',
background: active ? 'var(--accent)' : 'transparent',
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
}}
>
{children}
</button>
)
}
function CreatureCard({ creature }) {
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
return (
<Link
to={`/uo/atlas/${encodeURIComponent(creature.slug)}`}
className="panel"
style={{
padding: '13px 15px',
display: 'flex',
alignItems: 'center',
gap: 14,
textDecoration: 'none',
color: 'inherit',
}}
>
<div style={{ minWidth: 0, flex: 1 }}>
<div
className="display"
style={{
fontSize: '0.98rem',
color: 'var(--head)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{creature.name}
</div>
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
{facets.length === 0
? '—'
: facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
</div>
</div>
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(creature.total)}</div>
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>
{num(creature.points)} spawners
</div>
</div>
</Link>
)
}
// The creature list owns its own paging rather than going through useAsync: a
// "load more" appends to what is already on screen, which a hook that resets to
// `{ loading: true, data: null }` on every dependency change cannot express.
function Creatures({ q, facet }) {
const [state, setState] = useState({ loading: true, error: null, items: [], total: 0 })
const [more, setMore] = useState(false)
const load = useCallback(
async (offset) => {
const page = await api.atlas.creatures({ q, facet, limit: PAGE, offset })
return page
},
[q, facet],
)
useEffect(() => {
let alive = true
setState({ loading: true, error: null, items: [], total: 0 })
load(0)
.then((page) => {
if (alive) setState({ loading: false, error: null, items: page.creatures || [], total: page.total || 0 })
})
.catch((error) => alive && setState({ loading: false, error, items: [], total: 0 }))
return () => {
alive = false
}
}, [load])
const loadMore = async () => {
setMore(true)
try {
const page = await load(state.items.length)
setState((s) => ({ ...s, items: [...s.items, ...(page.creatures || [])], total: page.total ?? s.total }))
} catch {
// A failed "load more" leaves what is already on screen alone; the button
// simply stays available to retry.
} finally {
setMore(false)
}
}
if (state.loading) return <Loading />
if (state.error) return <ErrorState message="Could not load the bestiary right now." />
if (state.items.length === 0) {
return <EmptyState>Nothing in the atlas matches that.</EmptyState>
}
return (
<>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
Showing {num(state.items.length)} of {num(state.total)}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{state.items.map((c) => (
<CreatureCard key={c.slug} creature={c} />
))}
</div>
{state.items.length < state.total && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<button type="button" className="btn" onClick={loadMore} disabled={more}>
{more ? 'Loading…' : 'Load more'}
</button>
</div>
)}
</>
)
}
// The CONFIGURED altar roster — where the altars are and what each summons. The
// live board ("it is on level 3 right now") is a different page, /site/champs,
// fed by the sidecar. Both exist; they are not the same thing.
function Champions({ facet }) {
const { loading, error, data } = useAsync(() => api.atlas.champions(facet), [facet])
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load the champion altars right now." />
if (!data || data.length === 0) return <EmptyState>No champion altars are configured.</EmptyState>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map((champ) => (
<div key={champ.slug} className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '0.98rem', color: 'var(--head)' }}>
{champ.label || champ.name}
</div>
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
{champ.facet}
{champ.group ? ` · ${champ.group}` : ''} · {champ.x}, {champ.y}
</div>
</div>
<span className="sans" style={{ flex: 'none', fontSize: '0.76rem', color: 'var(--muted)' }}>
{champ.randomType ? 'Random champion' : champ.type || '—'}
</span>
</div>
))}
</div>
)
}
// Regions and landmarks together: both answer "where is that?", and splitting
// them into two tabs would make the visitor guess which list a name lives in.
function Places({ q, facet }) {
const { loading, error, data } = useAsync(
() => Promise.all([api.atlas.regions({ q, facet }), api.atlas.landmarks({ q, facet })]),
[q, facet],
)
const rows = useMemo(() => {
if (!data) return []
const [regions, landmarks] = data
return [
...regions.map((r) => ({ key: `r:${r.facet}:${r.name}`, name: r.name, facet: r.facet, detail: r.parent || r.type || 'Region', kind: 'Region' })),
...landmarks.map((l) => ({ key: `l:${l.facet}:${l.group || ''}:${l.name}:${l.x}:${l.y}`, name: l.group ? `${l.group} — ${l.name}` : l.name, facet: l.facet, detail: `${l.x}, ${l.y}`, kind: 'Landmark' })),
].sort((a, b) => a.name.localeCompare(b.name))
}, [data])
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load places right now." />
if (rows.length === 0) return <EmptyState>No regions or landmarks match that.</EmptyState>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{rows.map((row) => (
<div key={row.key} className="panel" style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>{row.name}</span>
<span className="sans dim" style={{ fontSize: '0.72rem' }}>{row.facet} · {row.detail}</span>
<span className="sans dim" style={{ fontSize: '0.66rem', letterSpacing: '0.06em', flex: 'none' }}>{row.kind}</span>
</div>
))}
</div>
)
}
export default function Atlas() {
const [tab, setTab] = useState('creatures')
const [input, setInput] = useState('')
const [q, setQ] = useState('')
const [facet, setFacet] = useState('')
const meta = useAsync(() => api.atlas.meta())
// Debounced: typing "lizardman" should be one request, not nine.
useEffect(() => {
const timer = setTimeout(() => setQ(input.trim()), 250)
return () => clearTimeout(timer)
}, [input])
const facets = meta.data?.facets || []
const counts = meta.data?.counts || null
const imported = meta.data?.importedAt ? new Date(meta.data.importedAt) : null
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader
eyebrow="Bestiary"
title="Spawn atlas"
lead="Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
/>
{/* The atlas is only as good as its placement rate, so the page states
it rather than implying every spawner resolved to a named place. */}
{counts && (
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
{num(counts.creatures)} creatures across {num(counts.points)} spawners
{Number.isFinite(counts.unresolvedPoints) && counts.points
? ` · ${Math.round(((counts.points - counts.unresolvedPoints) / counts.points) * 100)}% placed to a named region or landmark`
: ''}
{imported ? ` · parsed ${imported.toLocaleDateString()}` : ''}
</p>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
{TABS.map((t) => (
<Chip key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
{t.label}
</Chip>
))}
</div>
{tab !== 'champions' && (
<input
className="input"
type="search"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={tab === 'creatures' ? 'Search creatures…' : 'Search regions and landmarks…'}
style={{ width: '100%', marginBottom: 12 }}
/>
)}
{facets.length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 18 }}>
<Chip active={facet === ''} onClick={() => setFacet('')}>
All facets
</Chip>
{facets.map((f) => (
<Chip key={f} active={facet === f} onClick={() => setFacet(f)}>
{f}
</Chip>
))}
</div>
)}
{meta.error && <ErrorState message="Could not load the atlas right now." />}
{!meta.error && !meta.loading && !imported && (
<EmptyState>The spawn atlas has not been imported yet.</EmptyState>
)}
{!meta.error && imported && (
<>
{tab === 'creatures' && <Creatures q={q} facet={facet} />}
{tab === 'champions' && <Champions facet={facet} />}
{tab === 'places' && <Places q={q} facet={facet} />}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,198 @@
import { useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync } from '../ui.js'
import { api } from '../api.js'
// One creature: where it spawns, and what spawns alongside it.
//
// `places` is the point of the page — the aggregate that turns 62 raw
// coordinates into "Shrines, Isamu-Jima, Yew". The individual spawners are
// available underneath for the reader who actually wants a coordinate, but they
// are secondary and collapsed by default.
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
// Spawn delays are stored in seconds. A raw "1200" tells the reader nothing.
function delay(min, max) {
const fmt = (s) => (s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`)
if (!Number.isFinite(min) || !Number.isFinite(max)) return null
if (min === max) return fmt(min)
return `${fmt(min)}–${fmt(max)}`
}
function Panel({ title, right, children }) {
return (
<section className="panel" style={{ padding: 18 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}>
{title}
</h2>
{right}
</div>
{children}
</section>
)
}
function Places({ places }) {
if (places.length === 0) {
return <p className="sans dim" style={{ margin: 0 }}>No placed spawners.</p>
}
return (
<div>
{places.map((place) => (
<div
key={`${place.facet}:${place.label}`}
className="sans"
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 12,
padding: '6px 0',
borderBottom: '1px solid var(--line)',
fontSize: '0.86rem',
}}
>
<span style={{ minWidth: 0, color: 'var(--head)' }}>{place.label}</span>
<span className="dim" style={{ flex: 'none' }}>
{place.facet} · {num(place.spawners)} spawner{place.spawners === 1 ? '' : 's'} · up to{' '}
{num(place.maxAlive)} at once
</span>
</div>
))}
</div>
)
}
function Spawners({ spawners, truncated }) {
const [open, setOpen] = useState(false)
if (spawners.length === 0) return null
return (
<Panel
title="Individual spawners"
right={
<button
type="button"
className="sans"
onClick={() => setOpen((v) => !v)}
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: '0.78rem' }}
>
{open ? 'Hide' : `Show ${num(spawners.length)}`}
</button>
}
>
{open && (
<div style={{ overflowX: 'auto' }}>
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--muted)' }}>
<th style={{ padding: '4px 8px 8px 0' }}>Place</th>
<th style={{ padding: '4px 8px 8px 0' }}>Facet</th>
<th style={{ padding: '4px 8px 8px 0' }}>Coords</th>
<th style={{ padding: '4px 8px 8px 0' }}>Max</th>
<th style={{ padding: '4px 0 8px 0' }}>Respawn</th>
</tr>
</thead>
<tbody>
{spawners.map((s) => (
<tr key={s.id} style={{ borderTop: '1px solid var(--line)' }}>
<td style={{ padding: '6px 8px 6px 0', color: 'var(--head)' }}>{s.label}</td>
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.facet}</td>
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.x}, {s.y}</td>
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{num(s.maxCount)}</td>
<td style={{ padding: '6px 0' }} className="dim">{delay(s.minDelay, s.maxDelay) || '—'}</td>
</tr>
))}
</tbody>
</table>
{truncated && (
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
Only the largest spawners are listed.
</p>
)}
</div>
)}
</Panel>
)
}
export default function AtlasCreature() {
const { slug } = useParams()
const { loading, error, data } = useAsync(() => api.atlas.creature(slug), [slug])
// A 404 here means "no such creature in this atlas", which is a real answer
// and not a failure — a visitor following a stale link deserves to be told
// that plainly rather than shown a generic error box.
const missing = error?.status === 404 || error?.message === 'Not Found'
const facets = useMemo(
() => Object.entries(data?.facets || {}).sort((a, b) => b[1] - a[1]),
[data],
)
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<p className="sans" style={{ marginBottom: 8 }}>
<Link to="/uo/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
← Spawn atlas
</Link>
</p>
{loading && <Loading />}
{error && !missing && <ErrorState message="Could not load that creature right now." />}
{missing && <EmptyState>Nothing by that name spawns on this shard.</EmptyState>}
{!loading && !error && data && (
<>
<PageHeader
eyebrow="Bestiary"
title={data.name}
lead={`Up to ${num(data.total)} alive at once across ${num(data.points)} spawner${data.points === 1 ? '' : 's'}.`}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Panel
title="Where it spawns"
right={
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
{facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
</span>
}
>
<Places places={data.places || []} />
</Panel>
<Spawners spawners={data.spawners || []} truncated={!!data.spawnersTruncated} />
{data.alsoHere?.length > 0 && (
<Panel title="Shares a spawner with">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{data.alsoHere.map((other) => (
<Link
key={other.slug}
to={`/uo/atlas/${encodeURIComponent(other.slug)}`}
className="sans"
style={{
fontSize: '0.78rem',
padding: '4px 11px',
borderRadius: 999,
border: '1px solid var(--line)',
color: 'var(--muted)',
textDecoration: 'none',
}}
>
{other.name} <span className="dim">×{num(other.shared)}</span>
</Link>
))}
</div>
</Panel>
)}
</div>
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,3 @@
const reactDom = window.__rg.reactDom
export default reactDom
export const { createRoot, hydrateRoot } = reactDom

View File

@@ -0,0 +1,8 @@
// The automatic JSX runtime, from core's global. Every .jsx file in this module
// compiles to imports from here, so this is the single hottest path in the
// bundle — and the one that would silently produce a SECOND React if it resolved
// to a bundled copy instead.
const jsx = window.__rg.jsxRuntime
export default jsx
export const { jsx: jsxFn, jsxs, Fragment } = jsx
export { jsxFn as jsx }

View File

@@ -0,0 +1,10 @@
// react-router-dom from core's global. Same singleton argument as React, with a
// sharper edge: the router's context is created by whichever copy is loaded, so
// a second copy would give module pages an EMPTY router context — <Link> would
// throw and useParams() would return {} rather than the URL's params.
const router = window.__rg.router
export default router
export const {
Link, NavLink, Navigate, Outlet, Route, Routes, useParams, useNavigate,
useLocation, useSearchParams, createBrowserRouter, RouterProvider,
} = router

19
modules/uo/client/src/shim/react.js vendored Normal file
View File

@@ -0,0 +1,19 @@
// React, taken from core's global rather than bundled.
//
// There is exactly ONE React in the page and core owns it (MODULE_API.md §3.2).
// A module that bundled its own would get a second hook dispatcher and fail at
// the first useState — so `react` is declared external in vite.config.js and
// aliased here.
//
// Why an alias module rather than rollup's `output.globals`: `globals` only
// applies to iife/umd output, and this is an ES module. An alias is the ESM
// equivalent, and it also keeps named imports (`import { useState } from
// 'react'`) working unchanged in the page source.
const react = window.__rg.react
export default react
export const {
useState, useEffect, useMemo, useCallback, useRef, useContext, useReducer,
createElement, cloneElement, createContext, forwardRef, memo, Fragment,
Children, isValidElement, StrictMode, Suspense, lazy,
} = react

View File

@@ -0,0 +1,13 @@
// Core's shared UI kit, from the global.
//
// This is the §3.4 kit: a curated, closed set — the layout chrome, the three
// page states, the async hook and the two read-only contexts. It exists because
// a module page that does not use core's layout is a module page that does not
// look like the site it is installed in, and drifts further every time core's
// chrome changes.
//
// Anything NOT in here, the module bundles itself.
const { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync, useAuth, useSite } =
window.__rg.ui
export { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync, useAuth, useSite }

View File

@@ -0,0 +1,43 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// Library mode: one prebuilt ESM chunk, published by CI and dropped onto the
// operator's volume. The operator never builds anything (MODULE_SYSTEM.md §2.5).
//
// The four externals are the whole contract with core. Declaring them external
// alone is not enough, though: rollup would emit bare `import 'react'`
// specifiers, which a browser cannot resolve without an import map — and CSP
// forbids the inline <script type="importmap"> that would provide one. So each
// is ALIASED to a two-line shim that re-exports from window.__rg, and the
// external list then only has to stop Vite from following them into node_modules
// this package does not have.
const shim = (f) => path.resolve(import.meta.dirname, 'src/shim', f)
export default defineConfig({
plugins: [react()],
resolve: {
// EXACT matches, via the array form. Vite's object form does PREFIX
// replacement, so a plain `react` key also rewrote `react/jsx-runtime` into
// `src/shim/react.js/jsx-runtime` — a path that does not exist, and the
// first thing this build hit.
alias: [
{ find: /^react$/, replacement: shim('react.js') },
{ find: /^react\/jsx-runtime$/, replacement: shim('react-jsx-runtime.js') },
{ find: /^react-dom\/client$/, replacement: shim('react-dom-client.js') },
{ find: /^react-router-dom$/, replacement: shim('react-router-dom.js') },
],
},
build: {
lib: {
entry: path.resolve(import.meta.dirname, 'src/entry.jsx'),
formats: ['es'],
fileName: () => 'entry.js',
},
outDir: 'dist',
emptyOutDir: true,
// Same reason as core's client: no inline bootstrap script for
// `script-src 'self'` to trip on.
modulePreload: { polyfill: false },
},
})

14
modules/uo/module.json Normal file
View File

@@ -0,0 +1,14 @@
{
"id": "uo",
"name": "Ultima Online",
"version": "0.1.0-spike",
"coreApi": "^1.0.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",
"purge": "server/db/purge.sql",
"mounts": {
"public": ["/atlas"]
},
"capabilities": ["atlas"]
}

88
modules/uo/server/core.js Normal file
View File

@@ -0,0 +1,88 @@
// ── The module's single point of contact with core ─────────────────────────
//
// Every other file in this module imports THIS file instead of reaching into
// the website's tree. That is the whole mechanical trick behind the
// zero-internal-imports rule (docs/website/MODULE_API.md §5.1): the moved files
// changed by one `require` line each, and a CI grep for a relative path
// escaping the module root can then be an exact test rather than a heuristic.
//
// It exists because `ctx` arrives as an ARGUMENT to register(), while the files
// that need it are plain CommonJS modules that were written against top-level
// requires. Rather than thread ctx through nine constructors, register() parks
// it here once and everything else reads it lazily.
//
// Lazily is load-bearing: this file is required at module-require time, which is
// during app.js's own require, and reading `ctx.db` eagerly would rebuild the
// startup-time database dependency the loader is careful not to have.
let ctx = null
/** Called exactly once, by server/index.js, at the top of register(). */
function init(next) {
if (ctx) throw new Error('module-uo: core.init() called twice')
ctx = next
}
function require_() {
if (!ctx) throw new Error('module-uo: core used before register() ran')
return ctx
}
// Forwarders rather than re-exports: `const { query } = require('./core')`
// destructures at require time, which is before init(), so a plain re-export
// would capture undefined. Each of these resolves ctx at CALL time.
const query = (sql, params) => require_().db.query(sql, params)
const logger = (namespace) => require_().log(namespace)
const settings = {
get: (key) => require_().settings.get(key),
// `updatedBy` is the third parameter core's settings.model.set carries — the
// atlas path setter passes it (shardAtlas.model.js:60), so dropping it here
// would silently lose the audit attribution rather than fail.
set: (key, value, updatedBy) => require_().settings.set(key, value, updatedBy),
getInstanceName: () => require_().settings.getInstanceName(),
}
const auth = {
getUserFromRequest: (req) => require_().auth.getUserFromRequest(req),
}
const middleware = {
siteMode: (req, res, next) => require_().middleware.siteMode(req, res, next),
validate: (req, res, next) => require_().middleware.validate(req, res, next),
requireAuth: (req, res, next) => require_().middleware.requireAuth(req, res, next),
noindex: (req, res, next) => require_().middleware.noindex(req, res, next),
requireRole: (...roles) => {
// requireRole is a FACTORY, so it must be resolved at call time and the
// resulting middleware kept — resolving it per request would build a new
// closure on every hit.
let built = null
return (req, res, next) => {
built = built || require_().middleware.requireRole(...roles)
return built(req, res, next)
}
},
}
module.exports = {
init,
// Shared server dependencies, taken from core rather than required directly.
// A module lives outside server/, so `require('express')` from here does not
// resolve at all — and even where it did, a second express in the process
// would be a second Router prototype. Same rule as React on the client.
get express() { return require_().express },
get validator() { return require_().validator },
query,
logger,
settings,
auth,
middleware,
get pool() { return require_().db.pool },
get secretBox() { return require_().secretBox },
get push() { return require_().push },
get uploads() { return require_().uploads },
get posts() { return require_().posts },
get paths() { return require_().paths },
get moduleId() { return require_().moduleId },
}

View File

@@ -0,0 +1,21 @@
-- ── module-uo · purge ──────────────────────────────────────────────────────
--
-- DESTRUCTIVE. Run ONLY by the explicit admin purge action, never by uninstall
-- (docs/website/MODULE_API.md §2.6) — uninstalling a module removes its code and
-- retains its data, and an operator who wants the data gone has to say so.
--
-- Required because this module declares a schema fragment: a module that can
-- create tables and cannot drop them leaves an operator with orphaned data and
-- no supported way to remove it.
--
-- Dropped children-first even though these tables carry no foreign keys, so the
-- order stays correct if Phase 3 adds one.
DROP TABLE IF EXISTS shard_atlas_pending;
DROP TABLE IF EXISTS shard_atlas_meta;
DROP TABLE IF EXISTS shard_champion_spawns;
DROP TABLE IF EXISTS shard_landmarks;
DROP TABLE IF EXISTS shard_regions;
DROP TABLE IF EXISTS shard_spawn_point_types;
DROP TABLE IF EXISTS shard_spawn_points;
DROP TABLE IF EXISTS shard_spawn_creatures;

View File

@@ -0,0 +1,166 @@
-- ── module-uo · schema fragment ────────────────────────────────────────────
--
-- Replayed by core's ensureSchema() immediately after core's own schema.sql,
-- statement by statement, split the same way (docs/website/MODULE_API.md §2.6).
-- It inherits core's rules because it goes through core's splitter: idempotent
-- CREATE/ALTER only, no DROP, and no `--` inside a string literal.
--
-- SPIKE SCOPE: the eight spawn-atlas tables, lifted verbatim out of
-- server/db/schema.sql. Phase 3 brings the other nineteen.
--
-- These names are NOT `uo_`-prefixed, which the contract otherwise requires of a
-- module's tables. module-uo is grandfathered by an explicit allowlist in the
-- loader: renaming twenty-seven live tables is a data migration this workstream
-- deliberately does not do, and the prefix rule holds for every module written
-- after this one.
-- ── Spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
-- Static shard CONTENT, not live shard state: what spawns where, which regions
-- and landmarks exist, and which champion altars are configured. Nothing here
-- comes from the sidecar — it is imported from a committed artifact built off a
-- ServUO tree by `npm run atlas:build` (see docs/website/SPAWN_ATLAS.md), so
-- these tables stay populated whether the shard is up or not.
--
-- Every table is import-owned: `npm run atlas:import` TRUNCATEs and reloads them
-- in one transaction. Nothing else may write here, and nothing else may hold a
-- foreign key to them. No FKs at all, consistent with every other shard_* table.
-- One row per spawnable type, aggregated across the world. `total` is the sum of
-- each type's own MX across every point that spawns it (how many exist at once);
-- `facets` is a per-facet point count, so the facet filter and "where does this
-- live" both answer without touching shard_spawn_points.
CREATE TABLE IF NOT EXISTS shard_spawn_creatures (
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- slugified class name; the /atlas/:slug key
name VARCHAR(120) NOT NULL, -- display spelling chosen by the build
total INT NOT NULL DEFAULT 0,
points INT NOT NULL DEFAULT 0,
facets JSON NULL, -- { "Felucca": 171, "Trammel": 160, ... }
-- Operator-supplied artwork, always NULL on a fresh import. The repo ships no
-- creature art: sprites live in the operator's own client .mul/.uop files and
-- are theirs to extract and place under uploads/atlas/. The UI renders without
-- art when this is NULL, which is the normal case.
art VARCHAR(255) NULL,
-- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free,
-- and FULLTEXT's min-token-length would break searches for names like "orc".
INDEX idx_shard_spawn_creatures_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- One row per spawner. `region`/`landmark` are the resolved place name — the
-- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is
-- the resolved display string (region, else landmark, else 'Wilderness').
CREATE TABLE IF NOT EXISTS shard_spawn_points (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NULL, -- the ServUO spawner's own name
x INT NOT NULL,
y INT NOT NULL,
width INT NOT NULL DEFAULT 0,
height INT NOT NULL DEFAULT 0,
spawn_range INT NOT NULL DEFAULT 0, -- `range` is reserved in MariaDB
max_count INT NOT NULL DEFAULT 0,
min_delay INT NOT NULL DEFAULT 0,
max_delay INT NOT NULL DEFAULT 0,
tod_start INT NOT NULL DEFAULT 0, -- meaningless unless tod_mode <> 0
tod_end INT NOT NULL DEFAULT 0,
tod_mode INT NOT NULL DEFAULT 0,
region VARCHAR(120) NULL,
landmark VARCHAR(120) NULL,
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
INDEX idx_shard_spawn_points_facet (facet),
INDEX idx_shard_spawn_points_label (label)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The many-to-many between the two above: one spawner commonly carries several
-- types (a single Trammel point spawns six), each with its own max. This is how
-- /atlas/creatures/:slug finds the places a creature appears.
CREATE TABLE IF NOT EXISTS shard_spawn_point_types (
point_id INT NOT NULL,
slug VARCHAR(120) NOT NULL, -- → shard_spawn_creatures.slug (no FK)
max_count INT NOT NULL DEFAULT 1,
PRIMARY KEY (point_id, slug),
INDEX idx_shard_spawn_point_types_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Named regions from Data/Regions.xml, flattened out of their nesting. `rects`
-- holds the region's rectangles; `priority` and rect area are what resolved each
-- spawn point at build time, kept here so the admin drift check can re-derive.
CREATE TABLE IF NOT EXISTS shard_regions (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NOT NULL,
type VARCHAR(80) NULL, -- ServUO region class
priority INT NOT NULL DEFAULT 0,
parent VARCHAR(120) NULL, -- enclosing named region, if any
rects JSON NULL,
INDEX idx_shard_regions_facet (facet),
INDEX idx_shard_regions_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing
-- parent ("Covetous"), which is the label worth showing — "Covetous" reads
-- better than the individual marker "Level 1". (`group` is reserved in SQL.)
CREATE TABLE IF NOT EXISTS shard_landmarks (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NOT NULL,
grp VARCHAR(120) NULL,
x INT NOT NULL,
y INT NOT NULL,
z INT NOT NULL DEFAULT 0,
INDEX idx_shard_landmarks_facet (facet),
INDEX idx_shard_landmarks_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
CREATE TABLE IF NOT EXISTS shard_champion_spawns (
slug VARCHAR(160) NOT NULL PRIMARY KEY, -- facet-name, e.g. "felucca-deceit"
name VARCHAR(120) NOT NULL,
grp VARCHAR(80) NULL, -- spawn group; one active per group
type VARCHAR(80) NULL, -- '' when randomised per activation
random_type TINYINT(1) NOT NULL DEFAULT 0,
facet VARCHAR(40) NOT NULL,
x INT NOT NULL,
y INT NOT NULL,
z INT NOT NULL DEFAULT 0,
radius INT NOT NULL DEFAULT 0,
label VARCHAR(120) NULL, -- resolved place name
INDEX idx_shard_champion_spawns_facet (facet)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Singleton (id = 1) describing the artifact currently loaded: when it was
-- built, its counts, and a sha256 per ServUO source file. The admin drift check
-- compares this against db/data/spawnAtlas.meta.json to report when the database
-- is behind the committed artifact.
CREATE TABLE IF NOT EXISTS shard_atlas_meta (
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
payload JSON NOT NULL,
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_shard_atlas_meta_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Singleton (id = 1) holding an atlas refresh that was parsed but deliberately
-- NOT applied, because it would remove a facet the site currently serves.
--
-- Losing a facet is the signature of a half-copied or mid-update ServUO tree as
-- much as of a real map change, and boot cannot tell the two apart — so the
-- refresh is staged here for a human instead of being applied. Startup is never
-- blocked by it: the site comes up serving the atlas it already had.
--
-- Only the DECISION is stored, not the parsed world: `payload` holds the source
-- hashes and the facet diff (a few KB), and approving re-parses the tree. That
-- keeps a multi-megabyte blob out of the database and guarantees the applied
-- atlas matches the tree as it is at approval time, not as it was at boot.
--
-- `rejected` is remembered against those exact source hashes so a declined
-- refresh does not re-prompt on every restart; changing the tree changes the
-- hashes and asks again.
CREATE TABLE IF NOT EXISTS shard_atlas_pending (
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
status ENUM('pending','rejected') NOT NULL DEFAULT 'pending',
payload JSON NOT NULL, -- source hashes + facet diff
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -0,0 +1,59 @@
// ── module-uo · server entry point ─────────────────────────────────────────
//
// SPIKE SCOPE. Phase 1 carries only /api/v1/public/atlas/* out of core
// (MODULE_SYSTEM.md §2.7): six routes, DB-backed, no sidecar, no SSE, one boot
// hook. Phase 3 brings the rest — the other 12 router/controller files, the 7
// remaining model directories, the notification-stream catalog and the
// town-crier announce leg.
//
// Called ONCE, synchronously, during the website's app.js require. Everything
// here must therefore be synchronous and must not touch the database: the route
// manifest generator and the OpenAPI generator both require app.js with the
// pool pointed at a dead port, and a module that queried here would hang both
// (MODULE_API.md §2.2). Anything needing a live database goes in onBoot.
const core = require('./core')
module.exports = function register(ctx, api) {
// Park ctx before requiring anything that reads it. The requires below pull in
// the model layer, whose files resolve core lazily — but the ORDER still
// matters for the router, which is constructed at require time.
core.init(ctx)
/* eslint-disable global-require */
const atlasRouter = require('./router/atlas.router')
const atlas = require('./model/shardAtlas/shardAtlas.model')
/* eslint-enable global-require */
const log = ctx.log('boot')
// The URL is unchanged from when this router lived in core's
// router/v1/public/index.js — that is the point, and routes.manifest.json is
// the proof (MODULE_API.md §5.3).
api.registerRoutes({
public: { '/atlas': atlasRouter },
})
// Was server.js:92, an explicit call in core's start(). Re-derive the spawn
// atlas from the shard's own ServUO tree: the shard's maps change over its
// lifetime — facets get added, replaced or renamed — so the atlas is rebuilt
// on every boot rather than shipped as a snapshot that would silently go
// stale. Hash-gated, so an unchanged tree costs one read pass and no write.
//
// Best-effort by contract: no configured path, an unreadable mount or a
// malformed file must never stop the site coming up, and a refresh that would
// REMOVE a facet is staged for admin approval instead of being applied. So it
// is caught HERE rather than left to the loader — the loader's catch would be
// correct about the failure but wrong about the severity, marking the module
// startup_failed and 503-ing six routes that serve perfectly good stale data.
api.onBoot(async () => {
try {
const result = await atlas.refreshOnBoot()
log.info('spawn atlas refreshed', { status: result && result.status })
} catch (err) {
log.warn('spawn atlas refresh failed — serving whatever was last imported', {
error: err.message,
})
}
})
}

View File

@@ -0,0 +1,390 @@
const { pool, query } = require('../../core')
// Raw SQL for the spawn atlas. Every table here is IMPORT-OWNED: `replaceAtlas`
// empties and refills all six inside one transaction, and nothing else in the
// codebase writes to them. There are no foreign keys, consistent with every
// other shard_* table.
const BATCH = 500
const ATLAS_TABLES = [
'shard_spawn_point_types',
'shard_spawn_points',
'shard_spawn_creatures',
'shard_regions',
'shard_landmarks',
'shard_champion_spawns',
]
async function insertBatched(conn, sql, rows) {
for (let i = 0; i < rows.length; i += BATCH) {
await conn.batch(sql, rows.slice(i, i + BATCH))
}
return rows.length
}
/**
* Replace the entire atlas in one transaction.
*
* All-or-nothing on purpose: a failed reload must leave the previous atlas
* intact rather than a half-loaded world, since a partially-imported atlas is
* indistinguishable from a real one to anyone reading it.
*
* `DELETE`, not `TRUNCATE` — `TRUNCATE` is DDL in MariaDB and implicitly
* commits, which would defeat exactly that guarantee. At ~7k rows the cost of
* `DELETE` is irrelevant.
*/
async function replaceAtlas(atlas, art = {}) {
const conn = await pool.getConnection()
const counts = {}
try {
await conn.beginTransaction()
for (const table of ATLAS_TABLES) await conn.query(`DELETE FROM ${table}`)
counts.creatures = await insertBatched(
conn,
'INSERT INTO shard_spawn_creatures (slug, name, total, points, facets, art) VALUES (?,?,?,?,?,?)',
atlas.creatures.map((c) => [
c.slug,
c.name,
c.total ?? 0,
c.points ?? 0,
JSON.stringify(c.facets ?? {}),
art[c.slug] ?? null,
]),
)
counts.regions = await insertBatched(
conn,
'INSERT INTO shard_regions (facet, name, type, priority, parent, rects) VALUES (?,?,?,?,?,?)',
atlas.regions.map((r) => [
r.facet,
r.name,
r.type || null,
r.priority ?? 0,
r.parent || null,
JSON.stringify(r.rects ?? []),
]),
)
counts.landmarks = await insertBatched(
conn,
'INSERT INTO shard_landmarks (facet, name, grp, x, y, z) VALUES (?,?,?,?,?,?)',
atlas.landmarks.map((l) => [
l.facet,
l.name,
l.group || null,
l.x ?? 0,
l.y ?? 0,
l.z ?? 0,
]),
)
counts.champions = await insertBatched(
conn,
'INSERT INTO shard_champion_spawns ' +
'(slug, name, grp, type, random_type, facet, x, y, z, radius, label) ' +
'VALUES (?,?,?,?,?,?,?,?,?,?,?)',
atlas.champions.map((c) => [
c.slug,
c.name,
c.group || null,
c.type || null,
c.randomType ? 1 : 0,
c.facet,
c.x ?? 0,
c.y ?? 0,
c.z ?? 0,
c.radius ?? 0,
c.label || null,
]),
)
// Point ids are assigned explicitly rather than left to AUTO_INCREMENT: the
// join rows need to know them and `conn.batch()` reports no usable insertId
// for a multi-row insert. Safe because this transaction just emptied the
// table and nothing else writes to it.
counts.points = await insertBatched(
conn,
'INSERT INTO shard_spawn_points ' +
'(id, facet, name, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' +
'tod_start, tod_end, tod_mode, region, landmark, label) ' +
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
atlas.points.map((p, i) => [
i + 1,
p.facet,
p.name,
p.x,
p.y,
p.width ?? 0,
p.height ?? 0,
p.range ?? 0,
p.maxCount ?? 0,
p.minDelay ?? 0,
p.maxDelay ?? 0,
p.todStart ?? 0,
p.todEnd ?? 0,
p.todMode ?? 0,
p.region,
p.landmark,
p.label || 'Wilderness',
]),
)
counts.pointTypes = await insertBatched(
conn,
'INSERT INTO shard_spawn_point_types (point_id, slug, max_count) VALUES (?,?,?)',
atlas.pointTypes,
)
await conn.query(
'INSERT INTO shard_atlas_meta (id, payload) VALUES (1, ?) ' +
'ON DUPLICATE KEY UPDATE payload = VALUES(payload), imported_at = CURRENT_TIMESTAMP',
[JSON.stringify({ ...atlas.meta, importedCounts: counts })],
)
// A completed import answers whatever was pending.
await conn.query('DELETE FROM shard_atlas_pending')
await conn.commit()
return counts
} catch (err) {
await conn.rollback().catch(() => {})
throw err
} finally {
conn.release()
}
}
async function getMeta() {
const rows = await query('SELECT payload, imported_at FROM shard_atlas_meta WHERE id = 1')
if (rows.length === 0) return null
const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload
return { ...payload, importedAt: rows[0].imported_at }
}
/** Facet names currently loaded, used to detect a facet disappearing. */
async function getFacets() {
const rows = await query('SELECT DISTINCT facet FROM shard_spawn_points ORDER BY facet')
return rows.map((row) => row.facet)
}
// ── Pending review ─────────────────────────────────────────────────────────
async function getPending() {
const rows = await query('SELECT payload, status, detected_at FROM shard_atlas_pending WHERE id = 1')
if (rows.length === 0) return null
const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload
return { ...payload, status: rows[0].status, detectedAt: rows[0].detected_at }
}
async function setPending(payload, status = 'pending') {
return query(
'INSERT INTO shard_atlas_pending (id, status, payload) VALUES (1, ?, ?) ' +
'ON DUPLICATE KEY UPDATE status = VALUES(status), payload = VALUES(payload), ' +
'detected_at = CURRENT_TIMESTAMP',
[status, JSON.stringify(payload)],
)
}
async function clearPending() {
return query('DELETE FROM shard_atlas_pending')
}
// ── Reads (the public /atlas surface) ──────────────────────────────────────
//
// Every read here is a plain indexed query over ~7k rows and is served entirely
// from MariaDB: the atlas is static shard content, so nothing on this path
// touches the sidecar and nothing degrades when the shard is down.
//
// A facet filter is expressed as EXISTS over the points, never as a JSON path
// built from caller input. `shard_spawn_creatures.facets` is a JSON object keyed
// by facet name, and matching a key means either concatenating the name into a
// path or handing it to JSON_SEARCH — whose search string treats `%` and `_` as
// wildcards, so `?facet=%` would quietly match everything. The join is exact and
// uses the indexes that already exist.
const CREATURE_FACET_EXISTS = `EXISTS (
SELECT 1 FROM shard_spawn_point_types t
JOIN shard_spawn_points p ON p.id = t.point_id
WHERE t.slug = c.slug AND p.facet = ?
)`
// Build the WHERE for a creature search. `q` is a substring match on the display
// name — a LIKE scan, which is free at ~800 rows and, unlike FULLTEXT, has no
// minimum token length to break a search for "orc".
function creatureWhere({ q, facet }) {
const where = []
const params = []
if (q) {
where.push('c.name LIKE ?')
params.push(`%${q}%`)
}
if (facet) {
where.push(CREATURE_FACET_EXISTS)
params.push(facet)
}
return { sql: where.length ? `WHERE ${where.join(' AND ')}` : '', params }
}
async function countCreatures({ q = '', facet = '' } = {}) {
const { sql, params } = creatureWhere({ q, facet })
const rows = await query(`SELECT COUNT(*) AS n FROM shard_spawn_creatures c ${sql}`, params)
return rows[0] ? Number(rows[0].n) : 0
}
function listCreatures({ q = '', facet = '', limit = 50, offset = 0 } = {}) {
const { sql, params } = creatureWhere({ q, facet })
return query(
`SELECT c.slug, c.name, c.total, c.points, c.facets, c.art
FROM shard_spawn_creatures c
${sql}
ORDER BY c.total DESC, c.name ASC
LIMIT ? OFFSET ?`,
[...params, limit, offset],
)
}
async function getCreature(slug) {
const rows = await query(
'SELECT slug, name, total, points, facets, art FROM shard_spawn_creatures WHERE slug = ?',
[slug],
)
return rows[0] || null
}
/**
* Where a creature spawns, grouped by resolved place.
*
* This is the answer the atlas exists to give — "lizardman → Shrines,
* Isamu-Jima, Yew" — so it is aggregated in SQL rather than by summing 6,455
* point rows in Node.
*/
function listCreaturePlaces(slug, { facet = '' } = {}) {
const params = [slug]
let facetSql = ''
if (facet) {
facetSql = 'AND p.facet = ?'
params.push(facet)
}
return query(
`SELECT p.facet, p.label, COUNT(*) AS spawners, SUM(t.max_count) AS max_alive
FROM shard_spawn_point_types t
JOIN shard_spawn_points p ON p.id = t.point_id
WHERE t.slug = ? ${facetSql}
GROUP BY p.facet, p.label
ORDER BY spawners DESC, p.facet ASC, p.label ASC`,
params,
)
}
/** The individual spawners for a creature, newest-largest first. Bounded. */
function listCreaturePoints(slug, { facet = '', limit = 200 } = {}) {
const params = [slug]
let facetSql = ''
if (facet) {
facetSql = 'AND p.facet = ?'
params.push(facet)
}
params.push(limit)
return query(
`SELECT p.id, p.facet, p.name, p.x, p.y, p.width, p.height, p.spawn_range,
p.min_delay, p.max_delay, p.tod_start, p.tod_end, p.tod_mode,
p.region, p.landmark, p.label, t.max_count
FROM shard_spawn_point_types t
JOIN shard_spawn_points p ON p.id = t.point_id
WHERE t.slug = ? ${facetSql}
ORDER BY t.max_count DESC, p.facet ASC, p.label ASC, p.id ASC
LIMIT ?`,
params,
)
}
/** Every other creature sharing a spawner with this one. */
function listCreatureCompanions(slug, { limit = 24 } = {}) {
return query(
`SELECT o.slug, c.name, COUNT(*) AS shared
FROM shard_spawn_point_types t
JOIN shard_spawn_point_types o ON o.point_id = t.point_id AND o.slug <> t.slug
JOIN shard_spawn_creatures c ON c.slug = o.slug
WHERE t.slug = ?
GROUP BY o.slug, c.name
ORDER BY shared DESC, c.name ASC
LIMIT ?`,
[slug, limit],
)
}
function listRegions({ facet = '', q = '' } = {}) {
const where = []
const params = []
if (facet) {
where.push('facet = ?')
params.push(facet)
}
if (q) {
where.push('name LIKE ?')
params.push(`%${q}%`)
}
return query(
`SELECT facet, name, type, priority, parent, rects
FROM shard_regions
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY facet ASC, name ASC`,
params,
)
}
function listLandmarks({ facet = '', q = '' } = {}) {
const where = []
const params = []
if (facet) {
where.push('facet = ?')
params.push(facet)
}
if (q) {
where.push('(name LIKE ? OR grp LIKE ?)')
params.push(`%${q}%`, `%${q}%`)
}
return query(
`SELECT facet, name, grp, x, y, z
FROM shard_landmarks
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY facet ASC, grp ASC, name ASC`,
params,
)
}
function listChampions({ facet = '' } = {}) {
const params = []
let where = ''
if (facet) {
where = 'WHERE facet = ?'
params.push(facet)
}
return query(
`SELECT slug, name, grp, type, random_type, facet, x, y, z, radius, label
FROM shard_champion_spawns
${where}
ORDER BY facet ASC, name ASC`,
params,
)
}
module.exports = {
replaceAtlas,
getMeta,
getFacets,
getPending,
setPending,
clearPending,
countCreatures,
listCreatures,
getCreature,
listCreaturePlaces,
listCreaturePoints,
listCreatureCompanions,
listRegions,
listLandmarks,
listChampions,
}

View File

@@ -0,0 +1,485 @@
const fs = require('fs')
const path = require('path')
const db = require('./shardAtlas.db')
const { settings } = require('../../core')
const { slugify } = require('../../utils/spawnAtlasParse')
const {
AtlasSourceError,
PARSER_VERSION,
buildAtlas,
hashSources,
sameSources,
} = require('../../utils/spawnAtlasSource')
const log = require('../../core').logger('atlas')
// The spawn atlas, refreshed from the shard's own ServUO tree.
//
// The tree is the single source of truth. Nothing is precomputed and committed,
// because a shard's maps change over its lifetime — facets get added, replaced
// or renamed — and a snapshot in the repo would go stale against the world
// players actually see. So the atlas is re-derived on every boot.
//
// Two rules govern the boot path:
//
// 1. **It never blocks startup.** No configured path, an unreadable path, a
// malformed file, a database error — all of it is caught and logged. The
// site comes up either way, serving whatever atlas it already had.
// 2. **A facet disappearing is not applied automatically.** Losing a facet is
// the signature of a half-copied or mid-update tree as much as of a real
// map change, and the two are indistinguishable from here. The refresh is
// staged for a human instead, and an admin approves or rejects it.
//
// Everything else — new facets, renamed regions, changed spawns — applies
// straight away, because none of it can silently destroy data an operator would
// miss.
const SETTING_KEY = 'spawn_atlas_servuo_path'
/**
* Where the ServUO tree lives.
*
* The admin setting wins over the environment so an operator can point the
* atlas at a different tree without a redeploy, matching how the rest of the
* shard integration is admin-managed rather than env-configured. `SERVUO_PATH`
* remains as the deploy-time default, since the path usually describes a mount
* that the deployment sets up.
*/
async function getServuoPath() {
try {
const configured = await settings.get(SETTING_KEY)
if (configured && String(configured).trim() !== '') return String(configured).trim()
} catch {
// Settings unavailable is not fatal — fall through to the env default.
}
const fromEnv = process.env.SERVUO_PATH
return fromEnv && fromEnv.trim() !== '' ? fromEnv.trim() : ''
}
async function setServuoPath(value, updatedBy = null) {
return settings.set(SETTING_KEY, String(value ?? '').trim(), updatedBy)
}
/**
* Optional operator-supplied art map, `{ "<slug>": "<file under uploads/atlas/>" }`.
*
* Never committed and never shipped — creature sprites come out of the
* operator's own client `.mul`/`.uop` files, which are theirs, not ours to
* redistribute. Absent (the normal case) every `art` stays NULL and the UI
* renders text-only.
*/
function loadArtMap(dir = path.join(__dirname, '..', '..', '..', 'db', 'data')) {
try {
const file = path.join(dir, 'spawnAtlas.art.json')
if (!fs.existsSync(file)) return {}
const map = JSON.parse(fs.readFileSync(file, 'utf8'))
return map && typeof map === 'object' ? map : {}
} catch (err) {
log.warn('spawn atlas art map could not be read', { error: err.message })
return {}
}
}
/**
* Flatten each point's types into `shard_spawn_point_types` rows.
*
* A spawner may legitimately list the same type twice, and the primary key is
* (point_id, slug), so duplicates collapse to the larger max rather than
* failing the insert.
*/
function pointTypeRows(points) {
const rows = []
points.forEach((point, i) => {
const bySlug = new Map()
for (const entry of point.types ?? []) {
const slug = slugify(entry.type)
if (slug === '') continue
bySlug.set(slug, Math.max(bySlug.get(slug) ?? 0, entry.max ?? 1))
}
for (const [slug, max] of bySlug) rows.push([i + 1, slug, max])
})
return rows
}
async function applyAtlas(atlas) {
return db.replaceAtlas({ ...atlas, pointTypes: pointTypeRows(atlas.points) }, loadArtMap())
}
/**
* Refresh the atlas from the configured ServUO tree.
*
* Returns a result describing what happened rather than throwing, so the caller
* — including the boot path — can log it and move on:
*
* `skipped` no path configured
* `unavailable` path configured but unreadable / missing required files
* `unchanged` source hashes match the loaded atlas; nothing parsed
* `imported` parsed and applied
* `needsReview` parsed, but a facet would be lost; staged for an admin
* `failed` parsed or applied and something went wrong
*
* `force` skips the hash check (an admin asking for a reimport) and `approve`
* additionally accepts facet loss (an admin approving a staged refresh).
*/
/**
* Was the loaded atlas built by THIS parser?
*
* An atlas imported before `parserVersion` existed reports undefined, which is
* correctly "no" — those are exactly the ones carrying the old readings.
*/
const currentParser = (meta) => meta?.parserVersion === PARSER_VERSION
async function refresh({ force = false, approve = false, path: pathOverride = '' } = {}) {
// An explicit override wins outright — it is a one-off "use this tree", and it
// must not be silently overruled by the configured path the way an env default
// would be.
const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath()
if (root === '') return { status: 'skipped', reason: 'no ServUO path configured' }
let hashes
try {
hashes = hashSources(root)
} catch (err) {
if (err instanceof AtlasSourceError) {
return { status: 'unavailable', reason: err.message, code: err.code, path: root }
}
return { status: 'failed', reason: err.message, path: root }
}
const meta = await db.getMeta().catch(() => null)
const loaded = meta?.source
? Object.fromEntries(Object.entries(meta.source).map(([label, v]) => [label, v.sha256]))
: null
// Two things make a loaded atlas stale: the tree changed, or the PARSER did.
// Only checking the tree would strand an install whose maps never change on
// whatever an older build derived — a corrected parse would ship and never
// reach the data.
if (!force && sameSources(hashes, loaded) && currentParser(meta)) {
return { status: 'unchanged', path: root }
}
// A rejected refresh must not re-prompt on every boot. It stays rejected until
// the tree changes again, at which point the hashes differ and it is a new
// decision.
const pending = await db.getPending().catch(() => null)
if (!approve && !force && pending?.status === 'rejected' && sameSources(hashes, pending.hashes)) {
return { status: 'unchanged', path: root, reason: 'refresh previously rejected' }
}
let atlas
try {
atlas = buildAtlas(root)
} catch (err) {
return { status: 'failed', reason: err.message, path: root }
}
const currentFacets = await db.getFacets().catch(() => [])
const incomingFacets = atlas.facets
const removedFacets = currentFacets.filter((facet) => !incomingFacets.includes(facet))
const addedFacets = incomingFacets.filter((facet) => !currentFacets.includes(facet))
// Losing a facet is indistinguishable here from a half-copied tree, so it is
// staged rather than applied — but startup is never blocked by it.
if (removedFacets.length > 0 && !approve) {
const summary = {
hashes,
path: root,
currentFacets,
incomingFacets,
removedFacets,
addedFacets,
counts: atlas.meta.counts,
}
await db.setPending(summary, 'pending').catch((err) => {
log.warn('could not stage spawn atlas refresh', { error: err.message })
})
return { status: 'needsReview', ...summary }
}
try {
const counts = await applyAtlas(atlas)
return { status: 'imported', path: root, counts, addedFacets, removedFacets }
} catch (err) {
return { status: 'failed', reason: err.message, path: root }
}
}
/** Admin approved a staged refresh: apply it, facet loss and all. */
async function approvePending(options = {}) {
return refresh({ ...options, approve: true, force: true })
}
/**
* Admin rejected a staged refresh: keep the current atlas and remember the
* decision against those exact source hashes, so it does not re-prompt every
* boot. A further change to the tree produces different hashes and asks again.
*/
async function rejectPending() {
const pending = await db.getPending()
if (!pending) return { status: 'none' }
await db.setPending({ ...pending, rejectedAt: new Date().toISOString() }, 'rejected')
return { status: 'rejected' }
}
/** Everything the admin panel needs to describe atlas state. */
async function status({ path: pathOverride = '' } = {}) {
const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath()
const [meta, pending, facets] = await Promise.all([
db.getMeta().catch(() => null),
db.getPending().catch(() => null),
db.getFacets().catch(() => []),
])
let treeReadable = false
let drift = null
if (root !== '') {
try {
const hashes = hashSources(root)
treeReadable = true
const loaded = meta?.source
? Object.fromEntries(Object.entries(meta.source).map(([l, v]) => [l, v.sha256]))
: null
// Same question `refresh` asks: an import picks something up when either
// the tree or the parser has moved on.
drift = !sameSources(hashes, loaded) || !currentParser(meta)
} catch {
treeReadable = false
}
}
return {
configured: root !== '',
path: root,
treeReadable,
drift,
facets,
importedAt: meta?.importedAt ?? null,
counts: meta?.counts ?? null,
pending,
}
}
/**
* Boot hook. Best-effort by contract: it logs and returns, never throws, so a
* missing tree or a bad file can never stop the site coming up.
*/
async function refreshOnBoot() {
try {
const result = await refresh()
switch (result.status) {
case 'imported':
log.info('spawn atlas refreshed from ServUO tree', {
...result.counts,
added: result.addedFacets,
})
break
case 'needsReview':
log.warn(
'spawn atlas refresh staged for admin review — a facet would be removed; ' +
'the existing atlas is unchanged',
{ removed: result.removedFacets, added: result.addedFacets },
)
break
case 'unavailable':
log.warn('spawn atlas source unavailable', { reason: result.reason, path: result.path })
break
case 'failed':
log.warn('spawn atlas refresh failed', { reason: result.reason })
break
default:
break
}
return result
} catch (err) {
log.warn('spawn atlas refresh errored', { error: err.message })
return { status: 'failed', reason: err.message }
}
}
// ── Reads ──────────────────────────────────────────────────────────────────
//
// The shapes the /public/atlas endpoints serve. Rows are camelCased here rather
// than in the controller, for the same reason shardState does it: the column
// names are an implementation detail of the import, and the browser contract
// should not move when a column is renamed.
const jsonOr = (value, fallback) => {
if (value == null) return fallback
if (typeof value !== 'string') return value
try {
return JSON.parse(value)
} catch {
return fallback
}
}
const shapeCreature = (row) => ({
slug: row.slug,
name: row.name,
// `total` is the summed MaxCount across every spawner (how many can be alive
// at once); `points` is how many spawners mention it. They answer different
// questions and the UI shows both.
total: row.total,
points: row.points,
facets: jsonOr(row.facets, {}),
art: row.art || null,
})
const shapePlace = (row) => ({
facet: row.facet,
label: row.label,
spawners: Number(row.spawners) || 0,
maxAlive: Number(row.max_alive) || 0,
})
const shapePoint = (row) => ({
id: row.id,
facet: row.facet,
name: row.name || null,
x: row.x,
y: row.y,
width: row.width,
height: row.height,
range: row.spawn_range,
maxCount: row.max_count,
minDelay: row.min_delay,
maxDelay: row.max_delay,
todStart: row.tod_start,
todEnd: row.tod_end,
todMode: row.tod_mode,
region: row.region || null,
landmark: row.landmark || null,
label: row.label,
})
/**
* Paginated creature search. Returns the page plus the unpaginated total, so
* the UI can say "showing 50 of 800" without a second round trip.
*/
async function searchCreatures({ q = '', facet = '', limit = 50, offset = 0 } = {}) {
const [rows, total] = await Promise.all([
db.listCreatures({ q, facet, limit, offset }),
db.countCreatures({ q, facet }),
])
return { total, limit, offset, creatures: rows.map(shapeCreature) }
}
/**
* One creature: its totals, the places it spawns (the aggregate the atlas
* exists for), the individual spawners, and what else shares those spawners.
*
* `null` when the slug is unknown — the controller turns that into a 404.
*/
async function getCreature(slug, { facet = '', points = 200 } = {}) {
const row = await db.getCreature(slug)
if (!row) return null
const [places, pointRows, alsoHere] = await Promise.all([
db.listCreaturePlaces(slug, { facet }),
db.listCreaturePoints(slug, { facet, limit: points }),
db.listCreatureCompanions(slug),
])
return {
...shapeCreature(row),
places: places.map(shapePlace),
// `spawners`, not `points`: shapeCreature already uses `points` for the
// COUNT of spawners, and reusing the key for the list of them would make the
// same field a number on the search route and an array here.
spawners: pointRows.map(shapePoint),
// Bounded by the query, so a creature on hundreds of spawners returns a page
// rather than the world.
spawnersTruncated: pointRows.length >= points,
alsoHere: alsoHere.map((r) => ({
slug: r.slug,
name: r.name,
shared: Number(r.shared) || 0,
})),
}
}
async function listRegions(opts = {}) {
const rows = await db.listRegions(opts)
return rows.map((r) => ({
facet: r.facet,
name: r.name,
type: r.type || null,
priority: r.priority,
parent: r.parent || null,
rects: jsonOr(r.rects, []),
}))
}
async function listLandmarks(opts = {}) {
const rows = await db.listLandmarks(opts)
return rows.map((r) => ({
facet: r.facet,
name: r.name,
group: r.grp || null,
x: r.x,
y: r.y,
z: r.z,
}))
}
async function listChampions(opts = {}) {
const rows = await db.listChampions(opts)
return rows.map((r) => ({
slug: r.slug,
name: r.name,
group: r.grp || null,
// '' on the wire means "randomised at activation"; `randomType` says so
// explicitly rather than making the client infer it from an empty string.
type: r.type || null,
randomType: !!r.random_type,
facet: r.facet,
x: r.x,
y: r.y,
z: r.z,
radius: r.radius,
label: r.label || null,
}))
}
/**
* What is loaded: the facet list, the counts, and when it was imported.
*
* Deliberately does NOT report the source path, the per-file hashes or whether
* a refresh is pending. Those describe the operator's filesystem, and this is a
* public endpoint; the admin status route carries them instead.
*/
async function publicMeta() {
const [meta, facets] = await Promise.all([
db.getMeta().catch(() => null),
db.getFacets().catch(() => []),
])
return {
importedAt: meta?.importedAt ?? null,
generatedAt: meta?.generatedAt ?? null,
// The parse counts, not the row counts: `unresolvedPoints` is what lets the
// page state its own placement accuracy instead of implying it is complete.
counts: meta?.counts ?? null,
facets,
}
}
const listFacets = () => db.getFacets()
module.exports = {
refresh,
refreshOnBoot,
approvePending,
rejectPending,
status,
getServuoPath,
setServuoPath,
pointTypeRows,
loadArtMap,
SETTING_KEY,
searchCreatures,
getCreature,
listRegions,
listLandmarks,
listChampions,
listFacets,
publicMeta,
}

View File

@@ -0,0 +1,42 @@
const { query } = require('../../core')
const COLS = 'account, user_id, char_name, linked_at'
// Upsert a link. account is the PK, so a re-link moves the account to the new
// user (the sidecar already treats /link/confirm as authoritative).
async function upsert({ account, userId, charName }) {
await query(
`INSERT INTO shard_account_links (account, user_id, char_name)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), char_name = VALUES(char_name)`,
[account, userId, charName || null],
)
return getByAccount(account)
}
async function getByAccount(account) {
const rows = await query(`SELECT ${COLS} FROM shard_account_links WHERE account = ? LIMIT 1`, [account])
return rows[0] || null
}
const listByUser = (userId) =>
query(`SELECT ${COLS} FROM shard_account_links WHERE user_id = ? ORDER BY linked_at DESC`, [userId])
async function isOwnedBy(account, userId) {
const rows = await query(
'SELECT 1 FROM shard_account_links WHERE account = ? AND user_id = ? LIMIT 1',
[account, userId],
)
return rows.length > 0
}
const remove = (account, userId) =>
query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId])
// Drop the mirror for an account regardless of which user held it — used to
// reconcile when the tie is severed at the source (an in-game [unlink →
// account.unlinked event, or a site-side DELETE /link/{account}).
const removeByAccount = (account) =>
query('DELETE FROM shard_account_links WHERE account = ?', [account])
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }

View File

@@ -0,0 +1,37 @@
// Site-side mirror of in-game-account → website-user links. The sidecar owns the
// authoritative link (it tags the game account on /link/confirm); this model
// records it locally so the player portal can list links and enforce ownership.
const db = require('./shardLinks.db')
function toSafe(row) {
if (!row) return null
return {
account: row.account,
userId: row.user_id,
charName: row.char_name || null,
linkedAt: row.linked_at,
}
}
async function link({ account, userId, charName }) {
return toSafe(await db.upsert({ account, userId, charName }))
}
async function listForUser(userId) {
const rows = await db.listByUser(userId)
return rows.map(toSafe)
}
const ownsAccount = (account, userId) => db.isOwnedBy(account, userId)
async function getByAccount(account) {
return toSafe(await db.getByAccount(account))
}
const unlink = (account, userId) => db.remove(account, userId)
// Drop the local mirror for an account (source-of-truth severed elsewhere).
const removeByAccount = (account) => db.removeByAccount(account)
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount }

View File

@@ -0,0 +1,37 @@
const { query } = require('../../core')
// One row per shard feature. Absent rows are fine — utils/shardVisibility.js
// compiles a default for every known feature and merges stored rows over it, so
// a fresh install with an empty table behaves exactly as the site did pre-v3.
const COLS = 'feature, enabled, audience, stream, field_rules, updated_by, updated_at'
const listAll = () => query(`SELECT ${COLS} FROM shard_feature_visibility`)
const getOne = (feature) =>
query(`SELECT ${COLS} FROM shard_feature_visibility WHERE feature = ?`, [feature])
// Upsert one feature's settings. `fieldRules` is stored as a JSON object of
// {field: rung}; the caller has already stripped locked fields and validated
// every rung against the ladder.
const upsert = ({ feature, enabled, audience, stream, fieldRules, updatedBy }) =>
query(
`INSERT INTO shard_feature_visibility (feature, enabled, audience, stream, field_rules, updated_by)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
audience = VALUES(audience),
stream = VALUES(stream),
field_rules = VALUES(field_rules),
updated_by = VALUES(updated_by)`,
[
feature,
enabled ? 1 : 0,
audience,
stream ? 1 : 0,
fieldRules == null ? null : JSON.stringify(fieldRules),
updatedBy ?? null,
],
)
module.exports = { listAll, getOne, upsert }

View File

@@ -0,0 +1,44 @@
// ── Shard feature visibility (model) ───────────────────────────────────────
//
// Thin row-shaping layer over shardVisibility.db. The policy — the ladder, the
// feature catalog, the locked fields, the kind→feature map — lives in
// utils/shardVisibility.js; this file only reads and writes rows.
const db = require('./shardVisibility.db')
// The `field_rules` JSON column comes back as a string on the mariadb driver.
function parseRules(raw) {
if (raw == null) return {}
if (typeof raw === 'object') return raw
try {
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
const toSafe = (row) =>
row && {
feature: row.feature,
enabled: !!row.enabled,
audience: row.audience,
stream: row.stream == null ? null : !!row.stream,
fieldRules: parseRules(row.field_rules),
updatedBy: row.updated_by,
updatedAt: row.updated_at,
}
async function listAll() {
const rows = await db.listAll()
return rows.map(toSafe)
}
async function getOne(feature) {
const rows = await db.getOne(feature)
return toSafe(rows[0])
}
const upsert = (entry) => db.upsert(entry)
module.exports = { listAll, getOne, upsert }

View File

@@ -0,0 +1,134 @@
// ── Public: the spawn atlas ────────────────────────────────────────────────
//
// A browsable catalogue of what the shard CONTAINS — which creatures spawn,
// where, how many, and which champion altars are configured. Everything here is
// a plain indexed read of the tables the boot-time import fills from the shard's
// own ServUO tree (docs/website/SPAWN_ATLAS.md).
//
// Two properties separate this from /public/shard/*:
//
// • **Nothing touches the sidecar.** The atlas is static shard content, not
// live shard state, so these pages stay fully populated while the shard is
// down. That is why the routes are mounted at /public/atlas and are
// siteMode-gated like /posts and /wiki, rather than under /shard.
// • **The live champion feed is a different thing.** `/atlas/champions` is the
// configured roster ("there is an Unholy Terror altar in Deceit");
// `/shard/champs` is the running state ("it is on level 3 right now").
//
// Every response is still passed through `projectFeature` for the `atlas`
// feature. It declares no sensitive fields today, so the projection is a
// no-op — but v3.md §3.6.1's rule is that a read path returning shard data and
// not projecting is a bug, and the cost of honouring it is one call per handler
// rather than a retrofit the first time a field needs gating.
const atlas = require('../model/shardAtlas/shardAtlas.model')
const visibility = require('../utils/visibility')
const log = require('../core').logger('public-atlas')
const FEATURE = 'atlas'
// Query params arrive as strings; express-validator has already bounded them.
const int = (value, fallback) => {
const n = Number.parseInt(value, 10)
return Number.isFinite(n) ? n : fallback
}
const str = (value) => (typeof value === 'string' ? value.trim() : '')
// GET /public/atlas/creatures?q=&facet=&limit=&offset=
async function getCreatures(req, res) {
try {
const page = await atlas.searchCreatures({
q: str(req.query.q),
facet: str(req.query.facet),
limit: int(req.query.limit, 50),
offset: int(req.query.offset, 0),
})
return res.json(await visibility.project(FEATURE, page, req))
} catch (err) {
log.error('atlas.getCreatures', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/atlas/creatures/:slug — one creature, with the places it spawns.
//
// 404 means "no such creature in this atlas", which also covers "the atlas has
// never been imported" — an empty atlas has no slugs, and there is nothing more
// specific to say to an anonymous caller.
async function getCreature(req, res) {
try {
const creature = await atlas.getCreature(req.params.slug, {
facet: str(req.query.facet),
points: int(req.query.points, 200),
})
if (!creature) return res.status(404).json({ message: 'Not Found' })
return res.json(await visibility.project(FEATURE, creature, req))
} catch (err) {
log.error('atlas.getCreature', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/atlas/regions?facet=&q=
async function getRegions(req, res) {
try {
const regions = await atlas.listRegions({
facet: str(req.query.facet),
q: str(req.query.q),
})
return res.json(await visibility.project(FEATURE, regions, req))
} catch (err) {
log.error('atlas.getRegions', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/atlas/landmarks?facet=&q=
async function getLandmarks(req, res) {
try {
const landmarks = await atlas.listLandmarks({
facet: str(req.query.facet),
q: str(req.query.q),
})
return res.json(await visibility.project(FEATURE, landmarks, req))
} catch (err) {
log.error('atlas.getLandmarks', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/atlas/champions?facet= — the CONFIGURED altar roster.
async function getChampions(req, res) {
try {
const champions = await atlas.listChampions({ facet: str(req.query.facet) })
return res.json(await visibility.project(FEATURE, champions, req))
} catch (err) {
log.error('atlas.getChampions', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/atlas/meta — what is loaded: facets, counts, when it was imported.
//
// Public-safe by construction: the model omits the ServUO path, the per-file
// hashes and the pending-refresh state, all of which describe the operator's
// filesystem rather than the game world. The admin status route carries those.
async function getMeta(req, res) {
try {
return res.json(await visibility.project(FEATURE, await atlas.publicMeta(), req))
} catch (err) {
log.error('atlas.getMeta', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = {
getCreatures,
getCreature,
getRegions,
getLandmarks,
getChampions,
getMeta,
}

View File

@@ -0,0 +1,132 @@
// Public · Atlas — the spawn atlas / bestiary. Static shard CONTENT derived from
// the shard's own ServUO tree, not live shard state.
//
// Mounted at /api/v1/public/atlas by public/index.js. Two deliberate differences
// from the /public/shard routes next door (docs/link/v3.md §6):
//
// • **Not under /shard.** Nothing here round-trips the sidecar, and the pages
// stay fully populated while the shard is down. Mounting it under /shard
// would imply a dependency it does not have.
// • **siteMode-gated, like /posts and /wiki.** The shard routes are exempt
// because shard status is wanted *during* maintenance; a bestiary is site
// content and follows site content's rules.
//
// Every route also carries `requireFeature('atlas')` — 404 when an admin has
// disabled the feature, 403 when the caller sits below its configured audience.
// The default audience is `anonymous`, so these gates are inert until an admin
// changes something.
// express and express-validator come from core, never from a require here: this
// file lives outside server/, so Node's resolver would not find them, and a
// second express in the process would be a second Router prototype
// (docs/website/MODULE_API.md §2.3).
const core = require('../core')
const atlas = require('./atlas.controller')
const { requireFeature } = require('../utils/visibility')
const { express, validator, middleware } = core
const { param, query } = validator
const { siteMode, validate } = middleware
const atlasRouter = express.Router()
// Facet names come from the shard's own files and are never validated against a
// list — nothing in the codebase names a facet (§6.1 R2). Only the length is
// bounded, and the query matches exactly, so an unknown name returns an empty
// result rather than an error.
const facetParam = query('facet').optional({ values: 'falsy' }).isString().isLength({ max: 40 })
atlasRouter.get(
'/creatures',
requireFeature('atlas'),
// #swagger.tags = ['Public · Atlas']
// #swagger.summary = 'Search the bestiary (paginated)'
// #swagger.description = 'Every creature the shard spawns, most numerous first. `total` is how many can be alive at once across all spawners; `points` is how many spawners mention it; `facets` maps facet name to that creature\'s share on it. Static content parsed from the shard\'s ServUO tree — unaffected by the shard being offline.'
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the creature name (max 60 chars).' }
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to creatures spawning on this facet. Facet names come from the shard\'s own files; an unknown one returns an empty page.' }
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' }
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
/* #swagger.responses[200] = { description: 'A page of creatures plus the unpaginated total', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreaturePage" } } } } */
/* #swagger.responses[403] = { description: 'The atlas feature is gated above this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'The atlas feature is disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
facetParam,
query('limit').optional().isInt({ min: 1, max: 100 }),
query('offset').optional().isInt({ min: 0, max: 100000 }),
validate,
siteMode,
atlas.getCreatures,
)
atlasRouter.get(
'/creatures/:slug',
requireFeature('atlas'),
// #swagger.tags = ['Public · Atlas']
// #swagger.summary = 'One creature: where it spawns, and what spawns with it'
// #swagger.description = 'The answer the atlas exists to give. `places` is the aggregate — "lizardman → Shrines, Isamu-Jima, Yew" — resolved by point-in-rect against the shard\'s own region rectangles, falling back to the nearest landmark, else "Wilderness". `spawners` lists the individual spawn points (bounded; `spawnersTruncated` says when the list was cut), and `alsoHere` is what shares those spawners.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Creature slug, e.g. lizardman.' }
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Restrict places and spawners to one facet.' }
// #swagger.parameters['points'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max spawners to return, 1..1000 (default 200).' }
/* #swagger.responses[200] = { description: 'The creature', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreature" } } } } */
/* #swagger.responses[404] = { description: 'No such creature in this atlas (or the feature is disabled)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('slug').isString().isLength({ min: 1, max: 120 }),
facetParam,
query('points').optional().isInt({ min: 1, max: 1000 }),
validate,
siteMode,
atlas.getCreature,
)
atlasRouter.get(
'/regions',
requireFeature('atlas'),
// #swagger.tags = ['Public · Atlas']
// #swagger.summary = 'Named regions and their rectangles'
// #swagger.description = 'Flattened out of the shard\'s nested Regions.xml. `priority` and the rectangles are what placed each spawn point, kept so the placement can be re-derived rather than taken on trust.'
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the region name.' }
/* #swagger.responses[200] = { description: 'Regions, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasRegion" } } } } } */
facetParam,
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
validate,
siteMode,
atlas.getRegions,
)
atlasRouter.get(
'/landmarks',
requireFeature('atlas'),
// #swagger.tags = ['Public · Atlas']
// #swagger.summary = 'Points of interest (dungeon levels, town markers)'
// #swagger.description = 'From the shard\'s Data/Locations files. `group` is the innermost enclosing parent ("Covetous"), which is the label worth showing over the individual marker ("Level 1").'
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the landmark name or its group.' }
/* #swagger.responses[200] = { description: 'Landmarks, by facet then group', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasLandmark" } } } } } */
facetParam,
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
validate,
siteMode,
atlas.getLandmarks,
)
atlasRouter.get(
'/champions',
requireFeature('atlas'),
// #swagger.tags = ['Public · Atlas']
// #swagger.summary = 'Configured champion altars (the roster, not the live board)'
// #swagger.description = 'Where the altars are and what each one summons — "there is an Unholy Terror altar in Deceit". `randomType` marks altars whose champion is drawn at activation. Do not conflate this with GET /public/shard/champs, which is the live sidecar-fed board ("it is on level 3 right now").'
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
/* #swagger.responses[200] = { description: 'Altars, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasChampion" } } } } } */
facetParam,
validate,
siteMode,
atlas.getChampions,
)
atlasRouter.get(
'/meta',
requireFeature('atlas'),
// #swagger.tags = ['Public · Atlas']
// #swagger.summary = 'What atlas is loaded: facets, counts, when it was imported'
// #swagger.description = 'Drives the facet filter and the "parsed from the shard\'s own files on <date>" line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.'
/* #swagger.responses[200] = { description: 'Atlas metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasMeta" } } } } */
siteMode,
atlas.getMeta,
)
module.exports = atlasRouter

View File

@@ -0,0 +1,60 @@
// ── Test harness: a fake ctx ───────────────────────────────────────────────
//
// A module's tests cannot require core — that is the whole zero-internal-imports
// rule (docs/website/MODULE_API.md §5.1), and it applies to test files too. So
// instead of stubbing core's modules the way core's own tests do, a module test
// hands `core.init()` a ctx it fabricated.
//
// That turns out to be the nicer story: the seam that exists so a module can be
// swapped onto a different core is the same seam that lets its tests run with no
// database, no express app and no settings table. Core's tests reach the same
// place by pointing the mariadb pool at a dead port; a module does not have to.
const core = require('../core')
/**
* Build and install a fake ctx. Every member is a stub the test can reassign.
* @param {object} [over] members to override, deep-merged one level
*/
function installFakeCtx(over = {}) {
const settings = new Map()
const ctx = {
moduleId: 'uo',
paths: { moduleRoot: require('path').join(__dirname, '..', '..') },
// Null, not the real packages: a module cannot resolve express from outside
// server/ (that is why ctx carries them at all), and these tests construct no
// router. A test that needs one passes the real ones in `over`.
express: null,
validator: null,
db: {
// Every test that needs a query result reassigns this.
query: async () => [],
pool: { getConnection: async () => { throw new Error('no pool in tests') } },
},
log: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
settings: {
get: async (key) => (settings.has(key) ? settings.get(key) : null),
set: async (key, value) => { settings.set(key, value) },
getInstanceName: async () => 'Test Shard',
},
auth: { getUserFromRequest: () => null },
push: { publish: async () => {} },
secretBox: { encrypt: (s) => s, decrypt: (s) => s },
middleware: {
requireAuth: (req, res, next) => next(),
requireRole: () => (req, res, next) => next(),
siteMode: (req, res, next) => next(),
validate: (req, res, next) => next(),
noindex: (req, res, next) => next(),
},
uploads: {},
posts: {},
...over,
}
core.init(ctx)
return ctx
}
module.exports = { installFakeCtx }

View File

@@ -0,0 +1,601 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const {
parseXml,
parseObjects2,
parsePoints,
parseRegions,
parseLocations,
parseChampions,
buildPlacementIndex,
resolveRegion,
facetKey,
buildFacetIndex,
resolveFacetName,
slugify,
decodeEntities,
} = require('../utils/spawnAtlasParse')
// These parsers are pure and fs-free precisely so this suite can run in CI,
// where there is no ServUO tree. Every fixture below is a literal excerpt of a
// real shard file, trimmed — not invented shapes.
// ── parseObjects2 ──────────────────────────────────────────────────────────
test('parseObjects2: single type', () => {
const types = parseObjects2('Jacob:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1')
assert.deepEqual(types, [{ type: 'Jacob', max: 1 }])
})
test('parseObjects2: splits six types on :OBJ= and keeps each MX', () => {
// Verbatim from trammel.xml — the case that a naive split(':') destroys.
const raw =
'Gazer:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
':OBJ=Giantspider:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
':OBJ=Harpy:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
':OBJ=Headlessone:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
':OBJ=Lizardman:MX=3:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
':OBJ=Mongbat:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1'
const types = parseObjects2(raw)
assert.equal(types.length, 6)
assert.deepEqual(
types.map((t) => t.type),
['Gazer', 'Giantspider', 'Harpy', 'Headlessone', 'Lizardman', 'Mongbat'],
)
// MX is per type, not per spawner: the lizardman entry carries 3.
assert.equal(types.find((t) => t.type === 'Lizardman').max, 3)
assert.equal(types.find((t) => t.type === 'Gazer').max, 1)
})
test('parseObjects2: empty and whitespace values yield no types', () => {
assert.deepEqual(parseObjects2(''), [])
assert.deepEqual(parseObjects2(' '), [])
assert.deepEqual(parseObjects2(null), [])
assert.deepEqual(parseObjects2(undefined), [])
})
test('parseObjects2: strips XmlSpawner property directives after "/"', () => {
// Left in place these become creatures that do not exist.
assert.deepEqual(parseObjects2('Agralem/Name/Agralem:MX=1'), [{ type: 'Agralem', max: 1 }])
assert.deepEqual(parseObjects2('alchemist/z/-50:MX=1'), [{ type: 'alchemist', max: 1 }])
assert.deepEqual(parseObjects2('GargishRefugee/hue/34532'), [
{ type: 'GargishRefugee', max: 1 },
])
})
test('parseObjects2: strips argument lists after ","', () => {
assert.deepEqual(parseObjects2('Fairy,{RND,4,8}:MX=1'), [{ type: 'Fairy', max: 1 }])
assert.deepEqual(parseObjects2('GargishRouser,1'), [{ type: 'GargishRouser', max: 1 }])
assert.deepEqual(parseObjects2('greatape,true'), [{ type: 'greatape', max: 1 }])
})
test('parseObjects2: a directive-laden token slugs the same as the bare one', () => {
// The bug this closes: `Fairy` and `Fairy,{RND,4,8}` slugged apart and showed
// as two different creatures on the same page.
const bare = parseObjects2('Fairy:MX=1')[0]
const decorated = parseObjects2('Fairy,{RND,4,8}:MX=1')[0]
assert.equal(slugify(decorated.type), slugify(bare.type))
})
test('parseObjects2: strips a long EQUIP directive chain containing "<" and ">"', () => {
const raw =
'xmlquestnpc/UNEQUIP,Innertorso/UNEQUIP,MiddleTorso/EQUIP/<robe/loottype/blessed' +
'/itemid/8259>/blessed/true/name/lord blackthorne/z/:MX=1'
assert.deepEqual(parseObjects2(raw), [{ type: 'xmlquestnpc', max: 1 }])
})
test('parseObjects2: a token that is only a directive yields nothing', () => {
assert.deepEqual(parseObjects2('/Name/Foo:MX=1'), [])
assert.deepEqual(parseObjects2(',1:MX=1'), [])
})
test('parseObjects2: a type with no MX token defaults to 1', () => {
assert.deepEqual(parseObjects2('Orc'), [{ type: 'Orc', max: 1 }])
assert.deepEqual(parseObjects2('Orc:SB=0:RT=0'), [{ type: 'Orc', max: 1 }])
})
// ── parsePoints ────────────────────────────────────────────────────────────
const POINTS_XML = `<Spawns>
<Points>
<Name>CovetousSpawner26</Name>
<UniqueId>001a34e5-0efa-46de-9c93-b6a163d96370</UniqueId>
<Map>Trammel</Map>
<X>5412</X>
<Y>1970</Y>
<Width>10</Width>
<Height>10</Height>
<Range>5</Range>
<MaxCount>3</MaxCount>
<MinDelay>5</MinDelay>
<MaxDelay>10</MaxDelay>
<ProximityTriggerSound>500</ProximityTriggerSound>
<TODStart>0</TODStart>
<TODEnd>0</TODEnd>
<TODMode>0</TODMode>
<IsRunning>True</IsRunning>
<Objects2>Lizardman:MX=3:SB=0</Objects2>
</Points>
<Points>
<Name>Disabled</Name>
<Map>Felucca</Map>
<X>100</X>
<Y>200</Y>
<MaxCount>1</MaxCount>
<IsRunning>False</IsRunning>
<Objects2>Orc:MX=1</Objects2>
</Points>
</Spawns>`
test('parsePoints: reads the kept fields and drops the rest', () => {
const points = parsePoints(POINTS_XML)
assert.equal(points.length, 2)
const covetous = points[0]
assert.equal(covetous.name, 'CovetousSpawner26')
assert.equal(covetous.facet, 'Trammel')
assert.equal(covetous.x, 5412)
assert.equal(covetous.y, 1970)
assert.equal(covetous.width, 10)
assert.equal(covetous.range, 5)
assert.equal(covetous.maxCount, 3)
// Delays are normalised to seconds; this record carries no DelayInSec, which
// means minutes.
assert.equal(covetous.minDelay, 300)
assert.equal(covetous.maxDelay, 600)
assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }])
// Dropped fields must not survive into the artifact — this is what keeps it
// under 1 MB.
assert.equal(covetous.uniqueId, undefined)
assert.equal(covetous.proximityTriggerSound, undefined)
})
test('parsePoints: IsRunning is parsed so the build can drop dead spawners', () => {
const points = parsePoints(POINTS_XML)
assert.equal(points[0].running, true)
assert.equal(points[1].running, false)
})
test('parsePoints: facet comes from <Map>, never the file name', () => {
// Eodon.xml holds TerMur points; a file-name assumption would mislabel every
// one of them.
const points = parsePoints(
'<Spawns><Points><Name>a</Name><Map>TerMur</Map><X>1</X><Y>2</Y></Points></Spawns>',
)
assert.equal(points[0].facet, 'TerMur')
})
test('parsePoints: a record with no <Map> is skipped rather than misfiled', () => {
const points = parsePoints('<Spawns><Points><Name>a</Name><X>1</X><Y>2</Y></Points></Spawns>')
assert.deepEqual(points, [])
})
test('parsePoints: empty document yields no points', () => {
assert.deepEqual(parsePoints('<Spawns></Spawns>'), [])
assert.deepEqual(parsePoints(''), [])
})
// ── parseRegions ───────────────────────────────────────────────────────────
const REGIONS_XML = `<?xml version="1.0" encoding="utf-8"?>
<ServerRegions>
<Facet name="Felucca">
<region type="GuardedRegion" priority="50" name="Moongates">
<!-- britain -->
<rect x="1330" y="1991" width="13" height="13" />
<rect x="761" y="741" width="19" height="21" />
</region>
<region type="MondainRegion" priority="50" name="Prism of Light">
<rect x="6400" y="0" width="221" height="255" />
<go x="6474" y="188" z="0" />
<music name="Dungeon9" />
<region type="CrystalField" name="Crystal Field">
<rect x="6506" y="83" width="7" height="7" />
<zrange min="-4" />
</region>
<region type="IcyRiver">
<rect x="6576" y="73" width="10" height="31" />
</region>
</region>
<region type="TownRegion" priority="10" name="Music Only">
<music name="Britain" />
</region>
</Facet>
</ServerRegions>`
test('parseRegions: flattens nested regions and collects rects', () => {
const regions = parseRegions(REGIONS_XML)
const byName = new Map(regions.map((r) => [r.name, r]))
assert.ok(byName.has('Moongates'))
assert.ok(byName.has('Prism of Light'))
assert.equal(byName.get('Moongates').rects.length, 2)
assert.deepEqual(byName.get('Moongates').rects[0], {
x: 1330,
y: 1991,
width: 13,
height: 13,
})
assert.equal(byName.get('Moongates').facet, 'Felucca')
assert.equal(byName.get('Moongates').type, 'GuardedRegion')
})
test('parseRegions: a nested child records its parent', () => {
const regions = parseRegions(REGIONS_XML)
const crystal = regions.find((r) => r.name === 'Crystal Field')
assert.ok(crystal, 'the nested named region should be indexed')
assert.equal(crystal.parent, 'Prism of Light')
assert.equal(crystal.facet, 'Felucca')
})
test('parseRegions: a child with no priority inherits its parent', () => {
// Defaulting to 0 instead would sort this specific room below every
// top-level region that contains it.
const crystal = parseRegions(REGIONS_XML).find((r) => r.name === 'Crystal Field')
assert.equal(crystal.priority, 50)
})
test('parseRegions: unnamed regions are skipped but still walked', () => {
const regions = parseRegions(REGIONS_XML)
// IcyRiver has a type but no name — it cannot label anything.
assert.equal(regions.some((r) => r.type === 'IcyRiver'), false)
})
test('parseRegions: a named region with no rects is not indexed', () => {
// It can never contain a point, so indexing it only costs scan time.
assert.equal(parseRegions(REGIONS_XML).some((r) => r.name === 'Music Only'), false)
})
// ── parseLocations ─────────────────────────────────────────────────────────
const LOCATIONS_XML = `<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<places>
<parent name="Trammel">
<parent name="Dungeons">
<parent name="Covetous">
<child name="Entrance" x="2499" y="919" z="0" />
<child name="Level 1" x="5456" y="1863" z="0" />
</parent>
<parent name="Despise">
<child name="Level 3" x="5407" y="859" z="45" />
</parent>
</parent>
</parent>
</places>`
test('parseLocations: flattens to points carrying their group', () => {
const landmarks = parseLocations(LOCATIONS_XML)
assert.equal(landmarks.length, 3)
const level1 = landmarks.find((l) => l.name === 'Level 1')
assert.equal(level1.x, 5456)
assert.equal(level1.y, 1863)
assert.equal(level1.z, 0)
assert.equal(level1.facet, 'Trammel')
// "Covetous" is the useful label, not "Level 1".
assert.equal(level1.group, 'Covetous')
// The facet-level parent is dropped from the path.
assert.deepEqual(level1.path, ['Dungeons', 'Covetous'])
})
// ── Facet canonicalisation ─────────────────────────────────────────────────
// Facets are NOT a fixed list — a shard may add, replace or rename them when its
// maps are updated, so nothing may hardcode the stock six. Reconciliation is by
// matching against whatever the shard's own files declare.
test('facetKey: collapses spelling differences to one key', () => {
assert.equal(facetKey('Ter Mur'), facetKey('TerMur'))
assert.equal(facetKey('ter-mur'), facetKey('TerMur'))
assert.equal(facetKey('Felucca'), 'felucca')
assert.equal(facetKey(''), '')
assert.equal(facetKey(null), '')
})
test('facetKey: distinct facets keep distinct keys', () => {
assert.notEqual(facetKey('Felucca'), facetKey('Trammel'))
})
test('resolveFacetName: matches a loose spelling to the discovered canonical', () => {
// The canonical set comes from the shard's own spawn/region data, not a table.
const index = buildFacetIndex(['TerMur', 'Tokuno', 'Felucca'])
assert.equal(resolveFacetName('Ter Mur', index), 'TerMur')
assert.equal(resolveFacetName('Tokuno Islands', index), 'Tokuno')
assert.equal(resolveFacetName('felucca', index), 'Felucca')
})
test('resolveFacetName: works for facets that do not exist in stock UO', () => {
// The whole point: a shard running its own maps gets the same treatment as
// the stock ones, with no entry anywhere naming them.
const index = buildFacetIndex(['Sosaria', 'The Underdark'])
assert.equal(resolveFacetName('sosaria', index), 'Sosaria')
assert.equal(resolveFacetName('The Underdark', index), 'The Underdark')
assert.equal(resolveFacetName('the-underdark', index), 'The Underdark')
// Same shape as the real `Tokuno Islands` → `Tokuno` case.
assert.equal(resolveFacetName('Sosaria Isles', index), 'Sosaria')
})
test('resolveFacetName: a merely similar name is NOT forced to match', () => {
// "Underdark Isles" is not a prefix of "The Underdark" in either direction.
// Keeping its own name is right — a wrong match would silently file a real
// custom facet's landmarks under the wrong facet.
const index = buildFacetIndex(['The Underdark'])
assert.equal(resolveFacetName('Underdark Isles', index), 'Underdark Isles')
})
test('resolveFacetName: prefers the longer match when several could prefix', () => {
const index = buildFacetIndex(['Tokuno', 'TokunoDeep'])
assert.equal(resolveFacetName('TokunoDeep Reaches', index), 'TokunoDeep')
})
test('resolveFacetName: an unmatched facet keeps its own name', () => {
// Inventing a match would be worse than leaving a real custom facet alone.
const index = buildFacetIndex(['Felucca'])
assert.equal(resolveFacetName('Ilshenar', index), 'Ilshenar')
assert.equal(resolveFacetName('', index), '')
assert.equal(resolveFacetName(null, index), '')
})
test('buildFacetIndex: first spelling wins and is stable', () => {
const index = buildFacetIndex(['TerMur', 'Ter Mur', 'ter-mur'])
assert.equal(index.size, 1)
assert.equal(resolveFacetName('Ter Mur', index), 'TerMur')
})
test('parsePoints and parseRegions report facet names verbatim', () => {
// <Map> and <Facet name> are the authority; they are never rewritten.
const points = parsePoints(
'<Spawns><Points><Name>a</Name><Map>Sosaria</Map><X>1</X><Y>2</Y></Points></Spawns>',
)
assert.equal(points[0].facet, 'Sosaria')
const regions = parseRegions(
'<ServerRegions><Facet name="Sosaria"><region name="Town" priority="1">' +
'<rect x="0" y="0" width="10" height="10"/></region></Facet></ServerRegions>',
)
assert.equal(regions[0].facet, 'Sosaria')
})
test('placement index buckets two spellings of one facet together', () => {
// This is the bug the key exists to prevent: unreconciled, the landmark bucket
// is keyed apart from the points looking it up, the fallback never fires, and
// every unregioned spawn on that facet silently reads "Wilderness".
const index = buildPlacementIndex(
[],
[{ facet: 'Ter Mur', name: 'Bank', group: 'Holy City', path: [], x: 1000, y: 1000, z: 0 }],
)
assert.equal(resolveRegion(1000, 1000, 'TerMur', index).landmark, 'Holy City')
})
// ── parseChampions ─────────────────────────────────────────────────────────
const CHAMPIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
<championSystem>
<!-- comment describing the schema -->
<spawn name="Deceit" group="FelDungeons" type="UnholyTerror">
<location x="5178" y="708" z="20" map="Felucca" radius="60" />
</spawn>
<spawn name="Wandering" group="FelDungeons">
<location x="100" y="200" z="0" map="Felucca" radius="40" />
</spawn>
</championSystem>`
test('parseChampions: reads altar name, type and location', () => {
const champs = parseChampions(CHAMPIONS_XML)
assert.equal(champs.length, 2)
assert.deepEqual(champs[0], {
name: 'Deceit',
group: 'FelDungeons',
type: 'UnholyTerror',
randomType: false,
facet: 'Felucca',
x: 5178,
y: 708,
z: 20,
radius: 60,
})
})
test('parseChampions: a spawn with no type is flagged random, not blank', () => {
const champs = parseChampions(CHAMPIONS_XML)
assert.equal(champs[1].randomType, true)
assert.equal(champs[1].type, '')
})
// ── resolveRegion ──────────────────────────────────────────────────────────
function fixtureIndex() {
const regions = [
{
facet: 'Felucca',
name: 'Britain',
type: 'TownRegion',
priority: 10,
parent: null,
rects: [{ x: 1000, y: 1000, width: 500, height: 500 }],
},
{
facet: 'Felucca',
name: 'Britain Bank',
type: 'TownRegion',
priority: 50,
parent: 'Britain',
rects: [{ x: 1400, y: 1400, width: 20, height: 20 }],
},
{
facet: 'Felucca',
name: 'Wide Low Priority',
type: 'TownRegion',
priority: 10,
parent: null,
rects: [{ x: 1000, y: 1000, width: 2000, height: 2000 }],
},
]
const landmarks = [
{ facet: 'Felucca', name: 'Level 1', group: 'Covetous', path: [], x: 5000, y: 5000, z: 0 },
{ facet: 'Felucca', name: 'Far Away', group: 'Vesper', path: [], x: 9000, y: 9000, z: 0 },
]
return buildPlacementIndex(regions, landmarks)
}
test('resolveRegion: a contained point takes the region name', () => {
const result = resolveRegion(1100, 1100, 'Felucca', fixtureIndex())
assert.equal(result.region, 'Britain')
assert.equal(result.label, 'Britain')
assert.equal(result.landmark, null)
})
test('resolveRegion: higher priority wins over a containing region', () => {
const result = resolveRegion(1410, 1410, 'Felucca', fixtureIndex())
assert.equal(result.region, 'Britain Bank')
})
test('resolveRegion: equal priority breaks toward the smaller rect', () => {
// Both "Britain" (500x500) and "Wide Low Priority" (2000x2000) contain this
// point at priority 10; the specific one must win.
const result = resolveRegion(1200, 1200, 'Felucca', fixtureIndex())
assert.equal(result.region, 'Britain')
})
test('resolveRegion: rects are half-open — the far edge is outside', () => {
const index = fixtureIndex()
// Britain spans x 1000..1499. 1499 is in, 1500 belongs to the next region.
assert.equal(resolveRegion(1499, 1499, 'Felucca', index).region, 'Britain')
assert.equal(resolveRegion(1500, 1500, 'Felucca', index).region, 'Wide Low Priority')
})
test('resolveRegion: falls back to the nearest landmark group', () => {
const result = resolveRegion(5050, 5050, 'Felucca', fixtureIndex())
assert.equal(result.region, null)
assert.equal(result.landmark, 'Covetous')
assert.equal(result.label, 'Covetous')
})
test('resolveRegion: a landmark beyond the radius yields Wilderness', () => {
// Without the radius cap the nearest landmark is always *some* landmark, and
// open countryside would get labelled with a dungeon across the map.
const result = resolveRegion(7000, 7000, 'Felucca', fixtureIndex())
assert.equal(result.landmark, null)
assert.equal(result.label, 'Wilderness')
})
test('resolveRegion: the radius is configurable', () => {
const wide = resolveRegion(7000, 7000, 'Felucca', fixtureIndex(), { landmarkRadius: 5000 })
assert.equal(wide.label, 'Covetous')
})
test('resolveRegion: an unknown facet degrades to Wilderness, not a throw', () => {
const result = resolveRegion(1100, 1100, 'Malas', fixtureIndex())
assert.equal(result.label, 'Wilderness')
assert.equal(result.region, null)
})
test('resolveRegion: does not leak across facets', () => {
const index = buildPlacementIndex(
[
{
facet: 'Trammel',
name: 'Britain',
type: 'TownRegion',
priority: 10,
parent: null,
rects: [{ x: 1000, y: 1000, width: 500, height: 500 }],
},
],
[],
)
assert.equal(resolveRegion(1100, 1100, 'Trammel', index).region, 'Britain')
assert.equal(resolveRegion(1100, 1100, 'Felucca', index).region, null)
})
// ── Tokenizer edge cases ───────────────────────────────────────────────────
test('parseXml: skips comments, declarations and DOCTYPE', () => {
const root = parseXml(
'<?xml version="1.0"?><!DOCTYPE r><r><!-- <fake a="b"/> --><a x="1"/></r>',
)
assert.equal(root.name, 'r')
assert.equal(root.children.length, 1)
assert.equal(root.children[0].name, 'a')
assert.equal(root.children[0].attrs.x, '1')
})
test('parseXml: a ">" inside an attribute value does not end the tag', () => {
const root = parseXml('<r><a name="1 > 0" b="2"/></r>')
assert.equal(root.children[0].attrs.name, '1 > 0')
assert.equal(root.children[0].attrs.b, '2')
})
test('parseXml: single-quoted attributes are read', () => {
const root = parseXml("<r><a name='Mondain' /></r>")
assert.equal(root.children[0].attrs.name, 'Mondain')
})
test('parseXml: a stray closing tag is ignored, not fatal', () => {
// Hand-maintained shard config: one malformed element should degrade to a
// missing element, not abort an otherwise good build.
const root = parseXml('<r><a/></b><c/></r>')
assert.equal(root.name, 'r')
assert.deepEqual(root.children.map((n) => n.name), ['a', 'c'])
})
test('parseXml: empty or element-free input yields null', () => {
assert.equal(parseXml(''), null)
assert.equal(parseXml('<!-- only a comment -->'), null)
})
test('decodeEntities: named, numeric and hex refs', () => {
assert.equal(decodeEntities("Mondain&apos;s Legacy"), "Mondain's Legacy")
assert.equal(decodeEntities('a &amp; b'), 'a & b')
assert.equal(decodeEntities('&lt;tag&gt;'), '<tag>')
assert.equal(decodeEntities('&#65;&#x42;'), 'AB')
// An unknown entity is left alone rather than silently eaten.
assert.equal(decodeEntities('&nosuch;'), '&nosuch;')
})
test('parseXml: decodes entities in attribute values', () => {
const root = parseXml('<r><region name="Mondain&apos;s Legacy" /></r>')
assert.equal(root.children[0].attrs.name, "Mondain's Legacy")
})
// ── slugify ────────────────────────────────────────────────────────────────
test('slugify: produces URL-safe keys', () => {
assert.equal(slugify('Lizardman'), 'lizardman')
assert.equal(slugify('Giant Spider'), 'giant-spider')
assert.equal(slugify("Mondain's Legacy"), 'mondain-s-legacy')
assert.equal(slugify(' Orc '), 'orc')
})
// ── Respawn delays: the unit is per record ──────────────────────────────────
// XmlSpawner writes minutes by default and switches to seconds only when a
// delay does not divide into whole minutes, flagged by DelayInSec. `5` therefore
// means five MINUTES on one spawner and five SECONDS on the next, and a reader
// assuming either unit is wrong about the other — silently, since both are
// plausible respawn times.
const DELAY_XML = `<Spawns>
<Points>
<Name>Minutes</Name>
<Map>Sosaria</Map>
<X>1</X><Y>1</Y>
<MinDelay>5</MinDelay>
<MaxDelay>10</MaxDelay>
<IsRunning>True</IsRunning>
<Objects2>Orc:MX=1</Objects2>
</Points>
<Points>
<Name>Seconds</Name>
<Map>Sosaria</Map>
<X>2</X><Y>2</Y>
<DelayInSec>True</DelayInSec>
<MinDelay>5</MinDelay>
<MaxDelay>10</MaxDelay>
<IsRunning>True</IsRunning>
<Objects2>Orc:MX=1</Objects2>
</Points>
</Spawns>`
test('parsePoints: DelayInSec decides the unit, and both come out in seconds', () => {
const [minutes, seconds] = parsePoints(DELAY_XML)
assert.equal(minutes.minDelay, 300)
assert.equal(minutes.maxDelay, 600)
assert.equal(seconds.minDelay, 5)
assert.equal(seconds.maxDelay, 10)
})

View File

@@ -0,0 +1,399 @@
// No dead-port pool trick here: a module test fabricates its ctx instead, so
// there is no database to point anywhere (see test/_ctx.js).
const fs = require('fs')
const os = require('os')
const path = require('path')
const { test, after, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const { installFakeCtx } = require('./_ctx')
installFakeCtx()
const {
AtlasSourceError,
aggregateCreatures,
displayName,
sameSources,
hashSources,
buildAtlas,
PARSER_VERSION,
} = require('../utils/spawnAtlasSource')
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
const atlasDb = require('../model/shardAtlas/shardAtlas.db')
// The model captured this object at require time, so reassigning a method on it
// is how a test stubs core — the module equivalent of core's own tests
// monkey-patching a model.
const { settings } = require('../core')
// ── A tiny synthetic ServUO tree ───────────────────────────────────────────
//
// Deliberately uses facets that do NOT exist in stock UO. The atlas must not
// contain a built-in facet list anywhere: a shard may add facets, replace them
// outright, or rename them when its maps are updated, and everything has to keep
// working with no code change.
function writeTree(root, { facets = ['Sosaria'], includeChampions = true } = {}) {
fs.mkdirSync(path.join(root, 'Spawns'), { recursive: true })
fs.mkdirSync(path.join(root, 'Data', 'Locations'), { recursive: true })
fs.mkdirSync(path.join(root, 'Config'), { recursive: true })
for (const facet of facets) {
fs.writeFileSync(
path.join(root, 'Spawns', `${facet}.xml`),
`<Spawns>
<Points><Name>${facet}A</Name><Map>${facet}</Map><X>1100</X><Y>1100</Y>
<MaxCount>3</MaxCount><IsRunning>True</IsRunning>
<Objects2>Lizardman:MX=3:SB=0:OBJ=Orc:MX=1:SB=0</Objects2></Points>
<Points><Name>${facet}B</Name><Map>${facet}</Map><X>9000</X><Y>9000</Y>
<MaxCount>1</MaxCount><IsRunning>True</IsRunning>
<Objects2>lizardman:MX=2:SB=0</Objects2></Points>
<Points><Name>${facet}Off</Name><Map>${facet}</Map><X>1</X><Y>1</Y>
<MaxCount>1</MaxCount><IsRunning>False</IsRunning>
<Objects2>Ghost:MX=1</Objects2></Points>
</Spawns>`,
'utf8',
)
// The location file names its facet differently from <Map>, the real
// `Ter Mur` / `Tokuno Islands` drift.
fs.writeFileSync(
path.join(root, 'Data', 'Locations', `${facet.toLowerCase()}.xml`),
`<places><parent name="${facet} Isles"><parent name="Deep Cave">
<child name="Level 1" x="9010" y="9010" z="0" /></parent></parent></places>`,
'utf8',
)
}
fs.writeFileSync(
path.join(root, 'Data', 'Regions.xml'),
`<ServerRegions>${facets
.map(
(facet) => `<Facet name="${facet}">
<region type="TownRegion" priority="10" name="${facet} City">
<rect x="1000" y="1000" width="500" height="500" />
</region></Facet>`,
)
.join('')}</ServerRegions>`,
'utf8',
)
if (includeChampions) {
fs.writeFileSync(
path.join(root, 'Config', 'ChampionSpawns.xml'),
`<championSystem><spawn name="Deep" group="G" type="Terror">
<location x="1100" y="1100" z="0" map="${facets[0]}" radius="40" />
</spawn></championSystem>`,
'utf8',
)
}
}
function tempTree(options) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-test-'))
writeTree(root, options)
return root
}
// ── buildAtlas against a custom-facet tree ─────────────────────────────────
test('buildAtlas: works entirely on facets that do not exist in stock UO', () => {
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
const atlas = buildAtlas(root)
assert.deepEqual(atlas.facets, ['Sosaria', 'Underdark'])
assert.equal(atlas.meta.counts.facets, 2)
})
test('buildAtlas: reconciles a location file that spells the facet differently', () => {
// "Sosaria Isles" inside the file vs <Map>Sosaria</Map> — the same drift that
// silently emptied the Ter Mur / Tokuno landmark buckets.
const root = tempTree({ facets: ['Sosaria'] })
const atlas = buildAtlas(root)
assert.deepEqual([...new Set(atlas.landmarks.map((l) => l.facet))], ['Sosaria'])
// And the fallback actually fires, rather than the point reading Wilderness.
const far = atlas.points.find((p) => p.name === 'SosariaB')
assert.equal(far.landmark, 'Deep Cave')
assert.equal(far.label, 'Deep Cave')
})
test('buildAtlas: resolves a contained point to its region', () => {
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
const inCity = atlas.points.find((p) => p.name === 'SosariaA')
assert.equal(inCity.region, 'Sosaria City')
assert.equal(inCity.label, 'Sosaria City')
})
test('buildAtlas: drops spawners that are switched off in-world', () => {
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
assert.equal(atlas.points.some((p) => p.name === 'SosariaOff'), false)
assert.equal(atlas.meta.counts.pointsDisabled, 1)
})
test('buildAtlas: a champion altar resolves through the same placement index', () => {
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
assert.equal(atlas.champions[0].label, 'Sosaria City')
assert.equal(atlas.champions[0].facet, 'Sosaria')
})
test('buildAtlas: a tree with no champion file still builds', () => {
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'], includeChampions: false }))
assert.deepEqual(atlas.champions, [])
})
test('buildAtlas: missing path and empty path raise typed errors', () => {
assert.throws(() => buildAtlas(''), (err) => err instanceof AtlasSourceError && err.code === 'NO_PATH')
assert.throws(
() => buildAtlas(path.join(os.tmpdir(), 'definitely-not-a-servuo-tree-xyz')),
(err) => err instanceof AtlasSourceError && err.code === 'NOT_FOUND',
)
})
test('buildAtlas: a directory with no spawn files raises rather than building empty', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-empty-'))
fs.mkdirSync(path.join(root, 'Data'), { recursive: true })
fs.writeFileSync(path.join(root, 'Data', 'Regions.xml'), '<ServerRegions/>', 'utf8')
assert.throws(() => buildAtlas(root), (err) => err.code === 'NO_SPAWNS')
})
// ── Hashing ────────────────────────────────────────────────────────────────
test('hashSources: stable across reads, changes when a file changes', () => {
const root = tempTree({ facets: ['Sosaria'] })
const first = hashSources(root)
assert.ok(sameSources(first, hashSources(root)))
fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- edit -->', 'utf8')
assert.equal(sameSources(first, hashSources(root)), false)
})
test('sameSources: a missing or extra file is a difference', () => {
assert.equal(sameSources({ a: '1' }, { a: '1', b: '2' }), false)
assert.equal(sameSources({ a: '1' }, { a: '2' }), false)
assert.equal(sameSources({ a: '1' }, { a: '1' }), true)
assert.equal(sameSources(null, { a: '1' }), false)
assert.equal(sameSources({ a: '1' }, null), false)
})
// ── Aggregation ────────────────────────────────────────────────────────────
const POINTS = [
{ facet: 'Sosaria', types: [{ type: 'Lizardman', max: 3 }, { type: 'Orc', max: 1 }] },
{ facet: 'Sosaria', types: [{ type: 'Lizardman', max: 2 }] },
{ facet: 'Underdark', types: [{ type: 'lizardman', max: 5 }] },
]
test('aggregateCreatures: sums each type’s own max and counts per facet', () => {
const lizardman = aggregateCreatures(POINTS).find((c) => c.slug === 'lizardman')
assert.equal(lizardman.total, 10)
assert.equal(lizardman.points, 3)
assert.deepEqual(lizardman.facets, { Sosaria: 2, Underdark: 1 })
})
test('aggregateCreatures: differing case collapses to one creature', () => {
const creatures = aggregateCreatures(POINTS)
assert.equal(creatures.filter((c) => c.slug === 'lizardman').length, 1)
assert.deepEqual(creatures.map((c) => c.slug), ['lizardman', 'orc'])
assert.equal(Object.hasOwn(creatures[0], 'spellings'), false)
})
test('displayName: most common wins, ties break to the capitalised form', () => {
assert.equal(displayName(new Map([['lizardman', 9], ['Lizardman', 2]])), 'lizardman')
assert.equal(displayName(new Map([['lizardman', 5], ['Lizardman', 5]])), 'Lizardman')
// Deterministic regardless of insertion order — a committed artifact is gone,
// but a spurious diff in the DB on every restart would be just as wrong.
assert.equal(
displayName(new Map([['abc', 1], ['abd', 1]])),
displayName(new Map([['abd', 1], ['abc', 1]])),
)
})
test('pointTypeRows: collapses a repeated type to the larger max', () => {
// The primary key is (point_id, slug), so a duplicate would otherwise fail the
// insert and take the whole transaction with it.
const rows = shardAtlas.pointTypeRows([
{ types: [{ type: 'Orc', max: 1 }, { type: 'orc', max: 4 }, { type: 'Rat', max: 2 }] },
])
assert.deepEqual(rows.sort(), [[1, 'orc', 4], [1, 'rat', 2]].sort())
})
test('pointTypeRows: point ids are 1-based and line up with insert order', () => {
const rows = shardAtlas.pointTypeRows([
{ types: [{ type: 'A', max: 1 }] },
{ types: [{ type: 'B', max: 1 }] },
])
assert.deepEqual(rows, [[1, 'a', 1], [2, 'b', 1]])
})
// ── The refresh decision ───────────────────────────────────────────────────
//
// The boot path's two contracts: it never blocks startup, and it never applies a
// facet removal on its own.
let applied
let pendingRow
let facetsInDb
let metaRow
beforeEach(() => {
applied = null
pendingRow = null
facetsInDb = []
metaRow = null
atlasDb.replaceAtlas = async (atlas) => {
applied = atlas
return { points: atlas.points.length, creatures: atlas.creatures.length }
}
atlasDb.getMeta = async () => metaRow
atlasDb.getFacets = async () => facetsInDb
atlasDb.getPending = async () => pendingRow
atlasDb.setPending = async (payload, status) => {
pendingRow = { ...payload, status }
}
atlasDb.clearPending = async () => {
pendingRow = null
}
settings.get = async () => ''
process.env.SERVUO_PATH = ''
})
test('refresh: no configured path is skipped, not an error', async () => {
const result = await shardAtlas.refresh()
assert.equal(result.status, 'skipped')
})
test('refresh: an unreadable tree reports unavailable rather than throwing', async () => {
const result = await shardAtlas.refresh({ path: path.join(os.tmpdir(), 'no-such-tree-abc') })
assert.equal(result.status, 'unavailable')
assert.equal(result.code, 'NOT_FOUND')
})
test('refresh: a fresh database imports', async () => {
const root = tempTree({ facets: ['Sosaria'] })
const result = await shardAtlas.refresh({ path: root })
assert.equal(result.status, 'imported')
assert.ok(applied)
assert.deepEqual(result.addedFacets, ['Sosaria'])
})
test('refresh: an unchanged tree parses nothing and writes nothing', async () => {
const root = tempTree({ facets: ['Sosaria'] })
metaRow = buildAtlas(root).meta
const result = await shardAtlas.refresh({ path: root })
assert.equal(result.status, 'unchanged')
assert.equal(applied, null)
})
// The hash gate alone would strand an install whose maps never change on
// whatever an older build derived: a corrected parse would ship and never reach
// the data, because the only thing compared is the tree.
test('refresh: an unchanged tree is REIMPORTED when the parser has moved on', async () => {
const root = tempTree({ facets: ['Sosaria'] })
metaRow = { ...buildAtlas(root).meta, parserVersion: PARSER_VERSION - 1 }
const result = await shardAtlas.refresh({ path: root })
assert.equal(result.status, 'imported')
assert.ok(applied)
})
test('refresh: an atlas imported before parser versions existed is stale', async () => {
const root = tempTree({ facets: ['Sosaria'] })
const meta = buildAtlas(root).meta
delete meta.parserVersion
metaRow = meta
const result = await shardAtlas.refresh({ path: root })
assert.equal(result.status, 'imported')
})
test('refresh: --force reimports an unchanged tree', async () => {
const root = tempTree({ facets: ['Sosaria'] })
metaRow = buildAtlas(root).meta
const result = await shardAtlas.refresh({ path: root, force: true })
assert.equal(result.status, 'imported')
assert.ok(applied)
})
test('refresh: a NEW facet applies straight away', async () => {
// Additions cannot destroy anything an operator would miss.
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
facetsInDb = ['Sosaria']
const result = await shardAtlas.refresh({ path: root })
assert.equal(result.status, 'imported')
assert.deepEqual(result.addedFacets, ['Underdark'])
})
test('refresh: a REMOVED facet is staged, not applied', async () => {
const root = tempTree({ facets: ['Sosaria'] })
facetsInDb = ['Sosaria', 'Underdark']
const result = await shardAtlas.refresh({ path: root })
assert.equal(result.status, 'needsReview')
assert.deepEqual(result.removedFacets, ['Underdark'])
// The critical part: the existing atlas was left alone.
assert.equal(applied, null)
assert.equal(pendingRow.status, 'pending')
})
test('refresh: approving applies the removal', async () => {
const root = tempTree({ facets: ['Sosaria'] })
facetsInDb = ['Sosaria', 'Underdark']
await shardAtlas.refresh({ path: root })
assert.equal(applied, null)
const result = await shardAtlas.approvePending({ path: root })
assert.equal(result.status, 'imported')
assert.ok(applied)
assert.deepEqual(result.removedFacets, ['Underdark'])
})
test('refresh: a rejected refresh does not re-prompt while the tree is unchanged', async () => {
const root = tempTree({ facets: ['Sosaria'] })
facetsInDb = ['Sosaria', 'Underdark']
await shardAtlas.refresh({ path: root })
await shardAtlas.rejectPending()
assert.equal(pendingRow.status, 'rejected')
const again = await shardAtlas.refresh({ path: root })
assert.equal(again.status, 'unchanged')
assert.equal(applied, null)
})
test('refresh: changing the tree asks again after a rejection', async () => {
const root = tempTree({ facets: ['Sosaria'] })
facetsInDb = ['Sosaria', 'Underdark']
await shardAtlas.refresh({ path: root })
await shardAtlas.rejectPending()
fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- changed -->', 'utf8')
const again = await shardAtlas.refresh({ path: root })
assert.equal(again.status, 'needsReview')
})
test('refreshOnBoot: never throws, whatever goes wrong', async () => {
atlasDb.getMeta = async () => {
throw new Error('database is on fire')
}
atlasDb.getFacets = async () => {
throw new Error('still on fire')
}
atlasDb.replaceAtlas = async () => {
throw new Error('and the import too')
}
process.env.SERVUO_PATH = tempTree({ facets: ['Sosaria'] })
const result = await shardAtlas.refreshOnBoot()
assert.equal(result.status, 'failed')
})
test('refreshOnBoot: a missing tree is survivable, not fatal', async () => {
process.env.SERVUO_PATH = path.join(os.tmpdir(), 'nope-not-here-xyz')
const result = await shardAtlas.refreshOnBoot()
assert.equal(result.status, 'unavailable')
})
test('refresh: an explicit path overrides the configured one', async () => {
const configured = tempTree({ facets: ['Configured'] })
const override = tempTree({ facets: ['Override'] })
settings.get = async () => configured
const result = await shardAtlas.refresh({ path: override })
assert.equal(result.status, 'imported')
assert.deepEqual(result.addedFacets, ['Override'])
})

View File

@@ -0,0 +1,686 @@
// Spawn atlas parsers — pure functions over strings, no `fs`, no dependencies.
//
// These back the CLI build script (`scripts/buildSpawnAtlas.js`), which is the
// only thing that reads a ServUO tree. Keeping every parser pure and fs-free is
// what lets the test suite cover them in CI, where no ServUO tree exists: the
// tests hand these functions literal XML strings.
//
// Four source shapes, two very different parsing strategies:
//
// Spawns/*.xml ~10.5 MB across 13 files, FLAT <Points> records
// → streaming regex, never a DOM. See parsePoints().
// Data/Regions.xml 129 KB, genuinely nested <region> inside <region>
// Data/Locations/*.xml nested <parent>/<child>
// Config/ChampionSpawns.xml 4.8 KB, <spawn>/<location>
// → the small recursive tokenizer below.
//
// The server has zero XML dependencies and this adds none. The tokenizer is
// deliberately a *subset* parser: it handles the constructs these four files
// actually use (elements, attributes, self-closing tags, comments, the XML
// declaration, CDATA, the five predefined entities plus numeric refs) and
// nothing else. It is not a general-purpose XML parser and must not be reused
// as one — no namespaces, no DTDs, no entity declarations.
// ── Entities ───────────────────────────────────────────────────────────────
const NAMED_ENTITIES = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
}
// Region and location names carry apostrophes ("Mondain's Legacy", "Wrong's
// Level 3"), so entity decoding is load-bearing here, not decorative.
function decodeEntities(text) {
if (!text.includes('&')) return text
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, body) => {
if (body[0] === '#') {
const code =
body[1] === 'x' || body[1] === 'X'
? Number.parseInt(body.slice(2), 16)
: Number.parseInt(body.slice(1), 10)
return Number.isFinite(code) ? String.fromCodePoint(code) : match
}
const named = NAMED_ENTITIES[body.toLowerCase()]
return named === undefined ? match : named
})
}
// ── The tokenizer ──────────────────────────────────────────────────────────
const ATTR_RE = /([\w:.-]+)\s*=\s*("([^"]*)"|'([^']*)')/g
function parseAttrs(source) {
const attrs = {}
ATTR_RE.lastIndex = 0
let match
while ((match = ATTR_RE.exec(source)) !== null) {
const raw = match[3] !== undefined ? match[3] : match[4]
attrs[match[1]] = decodeEntities(raw)
}
return attrs
}
/**
* Parse a small nested XML document into `{ name, attrs, children, text }`.
*
* Intended for Regions.xml / Locations / ChampionSpawns.xml only — never for
* the multi-megabyte Spawns files. Returns the root element, or `null` for a
* document with no elements.
*
* Mismatched or stray closing tags are ignored rather than thrown on: these are
* hand-maintained shard config files, and one malformed region should degrade
* to a missing region, not abort a build that is otherwise fine.
*/
function parseXml(source) {
const text = String(source)
const root = { name: '#document', attrs: {}, children: [], text: '' }
const stack = [root]
let i = 0
while (i < text.length) {
const lt = text.indexOf('<', i)
if (lt === -1) {
appendText(stack[stack.length - 1], text.slice(i))
break
}
if (lt > i) appendText(stack[stack.length - 1], text.slice(i, lt))
// Comment, declaration/DOCTYPE, or CDATA — skipped wholesale.
if (text.startsWith('<!--', lt)) {
const end = text.indexOf('-->', lt + 4)
i = end === -1 ? text.length : end + 3
continue
}
if (text.startsWith('<![CDATA[', lt)) {
const end = text.indexOf(']]>', lt + 9)
const stop = end === -1 ? text.length : end
appendRawText(stack[stack.length - 1], text.slice(lt + 9, stop))
i = end === -1 ? text.length : end + 3
continue
}
if (text.startsWith('<?', lt)) {
const end = text.indexOf('?>', lt + 2)
i = end === -1 ? text.length : end + 2
continue
}
if (text.startsWith('<!', lt)) {
const end = text.indexOf('>', lt + 2)
i = end === -1 ? text.length : end + 1
continue
}
const gt = findTagEnd(text, lt)
if (gt === -1) {
// Unterminated tag: nothing sane is left to read.
break
}
const inner = text.slice(lt + 1, gt)
if (inner[0] === '/') {
const name = inner.slice(1).trim()
// Pop to the nearest matching open element. If there is no match the tag
// is stray and we drop it rather than unwinding the whole stack.
for (let depth = stack.length - 1; depth > 0; depth -= 1) {
if (stack[depth].name === name) {
stack.length = depth
break
}
}
i = gt + 1
continue
}
const selfClosing = inner.endsWith('/')
const body = selfClosing ? inner.slice(0, -1) : inner
const space = body.search(/\s/)
const name = (space === -1 ? body : body.slice(0, space)).trim()
const node = {
name,
attrs: space === -1 ? {} : parseAttrs(body.slice(space)),
children: [],
text: '',
}
stack[stack.length - 1].children.push(node)
if (!selfClosing) stack.push(node)
i = gt + 1
}
return root.children.length > 0 ? root.children[0] : null
}
// `>` inside a quoted attribute value must not end the tag.
function findTagEnd(text, from) {
let quote = null
for (let i = from + 1; i < text.length; i += 1) {
const ch = text[i]
if (quote) {
if (ch === quote) quote = null
} else if (ch === '"' || ch === "'") {
quote = ch
} else if (ch === '>') {
return i
}
}
return -1
}
function appendText(node, chunk) {
if (chunk.trim() === '') return
appendRawText(node, decodeEntities(chunk))
}
function appendRawText(node, chunk) {
node.text = node.text ? `${node.text}${chunk}` : chunk
}
function childrenNamed(node, name) {
if (!node || !node.children) return []
return node.children.filter((child) => child.name === name)
}
// ── Facet names ────────────────────────────────────────────────────────────
//
// Facets are NOT a fixed list. A shard may add facets, replace them wholesale,
// or rename them when its maps are updated, so nothing here may name Felucca,
// Trammel or any other stock facet. The facet set is whatever the shard's own
// files say it is, discovered at parse time.
//
// The complication is that the sources disagree about spelling for the SAME
// facet and nothing in the files reconciles them: `Spawns/*.xml` `<Map>` and
// `Regions.xml` `<Facet name>` say `TerMur`, while `Data/Locations/*.xml` spells
// it `Ter Mur` and calls Tokuno `Tokuno Islands`. Left unreconciled this fails
// silently — the landmark bucket is keyed differently from the points looking it
// up, so the fallback never fires and every unregioned spawn on those facets
// reads "Wilderness".
//
// Reconciliation is therefore done by MATCHING, not by a lookup table:
// `facetKey()` collapses spelling differences, and `resolveFacetName()` matches
// a loosely-spelled name against the canonical set discovered from the shard's
// own data. A facet nobody else mentions keeps its own name rather than being
// dropped.
/**
* Collapse a facet name to a comparison key: lowercase, alphanumerics only.
* `TerMur`, `Ter Mur` and `ter-mur` all key alike.
*/
function facetKey(value) {
return String(value ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '')
}
/**
* Build a key → canonical-spelling lookup from the authoritative facet names.
*
* The authority is what the spawn records and region definitions actually say,
* since those are the names the atlas keys everything on. Later names do not
* overwrite earlier ones, so the first source wins consistently.
*/
function buildFacetIndex(names) {
const index = new Map()
for (const name of names) {
const key = facetKey(name)
if (key !== '' && !index.has(key)) index.set(key, String(name).trim())
}
return index
}
/**
* Resolve a loosely-spelled facet name against the discovered canonical set.
*
* Tried in order: exact key match (`Ter Mur` → `TerMur`), then a prefix match in
* either direction (`Tokuno Islands` → `Tokuno`), longest candidate first so a
* more specific facet wins over a shorter one that merely prefixes it.
*
* A name matching nothing is returned trimmed rather than dropped — on a shard
* with a custom facet that is a real facet the atlas simply has no spawns for
* yet, and inventing a match would be worse than leaving it alone.
*/
function resolveFacetName(value, index) {
const raw = String(value ?? '').trim()
const key = facetKey(raw)
if (key === '') return ''
if (index.has(key)) return index.get(key)
let best = null
for (const [candidateKey, canonical] of index) {
if (!key.startsWith(candidateKey) && !candidateKey.startsWith(key)) continue
if (best === null || candidateKey.length > facetKey(best).length) best = canonical
}
return best ?? raw
}
// ── Small coercions ────────────────────────────────────────────────────────
function toInt(value, fallback = 0) {
const n = Number.parseInt(value, 10)
return Number.isFinite(n) ? n : fallback
}
function toBool(value) {
return String(value).trim().toLowerCase() === 'true'
}
/**
* URL-safe slug used as the creature primary key and in `/atlas/:slug`.
* Spawn type tokens are C# class names, so they are already ASCII-ish; this
* mainly lowercases and collapses punctuation.
*/
function slugify(value) {
return String(value)
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
// ── Objects2 ───────────────────────────────────────────────────────────────
/**
* Parse a `<Objects2>` value into `[{ type, max }]`.
*
* The format is one or more segments joined by `:OBJ=`, each segment being
* `Type:MX=n:SB=0:RT=0:...` — the type is the token before the first `:`, and
* every following token is a `KEY=value` pair. Verified against trammel.xml,
* where a single point carries six types:
*
* Giantserpent:MX=1:...:OBJ=Giantspider:MX=1:...:OBJ=Boar:MX=1:...
*
* Splitting on `:` alone would shred this, which is why the `:OBJ=` split comes
* first. `MX` is that type's own max count and is what the atlas displays;
* every other flag (spawn/trigger/refractory bookkeeping) is dropped.
*
* The type token itself may carry XmlSpawner directives appended to the class
* name — property assignments after `/` and an amount/argument list after `,`:
*
* Agralem/Name/Agralem alchemist/z/-50 Fairy,{RND,4,8}
* GargishRefugee/hue/34532 greatape,true GargishRouser,1
*
* Taken literally these produce creatures that do not exist ("alchemist/z/-50")
* AND split real ones in two, because `Fairy` and `Fairy,{RND,4,8}` slug apart —
* 71 of 845 entries were affected before this was stripped. Only the leading
* class name identifies the creature, so everything from the first `/` or `,`
* is dropped.
*/
/** Reduce an XmlSpawner type token to the bare class name. */
function stripSpawnerDirectives(token) {
const cut = String(token).search(/[/,]/)
return (cut === -1 ? String(token) : String(token).slice(0, cut)).trim()
}
function parseObjects2(value) {
const source = String(value ?? '').trim()
if (source === '') return []
return source
.split(':OBJ=')
.map((segment) => {
const tokens = segment.split(':')
const type = stripSpawnerDirectives(tokens.shift() ?? '')
if (type === '') return null
let max = 1
for (const token of tokens) {
const eq = token.indexOf('=')
if (eq === -1) continue
if (token.slice(0, eq).trim().toUpperCase() === 'MX') {
max = toInt(token.slice(eq + 1), 1)
}
}
return { type, max }
})
.filter((entry) => entry !== null)
}
// ── Spawns/*.xml ───────────────────────────────────────────────────────────
const POINT_RE = /<Points>([\s\S]*?)<\/Points>/g
function tagValue(block, name) {
const match = block.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))
return match ? decodeEntities(match[1]).trim() : ''
}
/**
* Parse a `Spawns/<facet>.xml` file into spawn point records.
*
* Deliberately regex/streaming and NOT `parseXml` — these files total ~10.5 MB
* and putting them through a DOM builder would allocate a node per element for
* ~40 fields on every one of ~6,500 records to keep 14 of them. The records are
* flat, so a per-record regex sweep is both correct and cheap.
*
* Only the fields the site can actually show are kept. Everything to do with
* triggering, refractory windows, proximity, sequential spawning, sounds and
* `UniqueId` is dropped here rather than downstream — that is what holds the
* committed artifact under 1 MB.
*
* NOTE: the facet comes from each record's own `<Map>`, never from the file
* name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all
* carry TerMur/Trammel points, so there are 13 files but only 6 facets.
*/
/**
* A spawner's respawn window, in seconds.
*
* `DelayInSec` decides the unit of `MinDelay`/`MaxDelay`; absent (older files)
* it is false, which is minutes — the same default XmlSpawner assumes.
*/
function delaySeconds(block) {
const scale = toBool(tagValue(block, 'DelayInSec')) ? 1 : 60
return {
minDelay: toInt(tagValue(block, 'MinDelay')) * scale,
maxDelay: toInt(tagValue(block, 'MaxDelay')) * scale,
}
}
function parsePoints(source) {
const text = String(source)
const points = []
POINT_RE.lastIndex = 0
let match
while ((match = POINT_RE.exec(text)) !== null) {
const block = match[1]
// Reported exactly as written. `<Map>` is the authority the rest of the
// atlas keys on, so it is never rewritten.
const facet = tagValue(block, 'Map')
if (facet === '') continue
points.push({
name: tagValue(block, 'Name'),
facet,
x: toInt(tagValue(block, 'X')),
y: toInt(tagValue(block, 'Y')),
width: toInt(tagValue(block, 'Width')),
height: toInt(tagValue(block, 'Height')),
range: toInt(tagValue(block, 'Range')),
maxCount: toInt(tagValue(block, 'MaxCount')),
// Normalised to SECONDS here, because the unit is per-record. XmlSpawner
// writes minutes by default and switches to seconds only when a spawner's
// delay does not divide into whole minutes, flagging that with
// `DelayInSec` (XmlSpawner2.cs:7462-7480, read back at :6345-6358). Taken
// literally the two are indistinguishable — a `5` means five minutes on
// one spawner and five seconds on the next — so a consumer that assumed
// either unit would be wrong about the other. Stock ServUO 57.4 has ~30
// second-flagged spawners, few enough to look like noise and quietly
// mislabel.
...delaySeconds(block),
// Time-of-day gating: TODMode 0 means "always", in which case the start
// and end values are meaningless and the site must not render them.
todStart: toInt(tagValue(block, 'TODStart')),
todEnd: toInt(tagValue(block, 'TODEnd')),
todMode: toInt(tagValue(block, 'TODMode')),
// A spawner switched off in-world spawns nothing; the build filters these
// out so the atlas describes what actually appears, not what is merely
// configured. Parsed here so the decision stays in the build script.
running: toBool(tagValue(block, 'IsRunning')),
types: parseObjects2(tagValue(block, 'Objects2')),
})
}
return points
}
// ── Data/Regions.xml ───────────────────────────────────────────────────────
/**
* Flatten `Data/Regions.xml` into `[{ facet, name, type, priority, parent, rects }]`.
*
* Regions nest: a `<region>` may contain further `<region>` elements, and the
* inner ones frequently omit `name` and `priority` (`<region type="CrystalField">`
* inside "Prism of Light"). Unnamed regions are skipped — they cannot label a
* spawn point — but their children are still walked, and a child that omits
* `priority` inherits its parent's rather than defaulting to 0, which would
* quietly sort it below every top-level region.
*/
function parseRegions(source) {
const root = parseXml(source)
const regions = []
if (!root) return regions
for (const facetNode of childrenNamed(root, 'Facet')) {
const facet = (facetNode.attrs.name || '').trim()
if (facet === '') continue
walkRegions(facetNode, facet, null, 0, regions)
}
return regions
}
function walkRegions(node, facet, parentName, parentPriority, out) {
for (const regionNode of childrenNamed(node, 'region')) {
const name = regionNode.attrs.name || ''
const priority = Object.hasOwn(regionNode.attrs, 'priority')
? toInt(regionNode.attrs.priority, parentPriority)
: parentPriority
if (name !== '') {
const rects = childrenNamed(regionNode, 'rect').map((rect) => ({
x: toInt(rect.attrs.x),
y: toInt(rect.attrs.y),
width: toInt(rect.attrs.width),
height: toInt(rect.attrs.height),
}))
// A named region with no rects (some exist purely to carry music or a
// `go` point) can never contain anything, so it is not worth indexing.
if (rects.length > 0) {
out.push({
facet,
name,
type: regionNode.attrs.type || '',
priority,
parent: parentName,
rects,
})
}
}
walkRegions(regionNode, facet, name === '' ? parentName : name, priority, out)
}
}
// ── Data/Locations/*.xml ───────────────────────────────────────────────────
/**
* Flatten a `Data/Locations/<facet>.xml` into landmark points.
*
* The file nests `<parent>` arbitrarily deep and puts coordinates only on
* `<child>`: Trammel → Dungeons → Covetous → "Level 1". The outermost parent is
* the facet itself and is dropped from `path`; `group` is the innermost
* enclosing parent ("Covetous"), which is the label worth showing — "Covetous"
* reads better than "Level 1" when naming where a spawn is.
*/
function parseLocations(source, facetHint = '') {
const root = parseXml(source)
const landmarks = []
if (!root) return landmarks
for (const top of childrenNamed(root, 'parent')) {
// The file name (`Data/Locations/termur.xml`) is the more reliable signal
// and is preferred over the display label inside the file, which is where
// the `Ter Mur` / `Tokuno Islands` drift lives. Both are carried so the
// build can fall back to matching the label if the file name resolves to
// nothing — a shard may well name its files differently from its facets.
landmarks.push(
...collectLocations(top, facetHint || top.attrs.name || '', top.attrs.name || ''),
)
}
return landmarks
}
function collectLocations(top, facet, label) {
const out = []
walkLocations(top, facet, [], out)
for (const landmark of out) landmark.facetLabel = label
return out
}
function walkLocations(node, facet, path, out) {
for (const child of childrenNamed(node, 'child')) {
const name = child.attrs.name || ''
if (name === '') continue
out.push({
facet,
name,
group: path.length > 0 ? path[path.length - 1] : name,
path: [...path],
x: toInt(child.attrs.x),
y: toInt(child.attrs.y),
z: toInt(child.attrs.z),
})
}
for (const parent of childrenNamed(node, 'parent')) {
const name = parent.attrs.name || ''
walkLocations(parent, facet, name === '' ? path : [...path, name], out)
}
}
// ── Config/ChampionSpawns.xml ──────────────────────────────────────────────
/**
* Parse `Config/ChampionSpawns.xml` into champion altar records.
*
* This is the shard's *configured* champion roster — which altars exist, where,
* and which type each is pinned to. It is static content and distinct from the
* live `champ.update` feed the bridge already carries: this says "there is an
* Unholy Terror altar in Deceit", the feed says "it is on level 3 right now".
*
* A spawn with no `type` is randomised on every activation, which the site must
* render as "random" rather than as an empty type.
*/
function parseChampions(source) {
const root = parseXml(source)
const champions = []
if (!root) return champions
for (const spawnNode of childrenNamed(root, 'spawn')) {
const location = childrenNamed(spawnNode, 'location')[0]
const attrs = location ? location.attrs : {}
champions.push({
name: spawnNode.attrs.name || '',
group: spawnNode.attrs.group || '',
type: spawnNode.attrs.type || '',
randomType: !spawnNode.attrs.type,
facet: (attrs.map || '').trim(),
x: toInt(attrs.x),
y: toInt(attrs.y),
z: toInt(attrs.z),
radius: toInt(attrs.radius),
})
}
return champions
}
// ── Placement ──────────────────────────────────────────────────────────────
const DEFAULT_LANDMARK_RADIUS = 200
function inRect(x, y, rect) {
return (
x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
)
}
function rectArea(rect) {
return Math.max(1, rect.width) * Math.max(1, rect.height)
}
/**
* Group parsed regions and landmarks by facet once, so the per-point resolve
* below is a scan of one facet instead of the whole world. With ~6,500 points
* and a few thousand rects this stays comfortably sub-second; there is no need
* for a spatial index and none is worth the complexity.
*/
function buildPlacementIndex(regions, landmarks) {
const byFacet = new Map()
// Keyed on facetKey(), not the raw name, so two spellings of one facet cannot
// land in separate buckets — the failure that silently emptied the landmark
// bucket for Ter Mur and Tokuno.
const facet = (name) => {
const key = facetKey(name)
if (!byFacet.has(key)) byFacet.set(key, { regions: [], landmarks: [] })
return byFacet.get(key)
}
for (const region of regions) facet(region.facet).regions.push(region)
for (const landmark of landmarks) facet(landmark.facet).landmarks.push(landmark)
return byFacet
}
/**
* Turn a raw coordinate into a human place name.
*
* This is the transform the whole atlas exists for: it is what makes a row read
* "Lizardman — Despise, Felucca" instead of "Lizardman — 5411, 1234".
*
* Resolution order:
* 1. The highest-`priority` named region whose rect contains the point. Ties
* break toward the SMALLEST rect, so a specific room inside a dungeon wins
* over the dungeon-wide rect it sits in.
* 2. Otherwise the nearest landmark within `landmarkRadius` tiles, labelled by
* its group ("Covetous"), not the individual marker ("Level 1").
* 3. Otherwise "Wilderness". The radius cap is what keeps step 3 reachable —
* without it the nearest landmark is always *some* landmark, however far,
* and open countryside would get labelled with a dungeon on the far side
* of the map.
*/
function resolveRegion(x, y, facetName, index, options = {}) {
const radius = options.landmarkRadius ?? DEFAULT_LANDMARK_RADIUS
const bucket = index.get(facetKey(facetName))
const result = { region: null, landmark: null, label: 'Wilderness' }
if (!bucket) return result
let best = null
let bestPriority = -Infinity
let bestArea = Infinity
for (const region of bucket.regions) {
for (const rect of region.rects) {
if (!inRect(x, y, rect)) continue
const area = rectArea(rect)
if (region.priority > bestPriority || (region.priority === bestPriority && area < bestArea)) {
best = region
bestPriority = region.priority
bestArea = area
}
}
}
if (best) {
result.region = best.name
result.label = best.name
return result
}
let nearest = null
let nearestDistance = Infinity
const limit = radius * radius
for (const landmark of bucket.landmarks) {
const dx = landmark.x - x
const dy = landmark.y - y
const distance = dx * dx + dy * dy
if (distance < nearestDistance) {
nearest = landmark
nearestDistance = distance
}
}
if (nearest && nearestDistance <= limit) {
result.landmark = nearest.group || nearest.name
result.label = result.landmark
}
return result
}
module.exports = {
parseXml,
parseObjects2,
parsePoints,
parseRegions,
parseLocations,
parseChampions,
buildPlacementIndex,
resolveRegion,
facetKey,
buildFacetIndex,
resolveFacetName,
slugify,
decodeEntities,
DEFAULT_LANDMARK_RADIUS,
}

View File

@@ -0,0 +1,336 @@
// Spawn atlas — the filesystem layer over a ServUO tree.
//
// `spawnAtlasParse.js` holds the pure parsers; this module is the only thing
// that touches a ServUO tree on disk, and it is shared by both callers:
//
// - the server, which refreshes the atlas on boot (`shardAtlas.model.js`)
// - the CLI (`scripts/importSpawnAtlas.js`)
//
// The shard's own files are the single source of truth. Nothing is precomputed
// and committed, because a shard's maps change over its lifetime — facets get
// added, replaced or renamed — and a snapshot in the repo would silently go
// stale against the world players actually see.
//
// Reading and hashing the whole tree costs ~120 ms and a full parse ~400 ms, so
// the boot path hashes first and only parses when something actually changed.
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const {
parsePoints,
parseRegions,
parseLocations,
parseChampions,
buildPlacementIndex,
buildFacetIndex,
resolveFacetName,
resolveRegion,
facetKey,
slugify,
} = require('./spawnAtlasParse')
const REGIONS_FILE = path.join('Data', 'Regions.xml')
const LOCATIONS_DIR = path.join('Data', 'Locations')
const SPAWNS_DIR = 'Spawns'
const CHAMPIONS_FILE = path.join('Config', 'ChampionSpawns.xml')
class AtlasSourceError extends Error {
constructor(message, code) {
super(message)
this.name = 'AtlasSourceError'
this.code = code
}
}
// ── Reading ────────────────────────────────────────────────────────────────
function sha256(text) {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex')
}
function listXml(dir) {
try {
return fs
.readdirSync(dir)
.filter((name) => name.toLowerCase().endsWith('.xml'))
.sort()
} catch (err) {
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return []
throw err
}
}
function readIfPresent(file) {
try {
return fs.readFileSync(file, 'utf8')
} catch (err) {
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null
throw err
}
}
/**
* Read every atlas source file under `root`.
*
* Returns `{ files: [{ label, text, sha256, bytes }] }`, labels being
* tree-relative and forward-slashed so a hash map compares equal across
* platforms — the same tree read on Windows and Linux must produce the same
* fingerprint or every boot would look like a change.
*/
function readSources(root) {
if (!root || String(root).trim() === '') {
throw new AtlasSourceError('No ServUO path configured', 'NO_PATH')
}
if (!fs.existsSync(root)) {
throw new AtlasSourceError(`ServUO path does not exist: ${root}`, 'NOT_FOUND')
}
const files = []
const push = (label, file) => {
const text = readIfPresent(file)
if (text === null) return false
files.push({ label, text, sha256: sha256(text), bytes: Buffer.byteLength(text, 'utf8') })
return true
}
if (!push('Data/Regions.xml', path.join(root, REGIONS_FILE))) {
throw new AtlasSourceError(`Missing required file: ${REGIONS_FILE}`, 'NO_REGIONS')
}
for (const name of listXml(path.join(root, LOCATIONS_DIR))) {
push(`Data/Locations/${name}`, path.join(root, LOCATIONS_DIR, name))
}
const spawnFiles = listXml(path.join(root, SPAWNS_DIR))
if (spawnFiles.length === 0) {
throw new AtlasSourceError(`No spawn files found in ${SPAWNS_DIR}`, 'NO_SPAWNS')
}
for (const name of spawnFiles) push(`Spawns/${name}`, path.join(root, SPAWNS_DIR, name))
push('Config/ChampionSpawns.xml', path.join(root, CHAMPIONS_FILE))
return { files }
}
/**
* A fingerprint of the tree: `{ "<label>": "<sha256>" }`.
*
* The boot path compares this against what was last imported and skips the
* parse entirely when it matches, which is the normal case on every restart
* that did not follow a map update.
*/
function hashSources(root) {
const { files } = readSources(root)
const hashes = {}
for (const file of files) hashes[file.label] = file.sha256
return hashes
}
/**
* Bumped whenever the parser produces DIFFERENT data from IDENTICAL source
* files — a fixed misreading, a new field, a changed unit.
*
* Without it the hash gate is a trap: an install whose tree has not changed
* would keep serving what an older parser derived, indefinitely, because the
* only thing the boot path compares is the tree. The version is stored beside
* the source hashes and a mismatch counts as drift, so a deploy that corrects
* the parse actually reaches the data.
*
* 2 — respawn delays normalised to seconds (they are per-record minutes OR
* seconds in the source, decided by `DelayInSec`).
*/
const PARSER_VERSION = 2
/** True when two source fingerprints describe the same tree. */
function sameSources(a, b) {
if (!a || !b) return false
const aKeys = Object.keys(a).sort()
const bKeys = Object.keys(b).sort()
if (aKeys.length !== bKeys.length) return false
return aKeys.every((key, i) => key === bKeys[i] && a[key] === b[key])
}
// ── Aggregation ────────────────────────────────────────────────────────────
/**
* Choose one display spelling for a creature.
*
* Spawn files are not consistent about case — the same creature is `Lizardman`
* in one file and `lizardman` in another. Slugging collapses them correctly, but
* the display name would otherwise depend on file read order. Most frequent
* spelling wins; ties break toward more capitals, then alphabetically.
*/
function displayName(spellings) {
const capitals = (value) => (value.match(/[A-Z]/g) || []).length
return [...spellings.entries()].sort((a, b) => {
if (b[1] !== a[1]) return b[1] - a[1]
const caps = capitals(b[0]) - capitals(a[0])
if (caps !== 0) return caps
return a[0].localeCompare(b[0])
})[0][0]
}
/**
* Roll spawn points up into per-type creature rows.
*
* `total` is the sum of each type's own max across every point that spawns it —
* how many of this creature the world holds at once. `facets` is a per-facet
* point count, so "where does this live" answers without touching the points.
*/
function aggregateCreatures(points) {
const creatures = new Map()
for (const point of points) {
for (const entry of point.types) {
const slug = slugify(entry.type)
if (slug === '') continue
let creature = creatures.get(slug)
if (!creature) {
creature = { slug, name: '', total: 0, points: 0, facets: {}, spellings: new Map() }
creatures.set(slug, creature)
}
creature.total += entry.max
creature.points += 1
creature.facets[point.facet] = (creature.facets[point.facet] || 0) + 1
creature.spellings.set(entry.type, (creature.spellings.get(entry.type) || 0) + 1)
}
}
return [...creatures.values()]
.map(({ spellings, ...creature }) => ({ ...creature, name: displayName(spellings) }))
.sort((a, b) => a.slug.localeCompare(b.slug))
}
// ── Build ──────────────────────────────────────────────────────────────────
/**
* Parse a ServUO tree into the full atlas.
*
* Pure with respect to the database — it reads files and returns data; nothing
* here writes. `shardAtlas.model.js` decides what to do with the result.
*/
function buildAtlas(root, options = {}) {
const { files } = readSources(root)
const byLabel = new Map(files.map((file) => [file.label, file]))
const source = {}
for (const file of files) source[file.label] = { bytes: file.bytes, sha256: file.sha256 }
const regions = parseRegions(byLabel.get('Data/Regions.xml').text)
const rawLandmarks = []
for (const file of files) {
if (!file.label.startsWith('Data/Locations/')) continue
const basename = path.basename(file.label, '.xml')
rawLandmarks.push(...parseLocations(file.text, basename))
}
const rawPoints = []
for (const file of files) {
if (!file.label.startsWith('Spawns/')) continue
rawPoints.push(...parsePoints(file.text))
}
// The facet set is whatever THIS tree declares — never a built-in list. A
// shard may add facets, replace them outright, or rename them when its maps
// are updated, and the atlas has to follow without a code change. Spawn
// records and region definitions are the authority, because those are the
// names everything else is keyed on.
const facetIndex = buildFacetIndex([
...rawPoints.map((point) => point.facet),
...regions.map((region) => region.facet),
])
// Landmark facets are then matched against that set, which is what absorbs the
// `Ter Mur` / `Tokuno Islands` spelling drift between Locations and <Map>.
const landmarks = rawLandmarks.map(({ facetLabel, ...landmark }) => {
const fromFile = resolveFacetName(landmark.facet, facetIndex)
const matchedFile = facetIndex.has(facetKey(fromFile))
const resolved = matchedFile ? fromFile : resolveFacetName(facetLabel, facetIndex)
return { ...landmark, facet: resolved || landmark.facet }
})
const placement = buildPlacementIndex(regions, landmarks)
const resolveOpts = options.landmarkRadius ? { landmarkRadius: options.landmarkRadius } : {}
const disabled = rawPoints.filter((point) => !point.running).length
const points = rawPoints
// A spawner switched off in-world produces nothing; advertising it would be
// a straight lie to a player planning a hunt.
.filter((point) => point.running)
// A spawner with no types is a placeholder — nothing to show.
.filter((point) => point.types.length > 0)
.map((point) => {
const place = resolveRegion(point.x, point.y, point.facet, placement, resolveOpts)
return {
name: point.name,
facet: point.facet,
x: point.x,
y: point.y,
width: point.width,
height: point.height,
range: point.range,
maxCount: point.maxCount,
minDelay: point.minDelay,
maxDelay: point.maxDelay,
todStart: point.todStart,
todEnd: point.todEnd,
todMode: point.todMode,
region: place.region,
landmark: place.landmark,
label: place.label,
types: point.types,
}
})
const championsFile = byLabel.get('Config/ChampionSpawns.xml')
const champions = (championsFile ? parseChampions(championsFile.text) : []).map((champ) => {
const facet = resolveFacetName(champ.facet, facetIndex) || champ.facet
return {
...champ,
facet,
slug: slugify(`${facet}-${champ.name}`),
label: resolveRegion(champ.x, champ.y, facet, placement, resolveOpts).label,
}
})
const creatures = aggregateCreatures(points)
const facets = [...new Set(points.map((point) => point.facet))].sort()
const unresolved = points.filter((point) => !point.region && !point.landmark).length
return {
meta: {
generatedAt: new Date().toISOString(),
parserVersion: PARSER_VERSION,
landmarkRadius: options.landmarkRadius ?? undefined,
counts: {
facets: facets.length,
points: points.length,
pointsDisabled: disabled,
creatures: creatures.length,
regions: regions.length,
landmarks: landmarks.length,
champions: champions.length,
unresolvedPoints: unresolved,
},
source,
},
facets,
creatures,
regions,
landmarks,
champions,
points,
}
}
module.exports = {
AtlasSourceError,
PARSER_VERSION,
readSources,
hashSources,
sameSources,
buildAtlas,
aggregateCreatures,
displayName,
}

View File

@@ -0,0 +1,435 @@
// ── Shard feature visibility ───────────────────────────────────────────────
//
// Admin-configurable, per-feature and per-field audience control over every
// shard-derived surface on the site. Replaces the hardcoded split that used to
// live in two places (the PUBLIC_KINDS allowlist in shardBroadcast.js, and the
// ad-hoc `canSeeStaffLocation` style checks in the public controllers).
//
// Design rules (docs/link/v3.md §3):
//
// • Visibility lives HERE, on the website — never in the sidecar. The sidecar
// is a dumb forwarder: it accepts frames, stores them, forwards them
// verbatim, and serves store-backed reads. It defines no audiences.
// • Every default reproduces the behavior that shipped before this module, so
// installing it changes nothing until an admin edits the config.
// • Two rules an admin CANNOT override:
// 1. `acct` / `webId` are admin-only, always. They are not in-game
// visible (unlike a character name) and are not configurable fields.
// 2. A kind absent from KIND_FEATURE is never broadcast below `admin`.
// Fail closed — this is what keeps the kind map a security boundary
// rather than a convenience filter.
//
// The audience ladder is ordered; each rung implies the ones below it.
const db = require('../model/shardVisibility/shardVisibility.model')
const shardLinks = require('../model/shardLinks/shardLinks.model')
const { auth } = require('../core')
const log = require('../core').logger('visibility')
// ── The ladder ─────────────────────────────────────────────────────────────
const LADDER = ['anonymous', 'logged_in', 'player', 'staff', 'admin']
const RANK = new Map(LADDER.map((level, i) => [level, i]))
const isLevel = (level) => RANK.has(level)
// The two fallbacks are deliberately ASYMMETRIC, and the asymmetry is the whole
// point: an unrecognised value must always lose. A single shared fallback cannot
// do that — whichever direction it picks, it fails open on one side. So:
//
// • an unknown VIEWER level floors to the bottom rung (grants nothing), and
// • an unknown REQUIREMENT ceils to the top rung (satisfied by nobody but admin).
//
// With one `rank()` defaulting to admin, a viewer level that fell through (a
// typo, a future rung this build doesn't know, a value from a caller that
// skipped viewerLevel) would have been treated as an ADMIN and passed every gate.
const viewerRank = (level) => RANK.get(level) ?? 0
const requiredRank = (level) => RANK.get(level) ?? RANK.get('admin')
// True when a viewer at `viewer` satisfies a requirement of `required`.
const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
// Exported for tests/diagnostics; `meets` is what callers should use.
const rank = viewerRank
// ── Features ───────────────────────────────────────────────────────────────
//
// All ten shard surfaces: the six that shipped before v3 plus the four v3 adds.
// `fields` lists only the SENSITIVE fields — those an admin may re-gate. A field
// not listed here is visible whenever the feature itself is.
//
// LOCKED_FIELDS are exempt from configuration entirely (rule 1 above).
const LOCKED_FIELDS = { acct: 'admin', webId: 'admin' }
// Rule 1 matches on the FIELD'S MEANING, not on one exact spelling. The wire
// frames nest actors (`leader.acct`), but several read models flatten them
// instead (`shapeHouse` emits `ownerAcct`, `shapeGuild`'s fallback emits
// `leaderAcct`/`leaderWebId`), and an exact-key check silently missed every
// flattened one — which is how `GET /public/shard/idoc` served `ownerAcct` to
// anonymous callers while the same account name was correctly stripped from the
// live `house.decay` frame.
//
// So a key is locked when it IS `acct`/`webId` or ENDS in one, case-insensitively
// (`ownerAcct`, `leaderWebId`, `governorAcct`). Suffix matching is what makes this
// fail closed for shapes nobody has written yet.
const LOCKED_SUFFIXES = ['acct', 'webid']
const isLockedField = (key) => {
const k = String(key).toLowerCase()
return LOCKED_SUFFIXES.some((suffix) => k === suffix || k.endsWith(suffix))
}
const FEATURES = {
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
status: { audience: 'anonymous', fields: {} },
activity: { audience: 'anonymous', fields: {} },
champs: { audience: 'anonymous', fields: {} },
guilds: { audience: 'anonymous', fields: {} },
governors: { audience: 'anonymous', fields: {} },
// The public Houses page showed IDOC location only; owner/price were staff.
// `owner` is the actor object on the house.decay/house.update frames;
// `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the
// REST read models. Both are listed so one rule covers the wire and the read
// model — the flattened `ownerAcct` needs no entry, being locked by rule 1.
houses: {
audience: 'anonymous',
fields: { owner: 'staff', ownerName: 'staff', ownerSerial: 'staff', price: 'staff' },
},
// /public/shard/online listed linked staff to everyone but gated location to
// admin+moderator — which is exactly the `staff` rung.
presence: { audience: 'anonymous', fields: { location: 'staff' } },
// ── New in v3. ──
ruleset: { audience: 'anonymous', fields: { connect: 'anonymous' } },
atlas: { audience: 'anonymous', fields: {} },
// `name` is the ranked character's name inside points.board's `top` entries, and
// it is spelled the way the WIRE spells it, not the way v3.md §7.4 describes it
// ("characterName"). projectValue matches on the literal JSON key, so a rule
// named for the field's meaning rather than its key silently does nothing — the
// same failure §3.6.1 records for the flattened `ownerAcct` spelling. Within a
// leaderboards payload `name` can only be a character name: the board's own
// display name arrives as `nameString`/`nameNumber`.
leaderboards: { audience: 'anonymous', fields: { name: 'anonymous' } },
// Shop name, owner character name and vendor location are already globally
// visible in-game via the stock Vendor Search gump, so publishing them is not
// a new disclosure — but they stay configurable so an admin can tighten them.
//
// `ownerName` and `location` were pre-wired here by Part A, before the frame
// existed; both were re-checked against the real `vendor.listing` and both are
// genuine keys on it (unlike leaderboards' `characterName`, which was inert).
// `location` is a NESTED object on the wire and on the read model precisely so
// that one rule hides map, coordinates, region and house together — five flat
// keys would be five rules that drift apart.
//
// `ownerSerial` is listed alongside `ownerName` for the same reason `houses`
// lists both: an admin who hides the owner's name and is left with a serial
// that every other board resolves back to that name has not hidden anything.
market: {
audience: 'anonymous',
fields: { ownerName: 'anonymous', ownerSerial: 'anonymous', location: 'anonymous' },
},
}
const FEATURE_NAMES = Object.keys(FEATURES)
const isFeature = (name) => Object.hasOwn(FEATURES, name)
// ── Kind → feature ─────────────────────────────────────────────────────────
//
// Every event kind that may ever leave the admin channel must appear here.
// Anything else is admin-only by omission (rule 2). This map is seeded from
// what PUBLIC_KINDS listed before v3, so the public stream carries exactly the
// same kinds it did — now attributed to a feature that an admin can re-gate.
const KIND_FEATURE = new Map(
Object.entries({
// status / lifecycle
'server.hello': 'status',
'server.shutdown': 'status',
'server.crashed': 'status',
'economy.supply': 'status',
// activity feed
'player.death': 'activity',
'player.murdered': 'activity',
'mob.killed': 'activity',
'quest.complete': 'activity',
'skill.gain': 'activity',
'fame.change': 'activity',
'karma.change': 'activity',
'mob.login': 'activity',
'mob.logout': 'activity',
// boards
'champ.update': 'champs',
'champ.remove': 'champs',
'guild.update': 'guilds',
'guild.remove': 'guilds',
'guild.join': 'guilds',
'city.update': 'governors',
'presence.online': 'presence',
'region.enter': 'presence',
// house.decay is the IDOC signal the public Houses page renders. The full
// registry (house.update / house.remove — owner, price, co-owners) stays
// off the map deliberately, so it remains admin-only exactly as before.
'house.decay': 'houses',
// v3
'world.ruleset': 'ruleset',
'points.board': 'leaderboards',
// vendor.listing IS mapped, but the market feature ships with its stream
// disabled (see DEFAULT_STREAM_OFF): a live firehose of full vendor
// inventories would be the site's biggest bandwidth consumer and no page
// needs it live. An admin can turn it on.
'vendor.listing': 'market',
'vendor.listing.remove': 'market',
}),
)
// Features whose SSE fan-out is off unless an admin enables it. The REST reads
// are unaffected; only the live stream is suppressed.
const DEFAULT_STREAM_OFF = new Set(['market'])
// Back-compat: the set of kinds that reach an anonymous viewer under the default
// config. shardEvents `/feed` filtering and notificationStreams.js both consume
// this. Derived from the map above rather than hand-maintained, so the two can
// no longer drift.
const PUBLIC_KINDS = new Set(
[...KIND_FEATURE.entries()]
.filter(([, feature]) => {
if (DEFAULT_STREAM_OFF.has(feature)) return false
return FEATURES[feature].audience === 'anonymous'
})
.map(([kind]) => kind),
)
// ── Config (DB-backed, cached) ─────────────────────────────────────────────
const CONFIG_TTL_MS = 5000
let cache = null
let cachedAt = 0
// Merge a stored row over its compiled default. Unknown feature names in the DB
// are ignored (a stale row from a removed feature must not resurrect it), and an
// invalid rung falls back to the default rather than failing open.
function applyRow(name, row) {
const base = FEATURES[name]
const audience = isLevel(row?.audience) ? row.audience : base.audience
const fields = { ...base.fields }
for (const [field, level] of Object.entries(row?.fieldRules || {})) {
if (isLockedField(field)) continue // rule 1: not configurable
if (isLevel(level)) fields[field] = level
}
return {
enabled: row ? !!row.enabled : true,
audience,
fields,
stream: row?.stream == null ? !DEFAULT_STREAM_OFF.has(name) : !!row.stream,
}
}
function compileDefaults() {
const out = {}
for (const name of FEATURE_NAMES) out[name] = applyRow(name, null)
return out
}
// Read the config, cached briefly. Falls back to compiled defaults if the DB is
// unreachable — the defaults reproduce pre-v3 behavior, so a DB blip degrades to
// "what the site did before" rather than to "everything is public".
async function getConfig() {
const now = Date.now()
if (cache && now - cachedAt < CONFIG_TTL_MS) return cache
try {
const rows = await db.listAll()
const byName = new Map(rows.map((r) => [r.feature, r]))
const out = {}
for (const name of FEATURE_NAMES) out[name] = applyRow(name, byName.get(name))
cache = out
cachedAt = now
} catch (err) {
log.error('getConfig; falling back to defaults', err)
cache = cache || compileDefaults()
cachedAt = now
}
return cache
}
const invalidate = () => {
cache = null
cachedAt = 0
}
// ── Viewer level ───────────────────────────────────────────────────────────
//
// anonymous no session
// logged_in authenticated, no linked game account
// player authenticated with a linked game account
// staff admin | moderator — the same set as the existing `modAccess` gate.
// `editor` is a CONTENT role with no shard privilege today, so it
// resolves by link status like any other member; mapping it to staff
// here would silently widen what editors can see.
// admin admin
//
// Staff always satisfy the `player` rung (rank order guarantees it) even without
// a linked account, matching the existing rule that /player/* is role-agnostic
// self-service.
// Same TTL as the config cache: this decides a privilege rung, so an unlinked
// (or newly relinked) account must not keep the old answer for long. Anonymous,
// staff and admin callers short-circuit before this runs, so the lookup only
// costs a query on the logged-in-member path.
const LINK_TTL_MS = CONFIG_TTL_MS
const linkCache = new Map() // userId → { hasLink, at }
async function hasLinkedAccount(userId) {
const hit = linkCache.get(userId)
const now = Date.now()
if (hit && now - hit.at < LINK_TTL_MS) return hit.hasLink
let hasLink = false
try {
const links = await shardLinks.listForUser(userId)
hasLink = Array.isArray(links) && links.length > 0
} catch (err) {
log.warn('hasLinkedAccount failed; treating as unlinked', { message: err.message })
}
linkCache.set(userId, { hasLink, at: now })
return hasLink
}
// Drop a user's cached link status (called when a link is created or removed so
// the rung takes effect immediately rather than up to LINK_TTL_MS later).
const forgetUser = (userId) => linkCache.delete(userId)
async function viewerLevel(req) {
const viewer = req.user || auth.getUserFromRequest(req)
if (!viewer) return 'anonymous'
if (viewer.role === 'admin') return 'admin'
if (viewer.role === 'moderator') return 'staff'
return (await hasLinkedAccount(viewer.id)) ? 'player' : 'logged_in'
}
// ── Enforcement ────────────────────────────────────────────────────────────
// Route gate. 404 when the feature is disabled (do not leak that it exists);
// 403 when it exists but the viewer sits below its audience. Stashes the
// resolved level on the request so controllers can project without re-resolving.
function requireFeature(name) {
return async (req, res, next) => {
try {
const config = await getConfig()
const feature = config[name]
if (!feature || !feature.enabled) return res.status(404).json({ message: 'Not Found' })
const level = await viewerLevel(req)
req.viewerLevel = level
if (!meets(level, feature.audience)) return res.status(403).json({ message: 'Forbidden' })
return next()
} catch (err) {
log.error(`requireFeature(${name})`, err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
}
// Strip the fields a viewer at `level` may not see. Applies the locked rules
// first (so acct/webId can never survive below admin), then the feature's
// configured field rules. Recurses into arrays and nested objects because the
// sensitive fields sit inside actor sub-objects (guild.leader, city.governor).
// Only ARRAYS and PLAIN objects are walked. A Date, Buffer or other class
// instance is a value, not a bag of fields: rebuilding one key-by-key would
// return `{}` (a Date has no enumerable own properties), which is how the DB-
// backed read models — whose rows carry real Date columns — differ from the
// pure-JSON wire frames the projection was first written against.
const isPlainObject = (v) => {
if (v === null || typeof v !== 'object') return false
const proto = Object.getPrototypeOf(v)
return proto === Object.prototype || proto === null
}
function projectValue(value, rules, level) {
if (Array.isArray(value)) return value.map((v) => projectValue(v, rules, level))
if (!isPlainObject(value)) return value
const out = {}
for (const [key, v] of Object.entries(value)) {
// Locked fields are checked by meaning first, so no configured rule (and no
// flattened spelling) can widen them past `admin`.
const required = isLockedField(key) ? 'admin' : rules[key]
if (required && !meets(level, required)) continue
out[key] = projectValue(v, rules, level)
}
return out
}
// Project a payload for one feature. `level` defaults to admin-equivalent only
// when explicitly passed; callers should always pass a resolved level.
function projectFeature(name, payload, level, config) {
const feature = config?.[name]
const rules = { ...LOCKED_FIELDS, ...(feature ? feature.fields : {}) }
return projectValue(payload, rules, level)
}
// Convenience for controllers: resolve config once, project, return.
async function project(name, payload, req) {
const config = await getConfig()
const level = req.viewerLevel || (await viewerLevel(req))
return projectFeature(name, payload, level, config)
}
// Is this event kind allowed to reach a viewer at `level`? Fail closed on an
// unmapped kind (rule 2), and honour both the feature gate and its stream flag.
function kindVisibleTo(kind, level, config) {
if (level === 'admin') return true
const name = KIND_FEATURE.get(kind)
if (!name) return false // rule 2: unmapped ⇒ admin-only
const feature = config?.[name]
if (!feature || !feature.enabled || !feature.stream) return false
return meets(level, feature.audience)
}
// The event kinds a viewer at `level` may read under the CURRENT config. This is
// the live counterpart of PUBLIC_KINDS, which is a module-load constant derived
// from the compiled DEFAULTS and therefore cannot answer "may THIS viewer see
// this kind, given what the admin has configured?".
//
// Deliberately ignores the `stream` flag: that governs SSE fan-out only, so a
// feature whose live firehose is off (market) is still readable from the stored
// history. Unmapped kinds are absent by construction (rule 2).
function visibleKinds(level, config) {
return [...KIND_FEATURE.entries()]
.filter(([, name]) => {
const feature = config?.[name]
return !!feature && feature.enabled && meets(level, feature.audience)
})
.map(([kind]) => kind)
}
// The features a viewer at `level` can actually see — drives SPA nav so it never
// renders a link that would 403.
function visibleFeatures(level, config) {
return FEATURE_NAMES.filter((name) => {
const feature = config[name]
return feature.enabled && meets(level, feature.audience)
})
}
module.exports = {
LADDER,
FEATURES,
FEATURE_NAMES,
LOCKED_FIELDS,
KIND_FEATURE,
PUBLIC_KINDS,
DEFAULT_STREAM_OFF,
isLevel,
isFeature,
isLockedField,
rank,
meets,
getConfig,
invalidate,
compileDefaults,
viewerLevel,
forgetUser,
requireFeature,
projectFeature,
project,
kindVisibleTo,
visibleKinds,
visibleFeatures,
}

View File

@@ -0,0 +1,24 @@
{
"_comment": [
"OPTIONAL operator-supplied creature art for the spawn atlas. Copy this file to",
"spawnAtlas.art.json (same directory) and edit it, then restart the server or run",
"`npm run atlas:import` — the art map is read on every atlas refresh.",
"",
"This project ships NO creature artwork and never will. UO sprites live in your",
"own client's .mul/.uop files and are yours to extract, not ours to redistribute.",
"If you want art on the atlas pages, export it yourself (UOFiddler, ClassicUO's",
"tooling, or any art extractor), drop the images under server/uploads/atlas/, and",
"map each creature slug to its file name here.",
"",
"Both spawnAtlas.art.json and server/uploads/ are gitignored, so neither the map",
"nor the images can be committed by accident.",
"",
"Keys are creature slugs, as reported by the atlas API and derived from the type",
"names in your own shard's Spawns/*.xml. Values are file names relative to",
"server/uploads/atlas/. Any creature with no entry here simply renders without",
"art — that is the default and fully supported state, not a degraded one."
],
"lizardman": "lizardman.png",
"orc": "orc.png",
"dragon": "dragon.png"
}

View File

@@ -1,13 +1,6 @@
-- Runic Gateway database schema (MariaDB)
-- Run automatically by the MariaDB container (docker-entrypoint-initdb.d) on a
-- fresh volume, and idempotently by ensureSchema() on every server boot.
--
-- CORE ONLY. The 27 shard_* / uo_link_* tables left with module-uo in Phase 3
-- and live in its schema fragment, which core replays immediately after this
-- file (MODULE_API.md 2.6). Two of them carry a foreign key INTO users, which
-- is why that order matters and why the reverse -- a core table referencing a
-- module table -- must never appear here: it would make core unable to boot
-- without a module installed.
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -350,11 +343,395 @@ CREATE TABLE IF NOT EXISTS email_config (
CONSTRAINT chk_email_config_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── uo-link sidecar ────────────────────────────────────────────────────────
-- Connection config for the uo-link sidecar (the HTTP + WebSocket bridge to the
-- ServUO shard). Singleton row (id = 1), mirroring bot_config/email_config: the
-- DB only ever holds the AES-256-GCM-encrypted shared-secret auth token, never
-- plaintext, and it is only decrypted server-side (to call the sidecar). It is
-- never returned to the admin UI — responses expose only `hasToken`. base_url is
-- the REST endpoint, ws_url the live-feed endpoint; both are configurable because
-- in production the sidecar runs on a different host from the website. `status`/
-- `plugin_connected`/`last_event_at`/`boot_id` mirror the sidecar's last-known
-- state for the admin panel between polls; `boot_id` tracks server.hello.bootId
-- so a shard restart can be detected (and caches dropped).
CREATE TABLE IF NOT EXISTS uo_link_config (
id INT PRIMARY KEY DEFAULT 1,
base_url VARCHAR(255) NULL,
ws_url VARCHAR(255) NULL,
auth_token_enc TEXT NULL,
protocol INT NOT NULL DEFAULT 3,
enabled TINYINT(1) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
status_detail VARCHAR(500) NULL,
plugin_connected TINYINT(1) NOT NULL DEFAULT 0,
last_event_at DATETIME NULL,
boot_id VARCHAR(64) NULL,
updated_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_uo_link_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT chk_uo_link_config_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Append-only log of notable shard events ingested from the uo-link WebSocket
-- feed. The site OWNS this data (it does not query the sidecar's SQLite): the WS
-- client writes here, and the public/admin read endpoints + live feeds read from
-- here. Only "notable" kinds are logged (sales, deaths, murders, mob.killed,
-- IDOC transitions, quests, skill.gain, fame/karma, audit.*, cheat.*, link.*,
-- server.*). High-frequency kinds (char.vitals, economy.supply) are NOT logged
-- here — they update shard_online / shard_economy instead, keeping the log lean.
-- dedupe_key = sha256(kind + t + stable-json(payload)) truncated to 40 hex chars
-- (fits CHAR(40)); with the UNIQUE index it makes INSERT IGNORE idempotent so
-- WS-reconnect backfill never double-inserts.
CREATE TABLE IF NOT EXISTS shard_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
kind VARCHAR(48) NOT NULL,
t BIGINT NOT NULL, -- event time, epoch ms (from the sidecar)
boot_id VARCHAR(64) NULL, -- shard boot id at ingest (server.hello.bootId)
payload JSON NOT NULL, -- the full event object
dedupe_key CHAR(40) NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_shard_events_kind_t (kind, t),
INDEX idx_shard_events_t (t)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Current online players. Upserted on mob.login, refreshed on char.vitals, and
-- removed on mob.logout. Cleared wholesale when the shard restarts (a new
-- server.hello.bootId). web_id is the linked website user id (present when the
-- account is linked), so the roster can be correlated to site accounts.
CREATE TABLE IF NOT EXISTS shard_online (
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- mobile serial (opaque hex key)
name VARCHAR(120) NULL,
acct VARCHAR(120) NULL,
web_id INT NULL,
map VARCHAR(40) NULL,
x INT NULL,
y INT NULL,
z INT NULL,
hits INT NULL,
hits_max INT NULL,
mana INT NULL,
mana_max INT NULL,
stam INT NULL,
stam_max INT NULL,
str INT NULL,
dex INT NULL,
`int` INT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_shard_online_acct (acct)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Total-gold-supply time series (from the periodic economy.supply event). Kept
-- append-only so the public status page can render a supply-over-time sparkline.
CREATE TABLE IF NOT EXISTS shard_economy (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
accounts INT NULL, -- number of accounts included in the total
gold BIGINT NULL, -- total gold supply across all accounts
t BIGINT NOT NULL, -- sample time, epoch ms
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_shard_economy_t (t)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Current decay stage per house, upserted on house.decay. is_idoc is a derived
-- flag (stage == 'IDOC') so the public "houses in danger" list is a cheap
-- indexed lookup rather than a scan.
CREATE TABLE IF NOT EXISTS shard_houses (
serial VARCHAR(20) NOT NULL PRIMARY KEY,
stage VARCHAR(24) NULL, -- Somewhat | Fairly | Greatly | IDOC | Collapsed | ...
map VARCHAR(40) NULL,
x INT NULL,
y INT NULL,
z INT NULL,
region VARCHAR(120) NULL,
name VARCHAR(160) NULL,
owner_serial VARCHAR(20) NULL,
owner_acct VARCHAR(120) NULL,
built_on DATETIME NULL,
last_refreshed DATETIME NULL,
is_idoc TINYINT(1) NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_shard_houses_idoc (is_idoc)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Site-side mirror of in-game-account → website-user links. The sidecar is the
-- source of truth (it tags the game account with the websiteUserId on
-- /link/confirm); this table mirrors it so the player portal can list a user's
-- linked accounts and enforce ownership on roster/vendor reads without a shard
-- round-trip. account is unique (one game account maps to at most one site user);
-- a single user may link several game accounts.
CREATE TABLE IF NOT EXISTS shard_account_links (
account VARCHAR(120) NOT NULL PRIMARY KEY,
user_id INT NOT NULL,
char_name VARCHAR(120) NULL,
linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_shard_links_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_shard_links_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Current champion-spawn board, upserted on champ.update and removed on
-- champ.remove. Mirrors the sidecar's /champs projection into our own store so
-- the public Champions page (and its live deltas) survive a shard outage, the
-- same way shard_online / shard_houses do. Three families share one table, told
-- apart by `category` (champion | mini | sea); category-specific fields (level,
-- kills, boss, restartAt, hits, …) live in the JSON `payload` so the schema does
-- not have to model every variant.
CREATE TABLE IF NOT EXISTS shard_champs (
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- controller/mobile serial (opaque hex)
category VARCHAR(16) NULL, -- champion | mini | sea
type VARCHAR(80) NULL,
name VARCHAR(120) NULL,
status VARCHAR(16) NULL, -- active | cooldown | dormant
active TINYINT(1) NOT NULL DEFAULT 0,
map VARCHAR(40) NULL,
x INT NULL,
y INT NULL,
z INT NULL,
boss_up TINYINT(1) NOT NULL DEFAULT 0,
payload JSON NOT NULL, -- the full champ.update object
t BIGINT NULL, -- event time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_shard_champs_category (category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Current open help-page (support ticket) queue, upserted on page.new/page.updated
-- and removed on page.closed. Snapshotted authoritatively from the sidecar's
-- GET /pages on every (re)connect. page_id is the sender's serial (one page per
-- player). Staff-only data — served on the admin channel, never public.
CREATE TABLE IF NOT EXISTS shard_pages (
page_id VARCHAR(20) NOT NULL PRIMARY KEY, -- sender serial (one page per player)
type VARCHAR(40) NULL, -- Bug | Stuck | Account | Question | ...
sender_name VARCHAR(120) NULL,
sender_acct VARCHAR(120) NULL,
web_id INT NULL, -- linked website user id, if any
message TEXT NULL,
map VARCHAR(40) NULL,
x INT NULL,
y INT NULL,
z INT NULL,
sent_ms BIGINT NULL, -- when the page was opened, epoch ms
handled TINYINT(1) NOT NULL DEFAULT 0, -- a staffer claimed it in game
handler VARCHAR(120) NULL,
payload JSON NOT NULL, -- the full page.new/updated object
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_shard_pages_handled (handled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Guild roster board (Protocol 2.0). Upserted on guild.update (a full-state
-- snapshot emitted only on change) and removed on guild.remove. The leader is an
-- actor object flattened into leader_* columns; the full event is kept in
-- `payload` for anything not hoisted. Mirrors the sidecar's GET /guilds
-- projection into our store so the public Guilds page survives a shard outage.
CREATE TABLE IF NOT EXISTS shard_guilds (
id INT NOT NULL PRIMARY KEY, -- in-game guild id
name VARCHAR(120) NULL,
abbr VARCHAR(24) NULL,
members INT NULL,
online INT NULL,
alliance VARCHAR(120) NULL,
leader_serial VARCHAR(20) NULL,
leader_name VARCHAR(120) NULL,
leader_acct VARCHAR(120) NULL,
leader_web_id INT NULL,
payload JSON NOT NULL, -- the full guild.update object
t BIGINT NULL, -- event time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_shard_guilds_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on
-- city.update (full-state, emitted only on change; there is no remove event since
-- the set of cities is fixed). governor / governorElect are actor objects
-- flattened into columns; the full event is kept in `payload`. Empty on shards
-- that do not run the City Loyalty system.
CREATE TABLE IF NOT EXISTS shard_governors (
city VARCHAR(40) NOT NULL PRIMARY KEY, -- Britain | Moonglow | ...
governor_serial VARCHAR(20) NULL,
governor_name VARCHAR(120) NULL,
governor_acct VARCHAR(120) NULL,
governor_web_id INT NULL,
elect_serial VARCHAR(20) NULL,
elect_name VARCHAR(120) NULL,
elect_acct VARCHAR(120) NULL,
election_phase VARCHAR(16) NULL, -- none | nominate | vote | pending
candidates INT NULL,
auto_pick_at DATETIME NULL,
payload JSON NOT NULL, -- the full city.update object
t BIGINT NULL, -- event time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Governor term history — the "who governed when" ledger behind the Governors
-- board. Captured from day one (history cannot be backfilled) on every observed
-- governor CHANGE: the open term (ended_at IS NULL) is closed and a new one
-- opened. `votes` stays NULL — the city.update feed exposes only the candidate
-- COUNT and election phase, not per-candidate tallies, so we record who governed
-- and when (reliable) and never fabricate vote numbers. The look-back UI ("who
-- were all the governors of Britain?") reads this table.
CREATE TABLE IF NOT EXISTS shard_governor_terms (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
city VARCHAR(40) NOT NULL,
governor_serial VARCHAR(20) NULL,
governor_name VARCHAR(120) NULL,
governor_acct VARCHAR(120) NULL,
governor_web_id INT NULL,
started_at BIGINT NOT NULL, -- term start, epoch ms
ended_at BIGINT NULL, -- term end epoch ms (NULL = current)
votes INT NULL, -- not in the feed (reserved)
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_shard_gov_terms_city (city, started_at),
INDEX idx_shard_gov_terms_open (city, ended_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Online-population snapshot (Protocol 2.0). Singleton row (id = 1) holding the
-- latest presence.online aggregate: total count plus per-facet and per-region
-- breakdown maps (stored as JSON). Distinct from shard_online (per-player) — this
-- is the rolled-up headcount the public "Players Online" widget renders. The
-- time series, if ever needed, is available from GET /history?kind=presence.online.
CREATE TABLE IF NOT EXISTS shard_presence (
id INT PRIMARY KEY DEFAULT 1,
count INT NOT NULL DEFAULT 0,
by_facet JSON NULL, -- { "Felucca": 12, "Trammel": 30 }
by_region JSON NULL, -- { "Britain": 18, "Wilderness": 9 }
t BIGINT NULL, -- snapshot time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The shard's published ruleset (Protocol 3.0 world.ruleset). Singleton row
-- (id = 1) holding the latest frame: expansion, which optional systems are on,
-- skill/stat caps, account and house limits, champion scroll rules, the
-- save/restart schedule. The shard re-emits it on every sidecar connect, so this
-- row is simply overwritten; `rev` is the shard's own FNV-1a of the body, which
-- distinguishes "same ruleset, re-sent on reconnect" from "an operator changed a
-- .cfg". No row at all means the shard has never published one — served as null,
-- which the rules page renders differently from a published ruleset.
CREATE TABLE IF NOT EXISTS shard_ruleset (
id INT PRIMARY KEY DEFAULT 1,
rev VARCHAR(32) NULL,
expansion VARCHAR(16) NULL, -- hoisted for cheap display
payload JSON NOT NULL, -- the whole world.ruleset frame
t BIGINT NULL, -- frame time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_shard_ruleset_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Points/loyalty leaderboards (Protocol 3.0 points.board). One row per point
-- system, keyed by the shard's own PointsType name. The shard publishes ~25 of
-- these (Queen's Loyalty, Void Pool, the nine city loyalties, …), each a standing
-- players accumulate over months.
--
-- The top-N list stays inside `payload` rather than being normalized into a
-- shard_points_entries table. It is a fixed-size list (10 by default) that is only
-- ever read whole, exactly like shard_governors.candidates — normalizing it would
-- buy nothing until something needs a per-character reverse lookup, and a
-- character's own standings already ride inside char.profile instead.
--
-- No delete path: the shard's set of systems is fixed at startup, so there is no
-- points.remove to mirror.
CREATE TABLE IF NOT EXISTS shard_points_boards (
system VARCHAR(48) PRIMARY KEY, -- PointsType name, e.g. QueensLoyalty
name VARCHAR(128) NULL, -- resolved display name, if the shard sent a literal
name_cliloc INT NULL, -- cliloc id when the name is a TextDefinition number
max_points BIGINT NULL,
players INT NULL, -- players actually holding points in this system
show_on_gump TINYINT(1) NOT NULL DEFAULT 1, -- the shard's own "is this player-facing?" flag
payload JSON NOT NULL, -- the whole points.board frame, incl. `top`
t BIGINT NULL, -- frame time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Player-vendor market index (Protocol 3.0 vendor.listing). One row per player
-- vendor and one per priced listing, so the site can offer the search the in-game
-- Vendor Search gump offers — from outside the game.
--
-- The shard sweeps vendors round-robin and emits one AUTHORITATIVE frame per
-- vendor, so ingest is delete-then-insert of that vendor's items inside one
-- transaction (see shardMarket.db.js). No foreign key from items to vendors, in
-- keeping with every other shard_* table: the ingest transaction is what keeps
-- them consistent, and an FK would turn a malformed frame into a failed write
-- rather than a dropped row.
--
-- Only vendors whose owner left the in-game Vendor Search flag ON are ever sent,
-- so a player who hid their shop in game is hidden here too — see BridgeMarket.cs.
CREATE TABLE IF NOT EXISTS shard_vendors (
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- "0x40001234"
shop_name VARCHAR(160) NULL,
owner_serial VARCHAR(20) NULL,
owner_name VARCHAR(64) NULL,
map VARCHAR(40) NULL,
x INT NULL,
y INT NULL,
z INT NULL,
region VARCHAR(80) NULL,
house VARCHAR(160) NULL, -- the house SIGN's name, not the house type
item_count INT NOT NULL DEFAULT 0, -- listings published in the frame
item_total INT NOT NULL DEFAULT 0, -- listings the shop actually holds
truncated TINYINT(1) NOT NULL DEFAULT 0, -- item_total > item_count
t BIGINT NULL, -- frame time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_shard_vendors_owner (owner_name),
INDEX idx_shard_vendors_map (map),
INDEX idx_shard_vendors_region (region),
-- The market page's staleness banner is MIN(updated_at) over this column: the
-- round-robin sweep means the oldest row is how far behind the index can be.
INDEX idx_shard_vendors_updated (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- One priced listing. Unlike the points board's top-N — a fixed-size list read
-- whole — these are the searchable rows the whole feature exists for, so they are
-- normalized rather than left inside a payload column, and there is no payload
-- column on shard_vendors at all.
--
-- `display_name` is DENORMALIZED at ingest: the shard sends `cliloc` (the item's
-- LabelNumber) and, rarely, a literal `name`, and resolving 50 clilocs per page
-- at query time would make the cliloc table a join on the hot path AND make
-- search-by-name impossible. Resolving once on write buys the index. It is
-- re-resolved in bulk after a cliloc import, because the diff sweep will not
-- re-send an unchanged shop just because the site learned what its items are
-- called.
CREATE TABLE IF NOT EXISTS shard_vendor_items (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
vendor_serial VARCHAR(20) NOT NULL,
serial VARCHAR(20) NOT NULL,
item_id INT NOT NULL DEFAULT 0, -- ItemID (the art/graphic id)
hue INT NOT NULL DEFAULT 0,
amount INT NOT NULL DEFAULT 1,
price BIGINT NOT NULL DEFAULT 0,
name VARCHAR(160) NULL, -- the item's literal Name, null for most
cliloc INT NULL, -- LabelNumber, resolved against shard_clilocs
display_name VARCHAR(160) NULL, -- resolved at ingest; what search matches
child TINYINT(1) NOT NULL DEFAULT 0, -- priced by an enclosing container, not itself
INDEX idx_shard_vendor_items_vendor (vendor_serial),
INDEX idx_shard_vendor_items_price (price),
INDEX idx_shard_vendor_items_item (item_id),
INDEX idx_shard_vendor_items_name (display_name),
-- Search filters on name and sorts on price; the composite covers the common
-- "cheapest matching X" without a filesort over the whole table.
INDEX idx_shard_vendor_items_name_price (display_name, price)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Per-feature visibility for every shard-derived surface (Protocol 3.0). One row
-- per feature; an absent row means "use the compiled default", and the compiled
-- defaults reproduce the behavior that shipped before v3 — so an empty table is
-- a no-op. See utils/shardVisibility.js for the catalog and the ladder, and
-- docs/link/v3.md §3 for the contract.
--
-- audience the minimum rung on anonymous < logged_in < player < staff < admin
-- stream whether this feature's kinds fan out over SSE at all (the market
-- index ships with this off: no page needs a live firehose of
-- whole vendor inventories)
-- field_rules {"<field>": "<rung>"} for SENSITIVE fields only. `acct` and
-- `webId` are admin-only always and are rejected here — they are
-- not in-game visible and are deliberately not configurable.
CREATE TABLE IF NOT EXISTS shard_feature_visibility (
feature VARCHAR(48) NOT NULL PRIMARY KEY,
enabled TINYINT(1) NOT NULL DEFAULT 1,
audience VARCHAR(20) NOT NULL DEFAULT 'anonymous',
stream TINYINT(1) NOT NULL DEFAULT 1,
field_rules JSON NULL,
updated_by INT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Admin email invites (Protocol 2.0 provisioning). A staff member invites someone
-- by email at a pre-chosen access level; the invitee accepts via a tokened link,
@@ -729,134 +1106,66 @@ CREATE TABLE IF NOT EXISTS pages (
-- Announcement pipeline. One row per publish event of a news post; the table
-- doubles as the job queue (a light in-process poller — utils/announceWorker.js
-- — sweeps it for due legs). `status` is a derived rollup of the legs (see
-- announceJobs.logic.js): done when every leg is done, failed when every leg is
-- exhausted, partial in between. post_id is INT (matches posts.id) and cascades
-- so deleting a post reaps its jobs. posts.announce_job_id points back at the
-- latest row for admin lookups.
-- — sweeps it for due legs). Two INDEPENDENT delivery legs so a Discord outage
-- never blocks or retries the in-game town-crier leg and vice versa. `status` is
-- a derived rollup of the two legs (see announceJobs.logic.js): done when both
-- legs done, failed when both exhausted, partial in between. Each leg tracks its
-- own attempt count, last error, and next-due time for exponential backoff.
-- post_id is INT (matches posts.id) and cascades so deleting a post reaps its
-- jobs. posts.announce_job_id points back at the latest row for admin lookups.
CREATE TABLE IF NOT EXISTS announce_jobs (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
status ENUM('pending','partial','done','failed') NOT NULL DEFAULT 'pending',
towncrier_status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
towncrier_attempts SMALLINT NOT NULL DEFAULT 0,
towncrier_last_error TEXT NULL,
towncrier_next_attempt_at DATETIME NULL,
discord_status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
discord_attempts SMALLINT NOT NULL DEFAULT 0,
discord_last_error TEXT NULL,
discord_next_attempt_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_announce_jobs_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
CONSTRAINT fk_announce_jobs_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
INDEX idx_announce_due (towncrier_status, towncrier_next_attempt_at),
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- One row per delivery leg per job. INDEPENDENT by design: a Discord outage never
-- blocks or retries another leg, and each leg tracks its own attempt count, last
-- error and next-due time for exponential backoff.
-- UO's localization table: cliloc id -> display string. Items carry a
-- `LabelNumber` rather than a name, so without this the site can only render
-- `id 1023721` where the game shows "quarter staff". The shard has always sent
-- the id (char.profile's `cliloc`, and one per marketplace listing) — the number
-- was never the missing piece, the table was.
--
-- This is a child table rather than a pair of leg-prefixed column groups on
-- announce_jobs because the leg set is DATA now, not schema: core registers
-- `discord`, module-uo registers `towncrier`, and a module for another game
-- registers its own — through modules/registries.js's registerAnnounceLeg
-- (MODULE_SYSTEM.md §1.8). A module cannot ALTER a core table, so a leg that
-- needed its own columns could never come from a module at all. `leg` is a plain
-- VARCHAR and not an ENUM for the same reason.
CREATE TABLE IF NOT EXISTS announce_job_legs (
job_id INT NOT NULL,
leg VARCHAR(64) NOT NULL,
status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
attempts SMALLINT NOT NULL DEFAULT 0,
last_error TEXT NULL,
next_attempt_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (job_id, leg),
CONSTRAINT fk_announce_job_legs_job FOREIGN KEY (job_id) REFERENCES announce_jobs(id) ON DELETE CASCADE,
INDEX idx_announce_leg_due (status, next_attempt_at)
-- Sourced from a file the OPERATOR converts once from their own UO client and
-- points the site at (docs/website/CLILOCS.md); nothing derived from the client
-- is committed, the same rule the spawn atlas and the creature art map follow.
-- A shard with no cliloc file configured simply renders item ids, which is what
-- it did before this table existed.
--
-- `text` is TEXT, not VARCHAR: real tables top out around 12 KB for the long
-- property descriptions, and truncating them silently would be worse than
-- storing them. Item NAMES are all short — the index that matters for search is
-- on the denormalized `shard_vendor_items.display_name`, not here.
CREATE TABLE IF NOT EXISTS shard_clilocs (
number INT NOT NULL PRIMARY KEY,
flag SMALLINT NOT NULL DEFAULT 0,
text TEXT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Carry the two hardcoded leg column groups over to the child table, once. Guarded
-- on the OLD columns still existing (via information_schema, since a plain SELECT
-- of a dropped column is a parse error, not a runtime one) and on there being no
-- row already, so replaying this file on every boot is a no-op after the first.
-- Deleting this block once every deployment has booted it is safe.
SET @has_legacy_legs := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'announce_jobs'
AND COLUMN_NAME = 'towncrier_status'
);
SET @sql := IF(@has_legacy_legs > 0,
'INSERT IGNORE INTO announce_job_legs (job_id, leg, status, attempts, last_error, next_attempt_at)
SELECT id, ''towncrier'', towncrier_status, towncrier_attempts, towncrier_last_error, towncrier_next_attempt_at FROM announce_jobs
UNION ALL
SELECT id, ''discord'', discord_status, discord_attempts, discord_last_error, discord_next_attempt_at FROM announce_jobs',
'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- MariaDB's IF EXISTS makes this idempotent, so it replays cleanly like the rest
-- of the file. It is the one DROP in core's schema, and it is deliberate: leaving
-- the columns would leave `towncrier` in a core file, which Phase 3's acceptance
-- grep forbids (MODULE_SYSTEM.md §2.7).
ALTER TABLE announce_jobs
DROP COLUMN IF EXISTS towncrier_status,
DROP COLUMN IF EXISTS towncrier_attempts,
DROP COLUMN IF EXISTS towncrier_last_error,
DROP COLUMN IF EXISTS towncrier_next_attempt_at,
DROP COLUMN IF EXISTS discord_status,
DROP COLUMN IF EXISTS discord_attempts,
DROP COLUMN IF EXISTS discord_last_error,
DROP COLUMN IF EXISTS discord_next_attempt_at,
DROP INDEX IF EXISTS idx_announce_due,
DROP INDEX IF EXISTS idx_announce_due_discord;
-- ── Spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
-- Static shard CONTENT, not live shard state: what spawns where, which regions
-- and landmarks exist, and which champion altars are configured. Nothing here
-- comes from the sidecar — it is imported from a committed artifact built off a
-- ServUO tree by `npm run atlas:build` (see docs/website/SPAWN_ATLAS.md), so
-- these tables stay populated whether the shard is up or not.
--
-- Every table is import-owned: `npm run atlas:import` TRUNCATEs and reloads them
-- in one transaction. Nothing else may write here, and nothing else may hold a
-- foreign key to them. No FKs at all, consistent with every other shard_* table.
-- One row per spawnable type, aggregated across the world. `total` is the sum of
-- each type's own MX across every point that spawns it (how many exist at once);
-- Installed modules (module system, docs/website/MODULE_SYSTEM.md §2.4). One row
-- per module the operator has installed onto the modules volume, keyed by the
-- module id from its module.json — the same id that names the directory, the URL
-- segment and the client registry key.
--
-- This table is a RECORD of what happened, never the source of truth for what is
-- mounted: the loader scans the filesystem at require time, before the database is
-- reachable (MODULE_API.md §4.1), so the URL surface is a property of the volume
-- and not of a row here. What the row decides is whether a mounted module answers
-- (`disabled` ⇒ its guard 404s, §4.5) and what the admin panel shows after a
-- failure.
--
-- `state` is the §2.4 machine in one column: installed → enabled → started, with
-- disabled and startup_failed as the recoverable states. `installed` is the
-- transient state between an install writing the row and the restart that starts
-- it. On every boot each non-disabled row is reset to `enabled` and re-attempted
-- (so a fixed module recovers on restart, with no panel visit needed), then the
-- load outcome writes `started` or `startup_failed`. Only `disabled` survives a
-- boot untouched — it is the operator's decision, not an outcome.
--
-- failure_stage/failure_reason are §4.4's recorded reason, one of the seven
-- validation steps of §4.3 plus `boot`. Both are cleared by every transition that
-- is not a failure, so a stale reason can never be shown against a running module.
--
-- source/sha256 are install provenance (§2.5): the release the bundle came from and
-- the digest that was verified before unpacking. Both NULL for a directory placed
-- on the volume by hand, which stays supported.
CREATE TABLE IF NOT EXISTS installed_modules (
id VARCHAR(32) NOT NULL PRIMARY KEY, -- module.json id; names the directory
name VARCHAR(128) NOT NULL, -- human label for the admin Modules screen
version VARCHAR(32) NOT NULL, -- module.json version (semver)
state ENUM('installed','enabled','disabled','started','startup_failed')
NOT NULL DEFAULT 'installed',
failure_stage VARCHAR(32) NULL, -- manifest|core_api|mounts|extensions|schema|require|register|boot
failure_reason TEXT NULL, -- the recorded reason, shown in the admin panel
source VARCHAR(255) NULL, -- release URL the bundle came from
sha256 CHAR(64) NULL, -- verified bundle digest
installed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME NULL, -- last successful start
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_installed_modules_state (state)
-- Singleton (id = 1) describing the cliloc table currently loaded: the source
-- file, its sha256, the entry count and the parser version. The boot path
-- compares the stored hash against the file on disk and skips the parse when
-- they match, which is every restart that did not follow a client patch.
CREATE TABLE IF NOT EXISTS shard_cliloc_meta (
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
payload JSON NOT NULL,
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_shard_cliloc_meta_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses
@@ -910,6 +1219,23 @@ ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title,
ALTER TABLE posts ADD COLUMN IF NOT EXISTS announced_at DATETIME NULL;
ALTER TABLE posts ADD COLUMN IF NOT EXISTS announce_job_id INT NULL;
-- House registry (Protocol 2.0). The house.update full-state feed carries richer
-- fields than the house.decay transition feed shard_houses was built for. Rather
-- than a second table for one entity, extend shard_houses: house.update writes the
-- registry columns below (owner display name, co-owner/friend counts, placement
-- price, decay level name) while house.decay keeps owning `stage`/`is_idoc`. Each
-- upsert only touches its own columns, so the two feeds never clobber each other.
-- `price` is the placement value, NOT a "for sale" flag (stock ServUO has none).
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS owner_name VARCHAR(120) NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS co_owners INT NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS friends INT NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS price BIGINT NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay VARCHAR(24) NULL;
-- Distinguishes a full registry row (seen via house.update) from a decay-only row,
-- so the public Houses browser can list registered houses without pulling in rows
-- we only ever saw an IDOC transition for.
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NULL DEFAULT 0;
-- Mobile device sessions (M9): a friendly label the app may send at login, and
-- the last time this session token was issued/used, for the "Active Devices"
-- self-service list. Both nullable and additive; existing rows get them here.
@@ -921,4 +1247,19 @@ ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME
-- trust token. A boolean only — the token is returned over that app→server call
-- and never persisted here (only its sha256 lands in trusted_devices).
ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0;
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
-- Protocol 3.0 cutover: this build speaks wire protocol 3 (world.ruleset,
-- points.board, vendor.listing), so the pinned version an existing install
-- carries has to move with it — a 2 against a v3 sidecar 409s every REST call
-- and closes the WS on ws.hello. MODIFY fixes the column default for installs
-- created before the bump (idempotent, like the other MODIFYs here).
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 3;
-- The row itself is admin-editable, and schema.sql runs on EVERY boot, so this
-- must be one-shot: an operator who deliberately pins an older sidecar in
-- Admin → Shard has to stay pinned. The marker row in `settings` is what makes
-- it fire once — written after the UPDATE, and on a fresh install (no
-- uo_link_config row yet) it is simply written with nothing to update.
UPDATE uo_link_config SET protocol = 3
WHERE id = 1 AND protocol < 3
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_3_migrated');
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');

Some files were not shown because too many files have changed in this diff Show More