diff --git a/.dockerignore b/.dockerignore index 0431799..8b1c180 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,5 +10,8 @@ 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 diff --git a/.env.example b/.env.example index 31c49cc..520c30f 100644 --- a/.env.example +++ b/.env.example @@ -33,8 +33,8 @@ LOG_FILE=app.log # BRAND_NAME / BRAND_CONTACT_EMAIL. BRAND_NAME=Runic Gateway BRAND_SHORT_NAME=Runic Gateway -BRAND_TAGLINE=an independent private Ultima Online shard -BRAND_DESCRIPTION=Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes. +BRAND_TAGLINE=an independent game community +BRAND_DESCRIPTION=Runic Gateway — an independent game community. News, screenshots, guides, and community notes. BRAND_CONTACT_EMAIL= BRAND_URL= # Accent color — drives the web theme's --accent and the Discord embed color. @@ -107,20 +107,32 @@ CLIENT_ORIGIN=http://localhost:5173 BOT_INTERNAL_URL=http://bot:4100 BOT_INTERNAL_KEY=change-me-to-a-long-random-string -# uo-link sidecar — the HTTP + WebSocket bridge to the ServUO game server. The -# website ingests its live event feed and proxies its read queries/commands -# (shard status, online players, player-vendor sales, IDOC houses, character -# sheets, account linking, town-crier). In production the sidecar + shard run on -# a DIFFERENT host from the website, so both URLs are configurable. The -# shared-secret auth token is NOT an env var — it is entered in the admin panel -# (Shard page) and stored encrypted in the DB (same pattern as the Discord bot -# token). These URLs are just defaults; the admin can override them at runtime. -UOLINK_BASE_URL=http://127.0.0.1:8080 -UOLINK_WS_URL=ws://127.0.0.1:8080/ws -# Wire protocol this build speaks (3 = Protocol 3.0). Only a fallback for a site -# with nothing saved yet — the admin panel's pinned value wins — but set it lower -# if you deliberately run an older sidecar. -UOLINK_PROTOCOL=3 +# ─── Installed modules ─── +# A module is a directory on the modules volume (see MODULES_DIR in +# server/.env.example); everything about a specific game lives in one, and core +# knows nothing about any of them. A module may read its own env vars, and they +# belong here because Compose passes this file to the container. +# +# MODULES declares the set this deployment runs, and the container arrives at it +# on its own — no admin panel, no `tar -xf` on the host. One entry per module, +# `@=`, whitespace- or comma-separated: +# +# MODULES=uo@0.3.0=https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json +# +# A module already unpacked at the declared version is a no-op that never touches +# the network, so a restart with the internet down brings the site up exactly as +# it was; only a missing or different version is fetched, verified against the +# sha256 its manifest declares, and unpacked. A failure is logged and shown in +# Admin → Modules, and the site starts anyway. The variable owns what is on the +# volume, not what runs — a module disabled from the admin panel stays disabled. +# Leave it unset to install from the admin panel instead. +# +# RunicGateway/Module-uo, for example, reads UOLINK_BASE_URL / UOLINK_WS_URL / +# UOLINK_PROTOCOL as the defaults for its connection to a uo-link sidecar, and +# TOWNCRIER_DURATION_SEC for its news leg. Its README documents them; they are +# left out here rather than half-copied, because a copy of another repo's +# settings is a copy that goes stale silently. With no module installed, none of +# this applies and the site runs as core. # ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ─── # The `ntfy` compose service and the backend's push fan-out (opt-in notifications diff --git a/.env.uomysticmoon.example b/.env.uomysticmoon.example index e1a5d58..c0750a9 100644 --- a/.env.uomysticmoon.example +++ b/.env.uomysticmoon.example @@ -28,3 +28,36 @@ TOTP_ISSUER=UOMysticmoon DB_NAME=uomysticmoon DB_USER=uomm COOKIE_NAME=uomm_token + +# ── The UO module — REQUIRED for this instance, not optional like the vars above. +# +# Core is game-agnostic (docs/website/MODULE_SYSTEM.md): every shard-facing +# surface this instance runs — the shard pages, the player's characters, vendors +# and houses, Admin → Shard, and the uo-link connection itself — lives in +# RunicGateway/Module-uo and reaches the deployment through this line. Without +# it, the same image is a perfectly working site with no game on it. +# +# It is declared here rather than left to Admin → Modules because a compose host +# should arrive at its own set at boot, and because this instance has a shard to +# be down for: the panel path would leave the site game-less between the image +# roll and someone clicking install. +# +# Bump the version deliberately, and read Module-uo's release notes when you do — +# the container resolves this at every start, so changing the version here is +# what upgrades the module. A version already unpacked is a no-op that makes no +# network call at all. +# +# This owns what is ON the volume, never whether the module RUNS: disabling it in +# Admin → Modules keeps it disabled across restarts even though its files return. +MODULES=uo@0.3.0=https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json + +# Module-uo reads these as the DEFAULTS for its uo-link connection, used only +# until Admin → Shard has been saved once — after that the encrypted DB config +# (`uo_link_config`) is authoritative and these are ignored. Left unset here on +# purpose: an instance that has already saved Admin → Shard keeps that config +# across the extraction (the module's schema fragment is CREATE TABLE IF NOT +# EXISTS, so the existing row is untouched), and setting them would suggest they +# still decide something. Module-uo's README documents them. +# UOLINK_BASE_URL= +# UOLINK_WS_URL= +# UOLINK_PROTOCOL= diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index d802854..8c09cce 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -47,10 +47,20 @@ jobs: node-version: 20 cache: npm cache-dependency-path: server/package-lock.json + - name: Check core names no module identifier + # Phase 3's acceptance criterion 1 (MODULE_API.md §5.2): core must not + # name a module's files, import them, route to them, or declare its + # symbols. Before `npm ci`, deliberately — it is plain Node over + # server/ and client/ source with no dependency of its own, so putting it + # first makes a boundary break the first thing a reviewer sees instead of + # something found under a pile of unrelated failures, and it costs + # nothing when it passes. + run: npm run check:modules - name: Install server deps run: npm ci --prefix server - name: Run server tests run: npm test --prefix server + - name: Check the route manifest is current # The URL surface is frozen while the routers are carved up by capability # (docs/website/API_V2_PLAN.md § Phase 2). Regenerating from the live Express diff --git a/.gitignore b/.gitignore index 9d33164..a6a0669 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,13 @@ 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 diff --git a/Dockerfile b/Dockerfile index b0395e3..0131633 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,12 @@ 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 diff --git a/README.md b/README.md index f96a7d4..5b15305 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,19 @@ [![Security Rating](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=security_rating&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website) [![Vulnerabilities](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=vulnerabilities&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website) -Public site, wiki, and protected admin panel for a private Ultima Online shard — a -full-stack app in one repo. Branding is instance-configurable via `BRAND_*` (see -[Branding](#branding)); **UOMysticmoon** is the first instance. +Public site, wiki, and protected admin panel for a game community — a full-stack app +in one repo. Everything specific to a *particular* game lives in an installable +module, not here. Branding is instance-configurable via `BRAND_*` (see +[Branding](#branding)); **UOMysticmoon**, an Ultima Online shard, is the first +instance, and its game half is +[RunicGateway/Module-uo](https://gitea.whitlocktech.com/RunicGateway/Module-uo). A full-stack app in one repo: - **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO). - **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia). - **Deploy** — Docker Compose (app + MariaDB) behind a reverse proxy (Pangolin, Nginx, Caddy, Traefik, …). Express serves the built SPA in production. -- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link). +- **Modules** — the game-specific half of a site is a module dropped onto a volume: it adds routes, database tables, nav entries and whole SPA pages without this repo knowing anything about the game. See [Modules](#modules). The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md) (API contract, schema, security), in the [**RunicGateway/docs**](https://gitea.whitlocktech.com/RunicGateway/docs) repo — where all project documentation now lives. @@ -37,7 +40,7 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic - [Pages & routes](#pages--routes) - [API endpoints](#api-endpoints) - [API documentation (Swagger)](#api-documentation-swagger) -- [Shard integration (uo-link)](#shard-integration-uo-link) +- [Modules](#modules) - [Environment variables](#environment-variables) - [Security](#security) - [Logging](#logging) @@ -48,8 +51,8 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic ## Architecture How the pieces fit together — the React SPA and native app talk to one Express backend -(`router → controller → model → db`), which persists to MariaDB and bridges to the live -game world only through the **uo-link** sidecar. The shard itself is never internet-facing. +(`router → controller → model → db`), which persists to MariaDB. Anything that knows +what game this site is about lives in an installed module, on the right of the diagram. ```mermaid flowchart TB @@ -69,32 +72,29 @@ flowchart TB subgraph backend["server/ — Express backend"] direction TB mw["Middleware
helmet · siteMode · noindex
rateLimit · loginProtection · botScore · validate"] - router["Router /api/v1
auth (web · mobile · sso) · public · admin"] + router["Router /api/v1
auth (web · mobile · sso) · public · admin · player"] ctrl["Controllers"] auth["Session layer (auth/)
sessionService · JWT/cookie · bearer · SSO+PKCE"] model["Models (.model + .db)
raw parameterized SQL — no ORM"] sse["SSE fan-out
public stream (allowlist) · admin stream (sensitive)"] - - subgraph shardutil["Shard integration (utils/)"] - ingest["shardIngest.js
WS ingest dispatcher"] - restcli["uoLinkClient.js
REST client (never throws)"] - end - + loader["modules/loader.js
scans the volume · mounts · registries · lifecycle"] secret["secretBox.js
AES-256-GCM secrets at rest"] end bot["bot/
Discord bot"] end - db[("MariaDB
users · posts · wiki · settings · activity
mobileSessions · authProviders · userIdentities
uoLinkConfig · shard_online/economy/houses/events")] + db[("MariaDB
users · posts · wiki · settings · activity
mobileSessions · authProviders · userIdentities
installed_modules · <module>_*")] - %% ---------- Shard side ---------- - subgraph shardside["Game shard (never internet-facing)"] + %% ---------- Module side ---------- + subgraph modside["modules/<id>/  — installed, not built (e.g. Module-uo)"] direction TB - sidecar["uo-link sidecar
(Rust) — the only bridge exposed"] - servuo["ServUO shard
(C# plugin)"] + modsrv["server/ — routers, models, schema fragment
reaches core only through ctx"] + modcli["client/dist/entry.js — prebuilt ESM chunk
React shared via window.__rg"] end + game["The game
whatever the module talks to
(for Module-uo: a ServUO shard,
via the uo-link sidecar)"] + %% ---------- Edges ---------- browser <-->|"same-origin JSON + SSE (cookie)"| mw mobile -->|"REST (bearer access/refresh)"| mw @@ -104,40 +104,42 @@ flowchart TB mw --> router --> ctrl ctrl --> auth ctrl --> model - ctrl --> restcli ctrl --> sse auth --> model model <--> db auth -. reads/writes secrets .-> secret - restcli -. reads config/token .-> secret - ingest --> model - ingest --> sse sse -->|"live events"| browser bot -->|"messages"| discord bot <--> db - restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar - sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest - servuo -->|"loopback TCP 127.0.0.1:7788
newline-delimited JSON (shard dials out)"| sidecar + loader -->|"mounts under /api/v1/<tier>/<prefix>"| router + loader -->|"require() + register(ctx, api)"| modsrv + modsrv -->|"ctx.db · ctx.push · ctx.activity …"| model + modsrv <--> game + browser -->|"<script type=module> injected by htmlShell"| modcli %% ---------- Styling ---------- classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0; classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea; - classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8; - class idp,discord ext; + classDef mod fill:#2d2620,stroke:#94764c,color:#f0e6d8; + class idp,discord,game ext; class db store; - class sidecar,servuo bridge; + class modsrv,modcli mod; ``` - **One backend, layered.** Every request flows `middleware → router → controller → model → db`. Web browsers authenticate with an httpOnly JWT cookie; the native app uses short-lived bearer access tokens plus rotated refresh tokens; SSO (Google/Discord/OIDC) is link-only and PKCE-guarded. All three surfaces produce the *same* session via the session layer. -- **The shard is never reachable.** The ServUO shard *dials out* over loopback TCP to the uo-link - sidecar; only the sidecar is exposed, and only the backend talks to it. The REST client - (`uoLinkClient.js`) never throws, so the site degrades gracefully when the shard is down. -- **Sensitive events stay private.** Ingested game events fan out to browsers over two SSE channels — - a public allowlist stream and an admin-only stream that adds staff audit / cheat / login events. +- **Core knows nothing about any game.** Routes, tables, nav entries, SPA pages and push streams for + a specific game arrive from a module the operator installed. Core provides the seams; the module + fills them. See [Modules](#modules). +- **A module that fails must never take the site down.** The loader catches failures across a + module's whole lifecycle and marks that one module `startup_failed`; the site comes up with its + routes and nav absent, and the admin panel says why. +- **Sensitive events stay private.** Events fan out to browsers over two SSE channels — a public + allowlist stream and an admin-only stream that adds staff audit / cheat / login events. Which + event kinds are public is decided by the module that publishes them, and core enforces the split. --- @@ -164,12 +166,13 @@ website/ │ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0) │ │ ├─ app.js middleware + static SPA + routes │ │ ├─ auth/ session layer: session.service · token (JWT/cookies) · session.middleware · ssoState (PKCE/CSRF) · providers/ (base · oauth2 · google · discord · genericOidc · registry) -│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin route groups -│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities (.model + .db) +│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin / player route groups +│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities · modules (.model + .db) +│ │ ├─ modules/ loader (scan · validate · mount) · registries (the seams) · lifecycle (boot/shutdown + reconcile) │ │ ├─ middleware/ siteMode · noindex · rateLimit · loginProtection · botScore · validate -│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger +│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger · htmlShell │ ├─ db/ schema.sql + seed.js -│ ├─ swagger/ swagger.js (OpenAPI generator config) + swagger-output.json (generated spec) +│ ├─ swagger/ swagger.js (generator config) · swagger-output.json (generated, core only) · docsSpec.js (merges module fragments at request time) │ └─ .env.example ├─ client/ React + Vite SPA │ ├─ src/ @@ -178,9 +181,11 @@ website/ │ │ ├─ routes/admin/ AdminLogin (password + TOTP + SSO buttons), AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Bot Activity, Authentication, Users, Account) + editors │ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, ProviderIcon (inline SSO SVGs), … │ │ ├─ contexts/ AuthContext, SiteContext +│ │ ├─ modules/ the client registry: routes · nav · slots · feature gates · window.__rg │ │ ├─ api/client.js fetch wrapper (sends cookies) │ │ └─ styles/theme.css design tokens │ └─ public/assets/img/ hero image +├─ modules/ installed modules, one directory each — a Docker bind mount; empty here ├─ Dockerfile builds client → serves via Express ├─ docker-compose.yml app + MariaDB ├─ .env.example root env (used by Compose) @@ -222,6 +227,10 @@ 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: @@ -302,7 +311,7 @@ npm start # node server → serves API + SPA at http://localhost:3 | `/site/screenshots` | Screenshot gallery | | `/site/five-on-friday` | Five on Friday | | `/site/newsletter` · `/site/newsletter/:id` | Newsletter list + issue | -| `/site/about` · `/site/status` | About · Shard status | +| `/site/about` · `/site/status` | About · Site status | | `/wiki` · `/wiki/:slug` | Wiki landing + article (auto table-of-contents) | **Admin** (cookie auth, `noindex`): @@ -320,6 +329,13 @@ npm start # node server → serves API + SPA at http://localhost:3 | `/admin/users` | User management | | `/admin/account` | Account security (self-service TOTP two-factor + linked SSO accounts) | +**Player** (any signed-in account, `noindex`): `/player` and its self-service views. Staff are a +superset of players and reach these too. + +An installed module adds its own pages under `//*`, `/admin//*` and `/player//*` — for +Module-uo that is `/uo/shard`, `/admin/uo/link`, `/player/uo/characters` and the rest. Core does not +know their names; they arrive with the module and are interleaved into the nav. + --- ## API endpoints @@ -331,9 +347,15 @@ npm start # node server → serves API + SPA at http://localhost:3 | SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow | | Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none | | Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `auth/providers` (CRUD), `users`, `account`, `account/totp/*`, `account/identities`) | cookie (admin) | -| Public · Shard | `/api/v1/public/shard` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | none | -| Player · Shard | `/api/v1/player/shard` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | cookie/bearer (player) | -| Admin · Shard | `/api/v1/admin/shard` (self linking, same as player) · `/api/v1/admin/uo-link` (`config`, `towncrier`, `stream`) | cookie (staff / admin) | +| Player | `/api/v1/player` (`me`, credentials, 2FA, identities, appeals) | cookie/bearer (any signed-in account) | +| Modules | `/api/v1/public/modules` — id, name, version and capabilities of the modules currently serving | none | + +**Module routes are not in this table**, because they are not core's. An installed module mounts +under `/api/v1/public/`, `/api/v1/admin/` and `/api/v1/player/`; which +prefixes exist depends on what is installed. Module-uo, for instance, serves 72 routes under +`/shard`, `/atlas` and `/uo-link` — see its own +[`routes.manifest.json`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/routes.manifest.json). +On a running instance, `/api/docs` lists everything, core and modules together. Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`. `authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`. @@ -376,6 +398,28 @@ npm run swagger # → server/swagger/swagger-output.json If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does not crash). +**The committed spec is core only, and the served one is not.** swagger-autogen is *static +analysis* — it parses `src/app.js` as text and follows the literal `app.use(…)` chain — so it can +see neither an installed module (which arrives on a volume long after the image was built, and +mounts through a call no parser can follow) nor an extension slot (whose router is created empty and +filled later). Both are handled by merging a **fragment**: + +- **Extension slots** contribute at generation time, from `server/swagger/slotSpecs.js`, so they are + in the committed file. +- **Modules** contribute at request time, from the `swagger-fragment.json` each one ships, merged by + `server/swagger/docsSpec.js`. So `/api/docs.json` on a running instance describes more than + `npm run swagger` produces here, and `swagger-output.json` stays reproducible on any machine + regardless of what is installed. + +**Core wins every key collision** — a module cannot redefine a core path, tag or schema by shipping +one with the same name; the collision is logged and the module's version dropped. + +One thing worth knowing if you edit an annotation: swagger-autogen **reports a broken one and then +succeeds anyway**, dropping it. `npm run swagger` now captures those diagnostics and fails, which is +how two annotations that had been silently documenting an empty request body were found. If it +rejects yours, the usual causes are an object literal a brace short, or a `"` or backtick inside a +single-quoted description (it re-quotes both to `'` before evaluating). + ### The route manifest (frozen URL surface) `server/routes.manifest.json` is a generated, sorted `{ method, path }` list of every route the two @@ -391,9 +435,10 @@ 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` 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. +`/api/**` and `/.well-known/**` plus the internal listener. The SPA catch-all, `/uploads`, `/brand` +and installed modules' `/modules/` 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. Two generated files, two very different meanings: @@ -407,94 +452,101 @@ annotated routes appear), the manifest records reality. --- -## Shard integration (uo-link) +## Modules -The site is wired to the live in-game world through **uo-link**, a standalone sidecar service that -runs next to the ServUO shard. Its source lives in a separate repo: -**[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)**. uo-link speaks the shard's internals and -exposes a small, authenticated HTTP + WebSocket API; this website is a *client* of it. The shard -itself is never exposed to the internet — only the sidecar is, and only the website's backend talks -to it. +**Everything specific to a game is a module.** Core has no idea what an "account", a "character" or +a "shard" is; it provides seams, and a module fills them. That is what makes one image able to run a +site for any game rather than for Ultima Online in particular. -### Setting up the shard side +The design of record is +[MODULE_SYSTEM.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md); +the normative contract — the one to read before writing a module — is +[MODULE_API.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md). +The worked example is [RunicGateway/Module-uo](https://gitea.whitlocktech.com/RunicGateway/Module-uo), +which is where everything this README used to describe under *Shard integration (uo-link)* now +lives: the sidecar client, the ingest dispatcher, account linking, the town crier, the spawn atlas, +and every page that renders them. -You do not build or place any of it by hand. The -**[Runic Gateway installer](https://gitea.whitlocktech.com/RunicGateway/installer)** runs on the -shard host, deploys the ServUO plugin and the uo-link sidecar as a matched, protocol-checked pair, -registers the sidecar as a service, and ends by printing the four values this site needs: +### An operator never builds anything + +That constraint shapes the whole design. Installing a module is the WordPress-plugin experience — an +admin-panel action, or a directory dropped onto the `modules/` volume — because production runs a +prebuilt, pull-only image with no toolchain in it. So a module ships **assembled**: its client half +is a prebuilt ESM chunk that resolves React from a `window.__rg` global core owns (an import map +would have to be inline, and the CSP is `script-src 'self'`), and its one runtime dependency travels +inside the tarball. ``` -Base URL http://:8080 -WebSocket URL ws://:8080/ws -Protocol version 3 -Auth token 4f9c… +modules/ +└─ uo/ one directory per module; the id is the directory name + ├─ module.json id, version, coreApi range, mounts, extensions, capabilities + ├─ swagger-fragment.json merged into /api/docs.json while the module is running + ├─ server/ routers, models, and an idempotent schema.sql fragment + └─ client/dist/entry.js the prebuilt chunk, injected by utils/htmlShell.js ``` -Paste them into **Admin → Shard** here and the bridge is live. The operator guide is -[installer/INSTALL.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md); -its [Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand) -is the same deployment done by hand, still supported, for a host that cannot run the binary or a -developer working from a source tree. +`modules/` is a bind mount in `docker-compose.yml`, so placing a directory there by hand is a +supported install. The directory is tracked in git (via its README) on purpose: Docker recreates a +*missing* bind-mount source as `root:root`, and the container is uid 1000. -Nothing here needs the shard to exist: with no sidecar configured the site renders normally and -shows the shard offline. +### Three ways in, and none of them is a build -### How it works +| | How | Where it fits | +|---|---|---| +| **Admin panel** | Admin → Modules, paste the URL of a release's install manifest | The click path. Installs, upgrades, disables, uninstalls and purges, with a restart button — no shell on the box | +| **`MODULES`** | Declare the set in the environment; the container resolves it at every start | The compose-managed host. The running set is a line in a file you version-control, not the residue of past clicks | +| **By hand** | `tar -xf module-uo-0.3.0.tar.gz -C ./modules && mv modules/module-uo-0.3.0 modules/uo`, then restart | Development, and any host where the other two do not fit | + +`MODULES` takes one entry per module, whitespace- or comma-separated: ``` -ServUO shard ──▶ uo-link sidecar (RunicGateway/link) ──▶ website backend ──▶ browser - REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE +MODULES=uo@0.3.0=https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json ``` -- **Connection is admin-managed, not env.** The sidecar's base URL, WebSocket URL, shared-secret - token, and protocol version are stored in the database (`uoLinkConfig`), edited from the - **Admin → Shard** panel. The token is **encrypted at rest** (AES-256-GCM) and is **write-only** in - the API — it is never returned to any client and never sent to the browser. Every call the backend - makes carries `Authorization: Bearer ` and an `X-UOLink-Version` header (a protocol - mismatch fails fast with `409` instead of being mis-parsed). -- **Live ingest (WebSocket).** When enabled, the backend opens an outbound WebSocket to the sidecar - and receives a stream of game events — `mob.login`/`logout`, `char.vitals`, `economy.supply`, - `vendor.sale`, `player.death`/`murdered`, `house.decay` (IDOC), staff `audit.*`/`cheat.*`, - `link.request`, and `server.hello`/`shutdown`. A single dispatcher (`utils/shardIngest.js`) routes - each event: state-changing kinds update `shard_online` / `shard_economy` / `shard_houses`; notable - kinds are appended to an append-only `shard_events` log; high-frequency kinds (vitals, supply - ticks) only update state and are not logged. A changed boot id on `server.hello` is detected as a - restart and stale "online" rows are cleared. On reconnect the backend backfills missed events via - the sidecar's `/history`. -- **Live round-trips (REST).** For point-in-time reads the backend calls the sidecar directly — - `/char/serial/:serial`, `/roster/:account`, `/vendors/:account`, `/economy`, `/history` — plus - commands `/link/confirm` and `/towncrier`. The REST client (`utils/uoLinkClient.js`) **never - throws**: every call returns `{ ok, data, status }`, so a shard that is down or mid-restart - degrades to a `503`/retry banner instead of a 500. -- **Fan-out to the browser.** Ingested events are pushed to browsers over **Server-Sent Events**. - Two channels exist: a **public** stream carrying only a safe allowlist of kinds, and an - **admin-only** stream that also includes sensitive kinds (staff audit, cheat detection, login - attempts, IPs). Sensitive kinds can never leak onto the public channel. +The id and the version are written out rather than discovered inside the manifest so that **the +no-op case needs no network**: a module already unpacked at the declared version is answered by +reading its own `module.json`, so a restart with the internet down brings the site up exactly as it +was. Only a missing or different version is fetched, and it goes through the same +verify-and-unpack path — allowlisted `https` host, sha256 from the manifest, whole-archive +inspection before anything is written — that the admin panel uses. A version that cannot be +resolved is logged and shown on the admin screen; **it never stops the site from starting**. -### Account linking +The declaration owns what is *on the volume*, never what runs. A module disabled from the admin +panel gets its files back at the next start and stays disabled, because the row and the variable are +answering different questions. -A player (or staff member) proves ownership of a game account without sharing any game credentials: +### What a module gets, and what it may not do -1. In game, the player runs **`[link`** and receives a one-time code. -2. On the website (Player portal, or Admin → Account for staff) they enter the code. -3. The backend confirms the code with the sidecar (`POST /link/confirm`), which permanently tags the - game account with the website user id, and mirrors the link locally in `shard_account_links`. +At boot, `app.js` scans the volume synchronously, validates each `module.json`, and calls the +module's `register(ctx, api)`: -That mirror is the authorization basis for character reads: roster/vendor/character-sheet endpoints -are **ownership-checked** so a user only sees accounts they linked. **Admins may view any -character**; players and editor/moderator staff are limited to their own linked accounts. +- **`ctx` is everything core hands over** — the database, the logger, settings, the session reader, + push, the secret box, the middleware, the rate-limit factory, the activity log, and **express + itself**. A module lives outside `server/`, so Node's resolver never reaches core's + `node_modules`; anything it must share has to be handed to it, or there would be two Expresses and + two Reacts in one process. +- **`api` is everything it may register** — routes (one prefix per tier), an extension slot fill, + notification streams, a news-announce leg, a post hook, and `onBoot`/`onShutdown`. +- **It may not reach into core's tree**, mount outside its declared prefixes, or create tables + outside its `_` prefix. Each of those is checked, in the module's CI and again by the loader. -### What each audience sees +Two things are guaranteed regardless of what a module does. **A failure never takes the site down**: +the loader catches everything from `require` to `onBoot`, marks that module `startup_failed`, and +the site comes up with its routes and nav absent and the reason on the admin screen. And **no URL of +core's may move** — a module that displaced one is caught by the frozen route manifest, which is +generated from a real core with the module loaded. -| Surface | Endpoints | Who | Data | -|---|---|---|---| -| **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown with name + map location. Linked *players* are never listed publicly; no vitals or account are exposed. | -| **Player** | `/api/v1/player/shard/*` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | logged-in player | Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales. | -| **Admin** | `/api/v1/admin/shard/*` (self-linking, same as player) · `/api/v1/admin/uo-link/*` (`config`, `towncrier`, `stream`) | staff / admin | Staff link their own accounts like players; **admins** additionally read *any* character's data, edit the sidecar connection config, publish/remove **town-crier** messages, and subscribe to the full event stream (incl. audit/cheat). | +### What is running right now -The sidecar URL and token are set once in **Admin → Shard**; if uo-link is not configured (or the -shard is offline), every shard surface degrades gracefully — the public page still renders, showing -the shard as offline. +``` +GET /api/v1/public/modules +{ "modules": [ { "id": "uo", "name": "Ultima Online", "version": "0.3.0", + "capabilities": ["shard", "atlas", "market", …] } ] } +``` + +Anonymous, database-free, never site-mode gated, and **`started` modules only** — a module that is +disabled or failed is absent, exactly as its routes and its nav already are. Clients feature-detect +against it; they do not use it to decide what to load (the HTML shell injects each chunk's tag). --- @@ -507,6 +559,9 @@ 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` | `/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) | +| `MODULES_DIR` | `/modules` | where installed modules are scanned from (`/app/modules`, bind-mounted, in Compose) | +| `MODULES` | — | the module set this deployment runs, resolved at every start: `@=`, whitespace/comma separated. Already at the declared version = no network. A failure is logged and shown in Admin → Modules, never fatal. See [Modules](#modules) | +| `MODULE_SOURCE_HOSTS` | `gitea.whitlocktech.com` | **bootstrap only** — seeds the `module_source_hosts` setting on first boot; after that the setting is authoritative and is edited in Admin → Modules | | `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) | @@ -528,15 +583,14 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`. | `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only | | `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity | | `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) | -| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_jobs` for due/retry legs (town crier + Discord) | -| `TOWNCRIER_DURATION_SEC` | `3600` | how long a news post's in-game town-crier message stays up (≤ `86400`) | +| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_jobs` for legs that are due or retrying. Which legs exist is up to what has registered one — Discord is core's; a module may add its own | --- ## Branding Instance identity is data, not code — set via `BRAND_*` env vars, so one prebuilt -image can run as any shard. With none set, everything renders as **Runic Gateway**. +image can run as any community. With none set, everything renders as **Runic Gateway**. | Var | What | |---|---| diff --git a/client/src/App.jsx b/client/src/App.jsx index 3cacb15..a627509 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -5,6 +5,8 @@ import MaintenanceGate from './components/MaintenanceGate.jsx' 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' @@ -16,18 +18,6 @@ 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 Atlas from './routes/public/Atlas.jsx' -import AtlasCreature from './routes/public/AtlasCreature.jsx' -import Leaderboards from './routes/public/Leaderboards.jsx' -import Market from './routes/public/Market.jsx' -import MarketVendor from './routes/public/MarketVendor.jsx' import Wiki from './routes/wiki/Wiki.jsx' import WikiArticle from './routes/wiki/WikiArticle.jsx' import CmsPage from './routes/public/CmsPage.jsx' @@ -47,17 +37,11 @@ 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 ModulesAdmin from './routes/admin/views/ModulesAdmin.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' @@ -69,9 +53,7 @@ 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 from './routes/player/PlayerPortalLayout.jsx' -import PlayerCharacters from './routes/player/PlayerCharacters.jsx' -import PlayerCharacter from './routes/player/PlayerCharacter.jsx' +import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx' import PlayerAccount from './routes/player/PlayerAccount.jsx' import PlayerAppeals from './routes/player/PlayerAppeals.jsx' @@ -79,156 +61,172 @@ export default function App() { return ( - - {/* 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. */} - } /> + {/* Inside the auth and site contexts, because a feature provider is a + hook that may well read either — a live-status 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. */} + + + {/* 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. */} + } /> - {/* Rest of the public site — gated by maintenance mode (admins preview through it) */} - - - - } - > - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* CMS pages: top-level /:slug, matched only after the named routes - above (React Router ranks static routes over this dynamic one). */} - } /> - - - {/* Draft-preview link (token-gated). Outside the maintenance gate so a - preview link works regardless of site mode. */} - } /> - - {/* Admin */} - } /> - - - - } - > - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* 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. */} + {/* Rest of the public site — gated by maintenance mode (admins preview through it) */} - - - } - /> - {/* 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. */} - - - - } - /> - } /> - + - + } > - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Installed modules' public pages, namespaced `//…` — 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) => ( + + ))} + {/* CMS pages: top-level /:slug, matched only after the named routes + above (React Router ranks static routes over this dynamic one). */} + } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - } - /> - - - - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {/* Player portal */} - } /> - } /> - } /> - } /> - } /> - - - - } - > - } /> - } /> - } /> - } /> - + {/* Draft-preview link (token-gated). Outside the maintenance gate so a + preview link works regardless of site mode. */} + } /> - } /> - + {/* Admin */} + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* 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. */} + + + + } + /> + {/* 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. */} + + + + } + /> + } /> + + + + } + > + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Core's own screen, and it has to be: it is how a module reaches + the volume in the first place. Declared here with the rest of + core's routes, above the module-supplied ones below. */} + } /> + } /> + {/* Installed modules' admin pages, at /admin//…, 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) => ( + {r.element} : r.element} + /> + ))} + } /> + + + {/* Player portal */} + } /> + } /> + } /> + } /> + } /> + + + + } + > + {/* 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. */} + } /> + } /> + } /> + {/* Installed modules' player-portal pages, at /player//…. 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) => ( + {r.element} : r.element} + /> + ))} + + + } /> + + ) diff --git a/client/src/api/client.js b/client/src/api/client.js index 4312872..b21db5a 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -42,6 +42,20 @@ 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 `api` object below is core's own binding surface and nothing else: every +// namespace in it belongs to a route core still serves. A module binds its own +// paths in its own chunk, against this primitive. +// `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 } + export const api = { // ----- auth ----- me: () => req('/auth/me'), @@ -127,110 +141,6 @@ export const api = { pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`), contact: (payload) => req('/public/contact', { method: 'POST', body: payload }), - // ----- shard live data (uo-link) ----- - // Token-free, same-origin reads backed by the ingested feed + a cached live - // character round-trip. shardStreamUrl is the SSE endpoint for useShardFeed. - shard: { - status: () => req('/public/shard/status'), - feed: (opts = {}) => { - const qs = new URLSearchParams() - if (opts.kind) qs.set('kind', opts.kind) - if (opts.limit) qs.set('limit', opts.limit) - const s = qs.toString() - return req(`/public/shard/feed${withQs(s)}`) - }, - economy: (limit) => { - const q = limit ? `limit=${limit}` : '' - return req(`/public/shard/economy${withQs(q)}`) - }, - online: () => req('/public/shard/online'), - idoc: () => req('/public/shard/idoc'), - champs: () => req('/public/shard/champs'), - // Protocol 2.0 boards. - guilds: () => req('/public/shard/guilds'), - governors: () => req('/public/shard/governors'), - governorHistory: (city, limit) => { - const q = limit ? `limit=${limit}` : '' - return req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(q)}`) - }, - presence: () => req('/public/shard/presence'), - houses: () => req('/public/shard/houses'), - // Protocol 3.0: the shard's published ruleset. Resolves to null when the - // shard has never published one — a real answer, not an error. - ruleset: () => req('/public/shard/ruleset'), - // Protocol 3.0: points/loyalty leaderboards, one board per point system. - // `board` 404s for a system the shard has never published. - points: () => req('/public/shard/points'), - pointsBoard: (system) => req(`/public/shard/points/${encodeURIComponent(system)}`), - // Protocol 3.0: the player-vendor marketplace. Rate-limited server-side, so - // the page debounces its search box rather than firing per keystroke. - market: (opts = {}) => { - const qs = new URLSearchParams() - if (opts.q) qs.set('q', opts.q) - if (opts.minPrice != null && opts.minPrice !== '') qs.set('minPrice', opts.minPrice) - if (opts.maxPrice != null && opts.maxPrice !== '') qs.set('maxPrice', opts.maxPrice) - if (opts.itemId != null && opts.itemId !== '') qs.set('itemId', opts.itemId) - if (opts.map) qs.set('map', opts.map) - if (opts.region) qs.set('region', opts.region) - if (opts.sort) qs.set('sort', opts.sort) - if (opts.limit) qs.set('limit', opts.limit) - if (opts.offset) qs.set('offset', opts.offset) - return req(`/public/shard/market${withQs(qs.toString())}`) - }, - marketMeta: () => req('/public/shard/market/meta'), - marketVendor: (serial, opts = {}) => { - const qs = new URLSearchParams() - if (opts.limit) qs.set('limit', opts.limit) - if (opts.offset) qs.set('offset', opts.offset) - return req(`/public/shard/market/vendors/${encodeURIComponent(serial)}${withQs(qs.toString())}`) - }, - // Which shard surfaces this caller may reach, plus the audience rung they - // resolved to. Drives nav so we never render a link that would 403. - features: () => req('/public/shard/features'), - }, - - // ----- spawn atlas (Protocol 3.0 Part C) ----- - // Static shard CONTENT, parsed from the shard's own ServUO tree — deliberately - // not under /shard, because nothing here depends on the sidecar and the pages - // stay populated while the shard is offline. - atlas: { - creatures: (opts = {}) => { - const qs = new URLSearchParams() - if (opts.q) qs.set('q', opts.q) - if (opts.facet) qs.set('facet', opts.facet) - if (opts.limit) qs.set('limit', opts.limit) - if (opts.offset) qs.set('offset', opts.offset) - return req(`/public/atlas/creatures${withQs(qs.toString())}`) - }, - creature: (slug, opts = {}) => { - const qs = new URLSearchParams() - if (opts.facet) qs.set('facet', opts.facet) - if (opts.points) qs.set('points', opts.points) - return req(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`) - }, - regions: (opts = {}) => { - const qs = new URLSearchParams() - if (opts.facet) qs.set('facet', opts.facet) - if (opts.q) qs.set('q', opts.q) - return req(`/public/atlas/regions${withQs(qs.toString())}`) - }, - landmarks: (opts = {}) => { - const qs = new URLSearchParams() - if (opts.facet) qs.set('facet', opts.facet) - if (opts.q) qs.set('q', opts.q) - return req(`/public/atlas/landmarks${withQs(qs.toString())}`) - }, - // The CONFIGURED altar roster, not the live board — see shard.champs() for - // "which spawn is on level 3 right now". - champions: (facet) => req(`/public/atlas/champions${withQs(facet ? `facet=${encodeURIComponent(facet)}` : '')}`), - meta: () => req('/public/atlas/meta'), - }, - // Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is - // fetch-only, so SSE subscribers build the URL from here. The admin stream - // carries every kind (incl. audit/cheat) and needs the staff session cookie. - shardStreamUrl: `${BASE}/public/shard/stream`, - adminShardStreamUrl: `${BASE}/admin/uo-link/stream`, - // ----- admin ----- admin: { dashboard: () => req('/admin/dashboard'), @@ -322,21 +232,20 @@ export const api = { createInvite: (email, role, sendEmail = true) => req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }), revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }), - // A single user's shard (uo-link) footprint, scoped to their linked accounts. - // accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char - // reuse the admin-bypass /admin/shard/* endpoints (which already read any - // account) so the shared GameAccounts component works unchanged. - userShard: (id) => ({ - accounts: () => req(`/admin/users/${id}/shard/accounts`), - roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`), - vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`), - char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`), - sales: () => req(`/admin/users/${id}/shard/sales`), - houses: () => req(`/admin/users/${id}/shard/houses`), - online: () => req(`/admin/users/${id}/shard/online`), - standing: () => req(`/admin/users/${id}/shard/standing`), - unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }), - }), + + // Installed modules (MODULE_SYSTEM.md §2.7.2). `uninstallModule`'s purge flag + // is a query parameter rather than a body because it hangs off a DELETE, and + // it is spelled out at the call site rather than defaulted, so the + // destructive branch is never the one you get by forgetting an argument. + listModules: () => req('/admin/modules'), + installModule: (url) => req('/admin/modules', { method: 'POST', body: { url } }), + enableModule: (id) => req(`/admin/modules/${encodeURIComponent(id)}/enable`, { method: 'POST' }), + disableModule: (id) => req(`/admin/modules/${encodeURIComponent(id)}/disable`, { method: 'POST' }), + uninstallModule: (id, { purge } = {}) => + req(`/admin/modules/${encodeURIComponent(id)}${purge ? '?purge=true' : ''}`, { method: 'DELETE' }), + purgeModule: (id) => req(`/admin/modules/${encodeURIComponent(id)}/purge`, { method: 'POST' }), + setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }), + restartServer: () => req('/admin/modules/restart', { method: 'POST' }), // ----- moderation dashboard (admin + moderator) ----- modSummary: () => req('/admin/moderation/stats/summary'), @@ -409,19 +318,6 @@ export const api = { linkedIdentities: () => req('/admin/account/identities'), unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }), - // ----- game account linking (self-service, staff) ----- - shard: { - link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }), - accounts: () => req('/admin/shard/accounts'), - roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`), - vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`), - char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`), - sales: () => req('/admin/shard/sales'), - houses: () => req('/admin/shard/houses'), // full registry (admin/moderator) - createAccount: (account, password) => - req('/admin/shard/account', { method: 'POST', body: { account, password } }), - }, - // ----- auth providers / SSO config (admin only) ----- listAuthProviders: () => req('/admin/auth/providers'), createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }), @@ -432,45 +328,6 @@ export const api = { getDiscordBotConfig: () => req('/admin/discord-bot/config'), saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }), - // ----- uo-link sidecar control (admin only) ----- - getUoLinkConfig: () => req('/admin/uo-link/config'), - saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }), - postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }), - deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }), - // Per-feature shard visibility: who may see which shard surface, and which - // sensitive fields within it. Admin only — it decides what ANONYMOUS - // visitors get. acct/webId are admin-only always and the API rejects any - // attempt to configure them. - getShardVisibility: () => req('/admin/shard/visibility'), - saveShardVisibility: (features) => - req('/admin/shard/visibility', { method: 'PUT', body: { features } }), - - // ----- spawn atlas operation (admin only) ----- - // The atlas re-derives itself from the ServUO tree on every boot; these are - // for applying a map change without a restart, and for the approve/reject - // decision on a refresh that would remove a facet. - atlas: { - status: () => req('/admin/shard/atlas'), - import: (force = false) => req('/admin/shard/atlas/import', { method: 'POST', body: { force } }), - approve: () => req('/admin/shard/atlas/approve', { method: 'POST', body: {} }), - reject: () => req('/admin/shard/atlas/reject', { method: 'POST', body: {} }), - setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }), - }, - - // ----- in-game staff operations: write plane + support queue (admin/moderator) ----- - // `actor` is stamped server-side from the session — never sent from here. - shardOps: { - kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }), - ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }), - unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }), - broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }), - pages: () => req('/admin/shard/pages'), - respondPage: (id, data) => - req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }), - closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }), - audit: (limit) => req(`/admin/shard/audit${limit ? `?limit=${limit}` : ''}`), - }, - // ----- Email delivery / Gmail OAuth2 (admin only) ----- getEmailConfig: () => req('/admin/email/config'), saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }), @@ -494,19 +351,6 @@ export const api = { linkedIdentities: () => req('/player/account/identities'), unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }), - // ----- game account linking (uo-link) ----- - shard: { - link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }), - accounts: () => req('/player/shard/accounts'), - roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`), - vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`), - char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`), - sales: () => req('/player/shard/sales'), - houses: () => req('/player/shard/houses'), // the caller's own houses - createAccount: (account, password) => - req('/player/shard/account', { method: 'POST', body: { account, password } }), - }, - // ----- moderation appeals (self-service) ----- getMyAppeals: () => req('/player/appeals'), getEligibleAppeals: () => req('/player/appeals/eligible'), diff --git a/client/src/components/CharacterSheet.jsx b/client/src/components/CharacterSheet.jsx deleted file mode 100644 index 58c8fb4..0000000 --- a/client/src/components/CharacterSheet.jsx +++ /dev/null @@ -1,293 +0,0 @@ -// Reusable character-sheet renderer for the char.profile shape returned by -// /public/shard/char/:serial. Presentational only — the parent handles loading -// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles). -// -// `moderation` opts in the in-game kick/ban controls for the character's account; -// they self-gate to staff (ShardAccountActions), so passing it from a page a -// player can reach is safe. - -import ShardAccountActions from './ShardAccountActions.jsx' - -const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' } - -// What to call an equipped item. -// -// Items on the wire carry a `LabelNumber`, not a name, so this used to be able -// to show nothing but the layer and `id 12345`. The server now resolves the -// cliloc against its own table and attaches `clilocName` (see -// docs/website/CLILOCS.md); a shard with no cliloc file configured sends none, -// and the layer fallback below is exactly what the sheet did before. -// -// A player-given `name` outranks the resolved type name — "Bob's lucky axe" -// should not be relabelled "hatchet" — and the server applies the same -// precedence, so this only re-states it for a profile that arrived with both. -const itemName = (it) => it.name || it.clilocName || it.layer || 'Item' - -// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already -// computed display strings; reward entries may be a cliloc NUMBER-as-string or a -// literal string. -// -// `rewardResolved` is the server's parallel array with the numeric entries turned -// into words (null where the cliloc table had nothing, or is not configured at -// all). Prefer it, and keep the literal-only path as the fallback for a profile -// served before the cliloc table existed — a numeric entry with no resolution is -// still skipped rather than shown as a raw number. -function displayTitles(titles) { - if (!titles) return [] - const out = [] - if (titles.fameKarma) out.push(titles.fameKarma) - if (titles.skill) out.push(titles.skill) - const raw = Array.isArray(titles.reward) ? titles.reward : [] - const resolved = Array.isArray(titles.rewardResolved) ? titles.rewardResolved : null - const reward = raw.map((r, i) => resolved?.[i] ?? (/^\d+$/.test(String(r)) ? null : String(r))) - const sel = typeof titles.selected === 'number' ? titles.selected : -1 - // Prefer the selected reward title; fall back to the first one that resolved. - // The `??` matters: a selected title whose cliloc did not resolve must fall - // through to the fallback rather than suppress the chip entirely. - const candidate = (sel >= 0 && sel < reward.length ? reward[sel] : null) ?? reward.find(Boolean) - if (candidate) out.push(String(candidate)) - return [...new Set(out.filter(Boolean))] -} - -// The char.profile `points` block (Protocol 3.0 §7.3): one entry per point system -// the character actually holds a score in. Systems at zero are omitted by the -// shard, so an empty list means "this character has earned nothing anywhere", -// which is a normal state for a new character and renders as nothing at all. -// -// `nameString` may be null when the system's name is a cliloc; fall back to -// humanising the PointsType key, exactly as the leaderboards page does. `rank` is -// absent unless the shard runs with Bridge.cfg PointsProfileRank=true — absent and -// "unranked" are different, so the chip only appears when it was actually sent. -const humanisePoints = (key) => - String(key || '') - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .replace(/^./, (c) => c.toUpperCase()) - -function PointsRow({ entry }) { - const label = entry.nameString || humanisePoints(entry.system) - const max = Number.isFinite(entry.maxPoints) && entry.maxPoints > 0 ? entry.maxPoints : 0 - const pct = max ? Math.min(100, Math.round((entry.points / max) * 100)) : 0 - - return ( -
-
- - {label} - {Number.isFinite(entry.rank) && ( - · #{entry.rank} - )} - - - {(entry.points ?? 0).toLocaleString()} - {max > 0 && / {max.toLocaleString()}} - -
- {/* Only systems with a real cap get a bar; an uncapped score has nothing to - be a fraction of, and a full-width bar would imply completion. */} - {max > 0 && ( -
-
-
- )} -
- ) -} - -function TitleChip({ children, tone = 'var(--muted)' }) { - return ( - - {children} - - ) -} - -function StatTile({ value, label }) { - return ( -
-
{value}
-
{label}
-
- ) -} - -function Vital({ label, cur, max }) { - const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0 - return ( -
-
- {label} - {cur ?? '—'} / {max ?? '—'} -
-
-
-
-
- ) -} - -export default function CharacterSheet({ char, moderation = false }) { - if (!char) return null - const stats = char.stats || {} - const resist = stats.resist || {} - // Skills the character actually has, best first. - const skills = (char.skills || []) - .filter((s) => (s.value || s.base || 0) > 0) - .sort((a, b) => (b.value || 0) - (a.value || 0)) - const equipment = char.equipment || [] - // Best standing first, so the character's strongest loyalty leads. Guarded for - // an older shard plugin that sends no `points` block at all. - const points = (Array.isArray(char.points) ? char.points : []) - .filter((p) => p && (p.points || 0) > 0) - .sort((a, b) => (b.points || 0) - (a.points || 0)) - - return ( -
- {/* Identity */} -
-

{char.name || 'Unknown'}

- {char.title && {char.title}} - - - {char.online ? 'Online' : 'Offline'} - - {char.serial} -
- - {/* Titles + standing (guild led / governorship) — all optional */} - {(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && ( -
- {char.governorOf && char.governorOf.map((city) => ( - Governor of {city} - ))} - {char.guild && ( - - Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name} - - )} - {displayTitles(char.titles).map((t) => {t})} -
- )} - - {/* Staff moderation for this character's account (self-gates to staff). */} - {moderation && char.acct && ( -
- Account {char.acct} - -
- )} - - {/* Core stats */} -
-
Attributes
-
- - - -
-
- - - -
-
- - {/* Resistances */} - {Object.keys(resist).length > 0 && ( -
-
Resistances
-
- {['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => ( -
-
{resist[k] ?? 0}
-
{RESIST_LABELS[k]}
-
- ))} -
-
- )} - - {/* Skills */} - {skills.length > 0 && ( -
-
Skills ({skills.length})
-
- {skills.map((s) => { - const cap = s.cap || 100 - const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100)) - return ( -
-
- {s.n} - {s.value} -
-
-
-
-
- ) - })} -
-
- )} - - {/* Loyalty & points — one entry per system this character has scored in */} - {points.length > 0 && ( -
-
- Loyalty & points ({points.length}) -
-
- {points.map((p) => ( - - ))} -
-
- )} - - {/* Equipment */} - {equipment.length > 0 && ( -
-
Equipment
-
- {equipment.map((it) => { - const label = itemName(it) - const layer = it.layer || 'Item' - // The layer only earns its own line once the headline is a real - // name; when it IS the headline, repeating it is just noise. - const detail = [label === layer ? null : layer, `id ${it.itemId}`, it.hue ? `hue ${it.hue}` : null] - return ( -
- -
-
{label}
-
{detail.filter(Boolean).join(' · ')}
-
- {it.mods && Object.keys(it.mods).length > 0 && ( -
- {Object.entries(it.mods).map(([k, v]) => ( - {k} {v} - ))} -
- )} -
- ) - })} -
-
- )} -
- ) -} diff --git a/client/src/components/CharacterStats.jsx b/client/src/components/CharacterStats.jsx deleted file mode 100644 index 12142ed..0000000 --- a/client/src/components/CharacterStats.jsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useEffect, useState } from 'react' - -// A small stat-tile row for a "My Characters" page: total characters, how many -// are online right now, and how many game accounts are linked. `scope` is the -// shard api object (admin or player self-service). Renders nothing until an -// account is linked, so the empty/link-prompt state below it stands alone. -// -// It fetches the same rosters GameAccounts loads; for a personal page that's at -// most a couple of extra live round-trips, and keeps this presentational bit -// decoupled from GameAccounts' per-account roster loading. - -function Tile({ value, label }) { - return ( -
-
{value}
-
- {label} -
-
- ) -} - -// Fold the settled roster results into totals. `complete` is false when any -// account's roster failed (a partial result — shown as a dash rather than a -// misleadingly low count). -function summarizeRosters(rosters) { - let chars = 0 - let online = 0 - let complete = true - for (const r of rosters) { - if (r.status !== 'fulfilled') { - complete = false - continue - } - const cs = r.value.chars || [] - chars += cs.length - online += cs.filter((c) => c.online).length - } - return { chars, online, complete } -} - -export default function CharacterStats({ scope }) { - const [stats, setStats] = useState(null) - - useEffect(() => { - let cancelled = false - ;(async () => { - try { - const accounts = await scope.accounts() - const linked = accounts.length - if (linked === 0) { - if (!cancelled) setStats({ linked: 0 }) - return - } - // Roster is a live round-trip and can be unavailable (503); tolerate a - // partial result so a restarting shard doesn't blank the whole row. - const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account))) - if (!cancelled) setStats({ linked, ...summarizeRosters(rosters) }) - } catch { - if (!cancelled) setStats({ error: true }) - } - })() - return () => { cancelled = true } - }, [scope]) - - // Hidden until we know an account is linked (or while first loading). - if (!stats || stats.error || stats.linked === 0) return null - - // Counts depend on live rosters; show a dash if none came back. - const count = (n) => (stats.complete || stats.chars > 0 ? n : '—') - - return ( -
- - - -
- ) -} diff --git a/client/src/components/CreateGameAccountForm.jsx b/client/src/components/CreateGameAccountForm.jsx deleted file mode 100644 index 7b045f6..0000000 --- a/client/src/components/CreateGameAccountForm.jsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useState } from 'react' - -// Reusable "create a game account" form (its own username + password — the game -// client credentials, distinct from the website login). Calls `submit(account, -// password)` which should POST /player/shard/account; on success calls onCreated. -// Used by the player portal (self-serve) and the invite-accept page alike. -export default function CreateGameAccountForm({ submit, onCreated, compact = false }) { - const [account, setAccount] = useState('') - const [password, setPassword] = useState('') - const [busy, setBusy] = useState(false) - const [msg, setMsg] = useState('') - const [error, setError] = useState('') - - async function onSubmit(e) { - e.preventDefault() - setMsg(''); setError('') - if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/.test(account)) { - return setError('Account name must be 3–30 letters, numbers, . _ or -.') - } - if (password.length < 8) return setError('Password must be at least 8 characters.') - setBusy(true) - try { - await submit(account, password) - setMsg(`Game account “${account}” created and linked.`) - setAccount(''); setPassword('') - if (onCreated) await onCreated() - } catch (err) { - if (err.status === 409) setError('That account name is already taken.') - else if (err.status === 429) setError('The account limit for your network has been reached.') - else if (err.status === 403) setError('Game-account signup is not available right now.') - else if (err.status === 503) setError('The game server is unavailable — try again shortly.') - else setError(err.message || 'Could not create the account right now.') - } finally { - setBusy(false) - } - } - - return ( -
- {!compact && ( -

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

- )} - - - - {error &&

{error}

} - {msg &&

{msg}

} - - -
- ) -} diff --git a/client/src/components/GameAccounts.jsx b/client/src/components/GameAccounts.jsx deleted file mode 100644 index ef9d2ed..0000000 --- a/client/src/components/GameAccounts.jsx +++ /dev/null @@ -1,231 +0,0 @@ -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 ( -
- - - {msg && {msg}} - {error && {error}} -
- ) -} - -function AccountRoster({ scope, account, charTo }) { - const [roster, setRoster] = useState(null) - const [error, setError] = useState('') - const [unavailable, setUnavailable] = useState(false) - - const load = useCallback(async () => { - setError(''); setUnavailable(false) - try { - setRoster(await scope.roster(account)) - } catch (err) { - if (err.status === 503) setUnavailable(true) - else setError(err.message || 'Could not load this account.') - } - }, [scope, account]) - useEffect(() => { load() }, [load]) - - if (unavailable) { - return ( -
-

The game server is restarting — try again shortly.

- -
- ) - } - if (error) return

{error}

- if (!roster) return

Loading…

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

No characters on this account.

- - return ( -
- {chars.map((c) => ( - - - {(c.name || '?').charAt(0)} - -
-
{c.name}
-
{c.online ? 'Online' : 'Offline'}
-
- - - ))} -
- ) -} - -// Compact per-account "Unlink" button for the admin (readOnly) view. Confirms, -// then calls onUnlink(account) and reloads. Errors surface inline. -function UnlinkButton({ account, onUnlink }) { - const [busy, setBusy] = useState(false) - const [error, setError] = useState('') - async function go() { - if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return - setBusy(true); setError('') - try { - await onUnlink(account) - } catch (err) { - const byStatus = { 403: 'Protected account — refused.', 404: 'Not linked.' } - setError(byStatus[err.status] || err.message || 'Could not unlink.') - setBusy(false) - } - } - return ( - - - {error && {error}} - - ) -} - -export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) { - const [accounts, setAccounts] = useState(null) - const [error, setError] = useState('') - // Whether the site currently offers game-account creation (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 - if (!accounts) return - - // No linked accounts. In read-only (admin viewing another user) this is just an - // empty state; otherwise it's the link-your-account prompt. - if (accounts.length === 0) { - if (readOnly) { - return ( -
-

- This user has not linked a game account. -

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

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

- -
- {canCreate && ( -
-
Create a new game account
- -
- )} -
- ) - } - - // Linked — characters grouped by account. - return ( -
- {accounts.map((a) => ( -
-
-
- {a.account} -
- {onUnlink && { await onUnlink(acct); await load() }} />} -
- {moderation && } - -
- ))} - {!readOnly && ( -
-
Link another account
- - {canCreate && ( -
-
Create another game account
- -
- )} -
- )} -
- ) -} diff --git a/client/src/components/MaintenanceGate.jsx b/client/src/components/MaintenanceGate.jsx index 763ce1f..703b9a1 100644 --- a/client/src/components/MaintenanceGate.jsx +++ b/client/src/components/MaintenanceGate.jsx @@ -2,7 +2,7 @@ import { useAuth } from '../contexts/AuthContext.jsx' import { useSite } from '../contexts/SiteContext.jsx' import Maintenance from '../routes/public/Maintenance.jsx' -// Wraps the public site. When the shard is in maintenance, visitors see the +// Wraps the public site. When the site is in maintenance, visitors see the // coming-soon page; a logged-in admin sees the real site (live preview). export default function MaintenanceGate({ children }) { const { mode, loading } = useSite() diff --git a/client/src/components/PlayersOnline.jsx b/client/src/components/PlayersOnline.jsx deleted file mode 100644 index 2b2194a..0000000 --- a/client/src/components/PlayersOnline.jsx +++ /dev/null @@ -1,84 +0,0 @@ -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 ( -
-
- - Players online - - - {loading ? '—' : total} - -
- - {error && ( -

- Population is unavailable right now. -

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

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

- ) : ( - rows.map((r) => ( -
- {r.label} - {/* tabular figures keep the right-aligned counts in a clean column */} - {r.count} -
- )) - )} -
- )} -
- ) -} diff --git a/client/src/components/PublicLayout.jsx b/client/src/components/PublicLayout.jsx index 35537f9..c93713f 100644 --- a/client/src/components/PublicLayout.jsx +++ b/client/src/components/PublicLayout.jsx @@ -1,12 +1,36 @@ import SiteHeader from './SiteHeader.jsx' import SiteFooter from './SiteFooter.jsx' +import { shellClass } from '../lib/pageShell.js' // Standard page chrome for the public site + wiki. -export default function PublicLayout({ section = 'website', header = true, children }) { +// +// ── `shell` — added in MODULE_API_VERSION 1.5.0 ──────────────────────────── +// +// This component supplies the chrome and NOT the body: every core public page +// wraps its own content in `
`, which is what +// centres it in a max-width column, gives it its top and bottom padding, and — +// through `page-body { flex: 1 }` — pushes the footer to the bottom of the +// viewport. Nine of nine core pages do it, so the omission has never shown. +// +// A module page cannot: it is handed `PublicLayout` through the UI kit +// (MODULE_API.md §3.4) and has no way to learn about two class names that appear +// in no contract. The Integration Kit's acceptance run built a module exactly as +// the kit teaches and it rendered full-bleed at x=0 with the footer riding up +// under the content — the precise failure §3.4 says the kit exists to prevent +// ("a module page that does not look like the site it is installed in"). +// +// So the wrapper moves behind the component a module already has. `shell` is +// OPT-IN and omitting it is exactly today's behaviour, which is why core's own +// nine pages are untouched by this change — they keep their own wrapper, and a +// page wanting an unusual body still writes its own. The width mapping and its +// fallback are in lib/pageShell.js, where the DOM-less test runner can reach them. +export default function PublicLayout({ section = 'website', header = true, shell, children }) { + const bodyClass = shellClass(shell) + return (
{header && } - {children} + {bodyClass ?
{children}
: children}
) diff --git a/client/src/components/ShardAccountActions.jsx b/client/src/components/ShardAccountActions.jsx deleted file mode 100644 index 3db37c2..0000000 --- a/client/src/components/ShardAccountActions.jsx +++ /dev/null @@ -1,88 +0,0 @@ -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 ( -
-
- - - - {ok && {ok}} - {err && {err}} -
- - {banOpen && ( -
- - - -
- )} -
- ) -} diff --git a/client/src/components/SiteFooter.jsx b/client/src/components/SiteFooter.jsx index 5b94977..07a6be1 100644 --- a/client/src/components/SiteFooter.jsx +++ b/client/src/components/SiteFooter.jsx @@ -1,5 +1,14 @@ 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() @@ -31,15 +40,19 @@ export default function SiteFooter() {
- {siteTitle} is an independent private shard project. + {siteTitle} is an independent, privately-run game server. {contactEmail} -  ·  - - Shard Status - + {/* 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. */} + <> · {link}} />  ·  Admin diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index e9e142d..84706f0 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -4,22 +4,27 @@ 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 { 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). // -// 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. +// 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). // // 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). +// 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. export const NAV = [ { label: 'Home', to: '/', end: true }, { label: 'News', to: '/site/news' }, @@ -27,15 +32,6 @@ 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: 'Atlas', to: '/site/atlas', feature: 'atlas' }, - { label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' }, - { label: 'Market', to: '/site/market', feature: 'market' }, { label: 'About', to: '/site/about' }, ] @@ -48,23 +44,28 @@ const linkStyle = ({ isActive }) => ({ export default function SiteHeader() { const { user, loading } = useAuth() const { siteTitle, settings } = useSite() - const shardFeatures = useShardFeatures() + 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'), []) // An admin may relabel, reorder and hide these entries from Admin → // Navigation, and may group them into dropdown sections alongside links of // their own (THEMING_AND_NAV.md §7). Two things about the order here: // // • the override merge runs FIRST and the feature filter after it, so the - // filter stays the boundary — an override cannot un-hide a shard surface - // this viewer may not see, whatever it says. `pruneNav` applies the same + // filter stays the boundary — an override cannot un-hide a surface this + // viewer may not see, whatever it says. `pruneNav` applies the same // check inside a section and drops one it leaves empty, so a dropdown // 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. const nav = useMemo(() => { - const tree = buildPublicNav(NAV, parseJsonSetting(settings.nav_public)) - return pruneNav(tree, (item) => !item.feature || canSee(shardFeatures, item.feature)) - }, [settings.nav_public, shardFeatures]) + const tree = buildPublicNav(baseNav, parseJsonSetting(settings.nav_public)) + return pruneNav(tree, isVisible) + }, [baseNav, settings.nav_public, isVisible]) // Where the auth entry points: staff → admin, player → portal, else sign in. let account diff --git a/client/src/components/VendorSales.jsx b/client/src/components/VendorSales.jsx deleted file mode 100644 index 06c4345..0000000 --- a/client/src/components/VendorSales.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useEffect, useState } from 'react' -import { ago } from '../lib/format.js' - -// Owner-private recent player-vendor sales. `fetchSales` is the scope method -// (api.player.shard.sales / api.admin.shard.sales) — the server only returns -// sales for accounts linked to the caller. -export default function VendorSales({ fetchSales }) { - const [sales, setSales] = useState(null) - const [error, setError] = useState('') - - useEffect(() => { - let active = true - fetchSales() - .then((rows) => active && setSales(rows)) - .catch(() => active && setError('Could not load your vendor sales.')) - return () => { active = false } - }, [fetchSales]) - - if (error) return null - if (!sales) return null - - return ( -
-
Recent vendor sales
- {sales.length === 0 ? ( -

No vendor sales recorded yet.

- ) : ( -
    - {sales.map((s) => ( -
  • - - {s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} — {Number(s.price || 0).toLocaleString()}gp - - {ago(s.t)} -
  • - ))} -
- )} -
- ) -} diff --git a/client/src/data/cityCrests.js b/client/src/data/cityCrests.js deleted file mode 100644 index d9bff1b..0000000 --- a/client/src/data/cityCrests.js +++ /dev/null @@ -1,31 +0,0 @@ -// Placeholder heraldry for the eight City-Loyalty cities. Each entry is a simple -// emoji sigil + a ring colour — enough to make the Governors board and the -// governor badge read as distinct "crests" today, swappable for real artwork -// later WITHOUT touching any component: drop an `img` (an imported asset URL or a -// public path) onto an entry and update CityCrest to prefer it. -// -// Keyed by the exact `city` string the sidecar sends (see INTEGRATION.md §4: -// Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia). - -export const CITY_CRESTS = { - Britain: { sigil: '⚜', color: '#c9a24b', label: 'Britain' }, - Moonglow: { sigil: '🔮', color: '#7f8fd0', label: 'Moonglow' }, - Minoc: { sigil: '⚒', color: '#b0763f', label: 'Minoc' }, - Trinsic: { sigil: '⚓', color: '#5f9bd0', label: 'Trinsic' }, - Yew: { sigil: '🌳', color: '#5fb98a', label: 'Yew' }, - Jhelom: { sigil: '⚔', color: '#c76f6f', label: 'Jhelom' }, - SkaraBrae: { sigil: '🐎', color: '#9a8bbf', label: 'Skara Brae' }, - NewMagincia: { sigil: '🕊', color: '#cfc3a0', label: 'New Magincia' }, -} - -const FALLBACK = { sigil: '🏰', color: '#8c96a5', label: '' } - -// Look up a crest by the raw city key, tolerating spacing variants -// ("Skara Brae" / "New Magincia"). `label` falls back to the given name. -export function crestFor(city) { - if (!city) return FALLBACK - const key = String(city).replace(/\s+/g, '') - const crest = CITY_CRESTS[city] || CITY_CRESTS[key] - if (crest) return crest - return { ...FALLBACK, label: String(city) } -} diff --git a/client/src/data/regionBuckets.js b/client/src/data/regionBuckets.js deleted file mode 100644 index ffbc1dd..0000000 --- a/client/src/data/regionBuckets.js +++ /dev/null @@ -1,72 +0,0 @@ -// Roll the sidecar's raw presence.online `byRegion` map (many named ServUO -// regions) up into a handful of labelled display buckets for the "Players Online" -// widget. This is the ONE place to retune the grouping — edit BUCKETS (order + -// membership) and the widget follows. Anything not matched lands in "Wilderness" -// so the bucket counts always reconcile to the true total. - -// Named cities/towns, matched as a prefix on the (space/apostrophe-stripped) -// region name so "skara brae", "serpent's hold", etc. all resolve. Kept as a -// list rather than one giant alternation regex (simpler to read and retune). -const TOWN_PREFIXES = [ - 'moonglow', 'minoc', 'trinsic', 'jhelom', 'yew', 'skarabrae', 'magincia', - 'newmagincia', 'vesper', 'nujelm', 'cove', 'ocllo', 'serpenthold', 'serpentshold', - 'wind', 'delucia', 'papua', -] -const normalizeRegion = (r) => String(r).toLowerCase().replace(/['’\s]/g, '') - -// Ordered list of buckets. `label` shows in the widget; `match(region)` decides -// membership. First matching bucket wins; the last bucket is the catch-all. -export const BUCKETS = [ - { - id: 'britain', - label: 'Britain', - // Passthrough for the capital + its immediate surrounds. - match: (r) => /^britain/i.test(r), - }, - { - id: 'towns', - label: 'Towns', - // The other named cities/towns. - match: (r) => { - const norm = normalizeRegion(r) - return TOWN_PREFIXES.some((t) => norm.startsWith(t)) - }, - }, - { - id: 'dungeons', - label: 'Dungeons', - match: (r) => - /(despise|destard|deceit|shame|hythloth|covetous|wrong|terathan|fire|ice|orc cave|dungeon|abyss|doom|khaldun|wrong|blackthorn|exodus|labyrinth|underworld)/i.test( - r, - ), - }, - { - id: 'housing', - label: 'Housing', - // House regions expose themselves as named house/townhouse regions. - match: (r) => /(house|townhouse|homestead|tent)/i.test(r), - }, - { - id: 'wilderness', - label: 'Wilderness', - // Catch-all: the unnamed "Wilderness" region + anything unmatched above. - match: () => true, - }, -] - -// Given a raw { region: count } map, return [{ id, label, count }] in BUCKETS -// order, dropping empty buckets, with the summed total also returned. -export function bucketize(byRegion = {}) { - const totals = new Map(BUCKETS.map((b) => [b.id, 0])) - let total = 0 - for (const [region, n] of Object.entries(byRegion || {})) { - const count = Number(n) || 0 - total += count - const bucket = BUCKETS.find((b) => b.match(String(region))) || BUCKETS[BUCKETS.length - 1] - totals.set(bucket.id, totals.get(bucket.id) + count) - } - const rows = BUCKETS.map((b) => ({ id: b.id, label: b.label, count: totals.get(b.id) })).filter( - (r) => r.count > 0, - ) - return { rows, total } -} diff --git a/client/src/lib/adminNav.js b/client/src/lib/adminNav.js new file mode 100644 index 0000000..67a397a --- /dev/null +++ b/client/src/lib/adminNav.js @@ -0,0 +1,100 @@ +// 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 +} diff --git a/client/src/lib/heroLayout.js b/client/src/lib/heroLayout.js index b89f7cb..077d5cf 100644 --- a/client/src/lib/heroLayout.js +++ b/client/src/lib/heroLayout.js @@ -67,6 +67,11 @@ export function parseLayout(str) { // The current hardcoded hero as a HeroLayout, so the page is unchanged until // staff publish their own. Font sizes use the existing clamp() strings so the // default stays responsive (editor-created text uses px). +// +// The copy is deliberately game-neutral, and deliberately still copy: this is +// also the starting point the hero editor loads, so an instance that wants to +// name its game says so there, once, and the result is stored — rather than core +// shipping one game's words for every instance to overwrite in source. export function defaultLayout(teaser, name = 'Runic Gateway') { return { version: 1, @@ -84,9 +89,9 @@ export function defaultLayout(teaser, name = 'Runic Gateway') { align: 'center', width: 760, lines: [ - { text: 'Private shard project', tag: 'span', fontSize: '0.74rem', color: '#c2d2e6', weight: 700, letterSpacing: '0.22em', transform: 'uppercase', font: 'sans' }, + { text: 'Private game server', tag: 'span', fontSize: '0.74rem', color: '#c2d2e6', weight: 700, letterSpacing: '0.22em', transform: 'uppercase', font: 'sans' }, { text: name, tag: 'h1', fontSize: 'clamp(3rem,8.5vw,5.75rem)', color: 'var(--head)', weight: 600, letterSpacing: '0.02em', lineHeight: 1, font: 'display', marginTop: 14 }, - { text: 'A private Ultima Online world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 }, + { text: 'A private world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 }, { text: teaser, tag: 'div', html: true, fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 }, ], }, diff --git a/client/src/lib/moduleAdmin.js b/client/src/lib/moduleAdmin.js new file mode 100644 index 0000000..d3e09ab --- /dev/null +++ b/client/src/lib/moduleAdmin.js @@ -0,0 +1,252 @@ +// What an admin should be told about one installed module, and what they may do +// to it — derived, not spelled out at each button. +// +// Phase 4, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.2. Plain JS rather than +// a hook or a chunk of JSX, for the same reason `lib/adminNav.js` is: the test +// runner here has no DOM, and this is the part of the Modules screen that is +// actually worth testing. +// +// **The screen has four sources of truth and they are allowed to disagree** +// (MODULE_SYSTEM.md §2.4, and slice 3 for the fourth): +// +// state what the DATABASE row records — what the operator decided, and +// what the last boot ended up doing +// liveState what the LOADER has mounted in this process and is answering with +// onVolume whether there is still a directory there at all +// declared what this container's MODULES variable asks for — the only one of +// the four that no button on this screen can change +// +// Picking one and rendering it would be simpler and would lie. The case that +// makes this concrete is the one decision 3 creates on purpose: an operator +// disables a module (its onShutdown runs, its routes 404) and then enables it +// again. The row says `enabled`; the loader still says `disabled`, because +// there is no `onBoot` re-dispatch and nothing can start it before a restart. +// It is neither running nor off, and the honest thing to show is "enabled — +// restart to start it". + +/** + * The one-line status of a module, and whether that status is waiting on a + * restart. + * + * Ordering matters here. The checks run most-alarming first, so a module whose + * directory has been deleted is described that way rather than by whatever its + * row happens to still say. + * + * @param {object} m a row from GET /admin/modules + * @returns {{ label: string, tone: 'ok'|'warn'|'bad'|'idle', pending: boolean, detail: string }} + */ +export function statusOf(m) { + // Declared by the environment and not there at all: no row, no directory, + // nothing mounted. Every other branch below reads one of those three, so + // without this the screen would describe a module it has never had as though + // a row had gone stale — and the one thing the operator needs, the reason + // resolution failed, would be nowhere. + if (m.declared && !m.onVolume && m.state === null) { + return { + label: 'Declared, not installed', + tone: 'bad', + pending: false, + detail: m.declaredError + ? `MODULES asks for v${m.declaredVersion}; the last start could not install it: ${m.declaredError}` + : `MODULES asks for v${m.declaredVersion}. It will be installed when the server next starts.`, + } + } + + // Gone from the volume, but still known. Either a hand-deleted directory (the + // boot reconcile marks that `startup_failed`) or an uninstall waiting for its + // restart. Both are "there is nothing to run here". + if (!m.onVolume) { + return { + label: m.state === 'disabled' ? 'Uninstalled' : 'Missing from the volume', + tone: m.state === 'disabled' ? 'idle' : 'bad', + pending: m.liveState !== null, + detail: m.state === 'disabled' + ? 'The files are gone. Its data was kept, and reinstalling brings it back.' + : 'A row exists but there is no module directory. Reinstall it, or uninstall to clear the row.', + } + } + + // **Installed since this process booted**, and this check has to come before + // the failure one. `liveState` is the loader's record, and the loader scans + // the volume once at require time — so a module that is on the volume NOW and + // has no live record was put there after the scan. Anything the row still says + // about it therefore predates the install and is stale by definition. + // + // Found by the §7.7 browser smoke, and no unit test here had modelled it: + // installing over a row left `startup_failed` by the previous boot rendered + // "Failed at the require stage: module directory not present on the volume" + // one second after the file had been written to the volume — and, because that + // branch is not pending, suppressed the restart banner the install had just + // told the operator to use. + if (m.liveState === null) { + return { + label: 'Restart to start', + tone: 'warn', + pending: true, + detail: 'Installed. It mounts when the server next starts.', + } + } + + if (m.state === 'startup_failed' || m.liveState === 'startup_failed') { + return { + label: 'Failed to start', + tone: 'bad', + pending: false, + detail: m.failureReason + ? `Failed at the ${m.failureStage || 'unknown'} stage: ${m.failureReason}` + : 'It failed to start and recorded no reason.', + } + } + + if (m.state === 'disabled') { + return { + label: 'Disabled', + tone: 'idle', + pending: false, + detail: 'Stopped and switched off. Its routes answer 404 and it stays off across restarts.', + } + } + + // The row has been switched on but the loader has not started it — the + // decision-3 case: disable ran its onShutdown, and nothing can start it again + // before a restart. + if (m.liveState !== 'started') { + return { + label: 'Restart to start', + tone: 'warn', + pending: true, + detail: m.liveState === 'disabled' + ? 'Enabled, but still stopped in the running server — it cannot be restarted in place.' + : 'Enabled. It mounts when the server next starts.', + } + } + + // Running, but not the version that is installed. An upgrade writes new files + // and a new row while the old code stays loaded, so the row's `version` is a + // promise about the next boot rather than a description of this one — and + // "Running v2.0.0" beside a process serving v1.0.0 is the same lie as the + // stale-failure one above, in a different place. + if (m.liveVersion && m.liveVersion !== m.version) { + return { + label: 'Restart to finish upgrading', + tone: 'warn', + pending: true, + detail: `v${m.version} is installed; v${m.liveVersion} is still running.`, + } + } + + return { + label: 'Running', + tone: 'ok', + pending: false, + detail: 'Mounted and serving.', + } +} + +/** + * What the environment's declaration means for this module, as one sentence — or + * null if nothing declares it. + * + * Kept out of `statusOf` on purpose. A module can be running perfectly while its + * declared upgrade is failing, and collapsing both into one label would have to + * pick which of the two is "the" status. This is a second line, beside the first. + * + * The sentence an operator most needs is the uninstall one: MODULES owns what is + * on the volume and the row owns whether it runs, so uninstalling a declared + * module puts its files back at the next start and leaves it switched off. Files + * reappearing unexplained is exactly the kind of thing that gets debugged for an + * afternoon. + * + * @param {object} m a row from GET /admin/modules + * @returns {{ text: string, tone: 'warn'|'idle' }|null} + */ +export function declarationNoteFor(m) { + if (!m.declared) return null + + if (m.declaredError) { + return { + text: `MODULES asks for v${m.declaredVersion} and the last start could not install it: ${m.declaredError}`, + tone: 'warn', + } + } + if (!m.onVolume) { + return { + text: + `MODULES declares v${m.declaredVersion}, so its files come back when the server next starts` + + (m.state === 'disabled' ? ' — switched off, until you enable it.' : '.'), + tone: 'warn', + } + } + return { text: `Declared by this deployment's MODULES variable at v${m.declaredVersion}.`, tone: 'idle' } +} + +/** + * Which actions are offered for a module, and why the others are not. + * + * Returned as a map of `{ shown, reason }` rather than a list of shown actions, + * so a disabled button can say what would make it available. Every rule here + * mirrors one the server enforces — this is presentation, never the boundary. + * + * @param {object} m a row from GET /admin/modules + */ +export function actionsFor(m) { + const running = m.liveState === 'started' + const disabled = m.state === 'disabled' + + return { + // Only offered while something is actually running: disabling a module that + // is already stopped has nothing to stop and no guard to flip. + disable: { + shown: !disabled && m.onVolume, + reason: disabled ? 'Already disabled.' : 'Nothing is running to stop.', + }, + enable: { + shown: disabled && m.onVolume, + reason: 'Only a disabled module can be enabled.', + }, + uninstall: { + shown: m.onVolume, + reason: 'There are no files left to remove.', + }, + // The server refuses a standalone purge unless the module is disabled, so + // the button says so rather than offering a click that 409s. + purge: { + shown: m.onVolume && m.canPurge, + enabled: disabled, + reason: !m.canPurge + ? 'This module ships no purge.sql, so its data cannot be deleted.' + : 'Disable it first, so nothing is serving out of the tables being dropped.', + }, + // A row with no directory is the one thing an uninstall cannot tidy through + // the normal path — offer clearing it instead. + forget: { + shown: !m.onVolume && m.state !== null, + reason: 'The module is still installed.', + }, + running, + } +} + +/** + * Does anything on this list need a restart before it matches what is running? + * + * Drives the one banner at the top of the screen rather than a badge per row: + * the restart is a property of the SERVER, not of a module, and offering it + * five times would suggest otherwise. + */ +export const needsRestart = (modules) => modules.some((m) => statusOf(m).pending) + +/** + * Split a hosts string the way the server will. + * + * Duplicated from `install.parseHosts` deliberately — it is four lines, and the + * alternative is an API round trip to preview what the field is going to mean. + * The server remains the one that decides; this only shows the operator how + * their typing will be read. + */ +export function parseHosts(value) { + return String(value || '') + .split(/[,\s]+/) + .map((h) => h.trim().toLowerCase()) + .filter(Boolean) +} diff --git a/client/src/lib/navOverrides.js b/client/src/lib/navOverrides.js index c82dc72..74c332d 100644 --- a/client/src/lib/navOverrides.js +++ b/client/src/lib/navOverrides.js @@ -20,7 +20,10 @@ // Two shapes are supported, because two exist: // flat [{ to, label, ... }] — public header, player portal // grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar -function isGrouped(nav) { +// 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) { return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items)) } @@ -201,7 +204,7 @@ export function buildNavRows(baseNav, overrides) { // // The base side is restricted to the rows the editor is actually holding: §8.1 // filters the palette to what this admin can themselves see, and an item that -// their role or a shard feature kept off the screen is not a reorder. +// their role or a module's feature gate kept off the screen is not a reorder. function orderMatchesBase(groups, baseNav) { const flatten = (gs) => gs.flatMap((g) => g.items.map((i) => `${g.title ?? ''}::${i.to}`)) const base = isGrouped(baseNav) @@ -405,9 +408,10 @@ export function buildPublicNav(baseNav, overrides, { keepHidden = false } = {}) * Apply the caller's visibility gate — and drop a section it leaves empty. * * Kept here rather than in SiteHeader because the empty-dropdown case is the one - * with real correctness risk: a section whose every entry is hidden by shard - * visibility must not render as a menu that opens onto nothing. The predicate - * stays the caller's, so this module still knows nothing about shard features. + * with real correctness risk: a section whose every entry is hidden by a + * module's visibility rules must not render as a menu that opens onto nothing. + * The predicate stays the caller's, so this module still knows nothing about + * what any module gates on. * * Added links carry no gate, so they are always visible — see the note above. * @@ -486,7 +490,7 @@ export function buildPublicNavOverrides(tree, baseNav, stored = null) { } // Carry through an entry for a coded item this admin's palette never showed - // them (shard-feature gated), so their save does not silently reset it. + // them (feature-gated by its module), so their save does not silently reset it. const { items: storedItems } = unwrapPublic(stored) for (const [to, entry] of Object.entries(storedItems)) { if (!shown.has(to) && baseLabels.has(to) && entry && typeof entry === 'object') items[to] = entry diff --git a/client/src/lib/pageShell.js b/client/src/lib/pageShell.js new file mode 100644 index 0000000..3eee530 --- /dev/null +++ b/client/src/lib/pageShell.js @@ -0,0 +1,26 @@ +// The page-body shell core's public pages sit in, as plain JS. +// +// Extracted from PublicLayout.jsx for the reason lib/adminNav.js was: the client +// test runner has no DOM and cannot import a .jsx file at all +// (client/test/moduleRegistry.test.js says the same about modules/shared.js), so +// anything with a rule worth asserting has to live outside the component. +// +// The rule worth asserting here is the fallback. `shell` is part of the module +// contract as of MODULE_API_VERSION 1.5.0 (MODULE_API.md §3.4), which means the +// value can come from a module core has never seen, written against a version of +// this list that is older or newer than the one running. An unknown width must +// therefore still produce a wrapper: a module page at the wrong width looks like +// the site, and a page with no wrapper does not — it renders full-bleed with the +// footer riding up under it, which is the defect the prop exists to fix. + +const SHELLS = { narrow: 'shell-narrow', mid: 'shell-mid', wide: 'shell-wide' } + +export const SHELL_WIDTHS = Object.keys(SHELLS) + +// Returns the className for a page body, or null when no shell was asked for — +// null is "render children bare", which is every core page written before 1.5.0 +// and stays the default forever. +export function shellClass(shell) { + if (!shell) return null + return `${SHELLS[shell] || SHELLS.narrow} page-body` +} diff --git a/client/src/lib/shardEvents.js b/client/src/lib/shardEvents.js deleted file mode 100644 index 5ad5786..0000000 --- a/client/src/lib/shardEvents.js +++ /dev/null @@ -1,128 +0,0 @@ -// 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, ' ') -} diff --git a/client/src/lib/useShardFeatures.js b/client/src/lib/useShardFeatures.js deleted file mode 100644 index f1c8533..0000000 --- a/client/src/lib/useShardFeatures.js +++ /dev/null @@ -1,58 +0,0 @@ -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) -} diff --git a/client/src/lib/useShardFeed.js b/client/src/lib/useShardFeed.js deleted file mode 100644 index 60e9558..0000000 --- a/client/src/lib/useShardFeed.js +++ /dev/null @@ -1,54 +0,0 @@ -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 } -} diff --git a/client/src/main.jsx b/client/src/main.jsx index dc409af..c2c3101 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -2,12 +2,97 @@ import React from 'react' 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' -createRoot(document.getElementById('root')).render( - - - - - , -) +// Publish window.__rg BEFORE rendering and before any module chunk evaluates. +// Installed modules arrive as ``) } // The admin theme as a :root block, or '' when this instance has never been @@ -170,7 +204,26 @@ async function get() { // mean a failing query per page view. overrides = {} } - const html = render(template, overrides) + // The module list is in-memory and filesystem-derived, so unlike the brand + // read above it cannot fail on a DB fault and needs no fallback of its own. + // Required lazily for the same reason the settings model is: app.js requires + // this file, and the loader would otherwise be pulled into that chain. + let moduleEntries = [] + try { + // eslint-disable-next-line global-require + moduleEntries = require('../modules/loader').clientEntryUrls() + } catch { + // The only reachable throw is §7.6's guard — the shell rendered before + // modules.load() ran, which app.js's ordering makes impossible and a test + // that renders in isolation makes possible. A page with no module scripts + // is the right answer either way; it is what a bare core serves. + moduleEntries = [] + } + // Note for whoever builds the admin Modules screen: a state change after boot + // (an operator disabling a module) has to call invalidate(), exactly as a + // brand-asset write does. The TTL converges on its own within five minutes; + // the explicit call is what makes the toggle feel like it did something. + const html = render(template, { ...overrides, moduleEntries }) // An invalidation that landed while this read was in flight means the value // we just read may already be stale. Serve it, but do not cache it. if (generation === startedAt) cached = { html, at: Date.now() } diff --git a/server/src/utils/newsGump.js b/server/src/utils/newsGump.js deleted file mode 100644 index 804c22b..0000000 --- a/server/src/utils/newsGump.js +++ /dev/null @@ -1,124 +0,0 @@ -// ── Town Cryer News gump sync (Protocol 2.1) ─────────────────────────────── -// -// Keeps the in-game Town Cryer *News* gump in sync with the site's published -// news posts. Distinct from the scrolling town-crier lines (that's a one-shot -// announce leg in announceWorker); this is a STATE SYNC — an article stays in the -// gump while its post is published news, and is pulled when the post is -// unpublished/deleted/re-categorised. -// -// The website is the source of truth. POST /news is idempotent (re-post replaces), -// so a refresh or a reconnect re-assert is safe. Every call is best-effort and -// never throws — a sidecar/shard hiccup must never break saving or deleting a -// post. Reliability comes from reassertAll() on every WS (re)connect -// (uoLinkSocket.backfill), which re-pushes the current published set silently and -// closes the gap if an earlier live push failed. - -const posts = require('../model/posts/posts.model') -const uoLinkClient = require('./uoLinkClient') -const settings = require('../model/settings/settings.model') -const { deriveExcerpt } = require('./sanitizeHtml') -const log = require('./logger')('news-gump') - -const MAX_TITLE = 120 -const MAX_BODY = 900 - -function baseUrl() { - return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '') -} - -function clamp(value, max) { - const s = String(value == null ? '' : value).replace(/\s+/g, ' ').trim() - return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…` -} - -// A post belongs in the gump exactly when it is published AND in the news category. -function inGump(post) { - return Boolean(post && post.published && post.category === 'news') -} - -// Optional UO gump image id for news articles (a shard art id), from the -// `news_gump_image` setting. Omitted → the sidecar uses a neutral scroll. -async function gumpImage() { - try { - const raw = await settings.get('news_gump_image') - const n = Number(raw) - return Number.isInteger(n) && n > 0 ? n : undefined - } catch { - return undefined - } -} - -// Build the in-game News article from a post. Body is a compact gump-HTML block -// (title centred + a plain-text excerpt) rather than the post's full rich HTML — -// the UO gump only supports a small HTML subset, so we keep it predictable. The -// "more info" URL is the public news list (news posts have no per-post route). -async function buildArticle(post, { announce = true } = {}) { - const title = clamp(post.title, MAX_TITLE) - const excerpt = clamp(post.excerpt || deriveExcerpt(post.body, MAX_BODY) || '', MAX_BODY) - const body = excerpt ? `
${title}


${excerpt}` : `
${title}
` - return { - id: String(post.id), - title, - body, - image: await gumpImage(), - url: `${baseUrl()}/site/news`, - announce, - } -} - -// Push a post to the gump (only if it belongs there). announce=true has the criers -// proclaim the title; false is a silent refresh/re-assert. -async function pushPost(post, { announce = true } = {}) { - if (!inGump(post)) return { ok: false, skipped: true } - const res = await uoLinkClient.postNews(await buildArticle(post, { announce })) - if (!res.ok) log.warn('news gump push failed', { id: post.id, status: res.status, error: res.error }) - return res -} - -// Remove a post from the gump. A 404 (not present) is not an error worth noting. -async function removePost(id) { - const res = await uoLinkClient.deleteNews(String(id)) - if (!res.ok && res.status !== 404) { - log.warn('news gump remove failed', { id, status: res.status, error: res.error }) - } - return res -} - -// Reconcile the gump after a post create/update/publish. `transition` -// ({ wasPublished, wasNews }) tells a fresh publish (announce) from an in-place -// edit (silent refresh) and catches a post leaving published-news (pull it). -async function syncPost(post, transition = {}) { - try { - if (inGump(post)) { - const wasInGump = Boolean(transition.wasPublished && transition.wasNews) - await pushPost(post, { announce: !wasInGump }) - } else if (transition.wasPublished && transition.wasNews) { - await removePost(post.id) - } - } catch (err) { - log.warn('news gump sync failed', { id: post && post.id, message: err.message }) - } -} - -// Re-push every currently-published news post, silently — run on each WS -// (re)connect to reconcile the gump to our source of truth (also recovers any -// article whose original live push failed). Best-effort; never throws. -async function reassertAll() { - try { - const list = await posts.listAll('news') - const published = (list || []).filter((p) => p.published) - let pushed = 0 - for (const p of published) { - const full = await posts.getById(p.id) // list projection may omit the body - if (full) { - await pushPost(full, { announce: false }) - pushed += 1 - } - } - if (pushed) log.info('re-asserted news gump articles', { count: pushed }) - } catch (err) { - log.warn('news gump reassert failed', { message: err.message }) - } -} - -module.exports = { inGump, buildArticle, pushPost, removePost, syncPost, reassertAll } diff --git a/server/src/utils/pushDispatch.js b/server/src/utils/pushDispatch.js index 9a4a47b..5a96ccc 100644 --- a/server/src/utils/pushDispatch.js +++ b/server/src/utils/pushDispatch.js @@ -1,10 +1,14 @@ // ── Push-notification fan-out (content-free tickles) ─────────────────────── // -// The transport-agnostic publisher that turns an event into opt-in push -// notifications. Two producers call in: -// • utils/shardIngest.js → fromShardEvent(event) for shard-derived streams -// (beside the existing SSE broadcast — same event source, same allowlist). -// • the admin create/publish-post path → publish('news.post', …). +// The transport-agnostic publisher that turns a stream id into opt-in push +// notifications. It knows nothing about where the stream came from: the admin +// create/publish-post path calls publish('news.post', …), and utils/shardPush.js +// resolves a shard event to a stream and an owner and calls the same function. +// +// That split is MODULE_SYSTEM.md §1.8's second entanglement, inverted. This file +// used to own `fromShardEvent()`, which required the shardLinks model and the +// shard event mapper — core infrastructure reaching into game content. Now the +// content side calls in, and a module reaches this through `ctx.push.publish`. // // What actually leaves the server is a CONTENT-FREE tickle — `{ stream, ref }`, // no sensitive data — POSTed to each subscribed device's UnifiedPush/ntfy @@ -18,9 +22,7 @@ // registration AND every publish: HTTPS only, never a private/loopback host, and // (when configured) the origin must be in the shard's ntfy allow-set. -const shardLinks = require('../model/shardLinks/shardLinks.model') const pushDevicesModel = require('../model/pushDevices/pushDevices.model') -const { mapShardEvent } = require('../config/notificationStreams') const log = require('./logger')('push-dispatch') const TIMEOUT_MS = 5000 @@ -106,30 +108,4 @@ async function publish(streamId, { ref, ownerUserId } = {}, deps = {}) { await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps))) } -// Fan a shard event out to push. Resolves personal (owner-keyed) targets to the -// owning website user via shardLinks (an unlinked account → nobody to notify). -// Never throws — a dead relay must never affect ingest. -async function fromShardEvent(event, deps = {}) { - const links = deps.shardLinks || shardLinks - const targets = mapShardEvent(event, deps.tracker) - for (const t of targets) { - try { - if (t.ownerAccount) { - let owner = null - try { - owner = await links.getByAccount(t.ownerAccount) - } catch { - owner = null - } - if (!owner || owner.userId == null) continue - await publish(t.streamId, { ref: t.ref, ownerUserId: owner.userId }, deps) - } else { - await publish(t.streamId, { ref: t.ref }, deps) - } - } catch (err) { - log.warn('push dispatch target failed', { streamId: t.streamId, message: err.message }) - } - } -} - -module.exports = { publish, fromShardEvent, isAllowedEndpoint } +module.exports = { publish, isAllowedEndpoint } diff --git a/server/src/utils/shardBroadcast.js b/server/src/utils/shardBroadcast.js deleted file mode 100644 index ea5941f..0000000 --- a/server/src/utils/shardBroadcast.js +++ /dev/null @@ -1,166 +0,0 @@ -// ── Shard live-feed SSE broadcaster ──────────────────────────────────────── -// -// The browser can't talk to the sidecar's WebSocket directly (the token must -// never reach it, and the WS may be on another host). Instead the server ingests -// the WS feed and re-broadcasts events to browsers over Server-Sent Events -// (plain HTTP — works through any reverse proxy). -// -// Since Protocol 3.0 the split is no longer "one public channel with a static -// allowlist plus one admin channel". Each subscriber carries the audience rung -// it resolved to at subscribe time, and every frame is -// -// 1. mapped kind → feature (an UNMAPPED kind reaches nobody below admin — -// fail closed; see utils/shardVisibility.js rule 2), -// 2. gated on that feature being enabled, streamed, and within the viewer's -// rung, and -// 3. passed through field projection, so `acct` / `webId` and any field an -// admin has re-gated are stripped per viewer. -// -// **This is the security boundary.** It used to be the PUBLIC_KINDS set in this -// file; it is now the kind map plus the visibility config. PUBLIC_KINDS still -// exists and is still exported, but it is now DERIVED from the kind map (see -// shardVisibility.js) so the two can no longer drift. -// -// shardIngest calls broadcast(event) for each ingested event; the public/admin -// SSE route handlers call subscribe(req, res, channel). - -const visibility = require('./shardVisibility') -const log = require('./logger')('shard-broadcast') - -// Re-exported for back-compat: shardEvents `/feed` filtering and -// config/notificationStreams.js both ask "is this kind public-safe?". -const { PUBLIC_KINDS } = visibility - -// Open streams. Each entry is { res, level }. The admin bucket is kept separate -// because it is unconditional and must not depend on a config read. -const clients = { public: new Set(), admin: new Set() } - -const KEEPALIVE_MS = 25000 - -// Register an SSE stream on a channel. Sets the SSE headers, sends an initial -// comment, keeps the connection warm with periodic pings, and cleans up on close. -// -// The viewer's rung is resolved ONCE, here, and frozen for the life of the -// connection — a long-lived stream must not silently gain privilege because the -// caller's session changed underneath it. (Config changes, by contrast, DO take -// effect live: the config is read per broadcast, cached ~5s.) -async function subscribe(req, res, channel) { - const bucket = clients[channel] - if (!bucket) { - res.status(400).end() - return - } - - let level = 'admin' - if (channel === 'public') { - try { - level = await visibility.viewerLevel(req) - } catch (err) { - // Fail closed: an unresolvable viewer is anonymous, not privileged. - log.warn('viewerLevel failed on subscribe; treating as anonymous', { message: err.message }) - level = 'anonymous' - } - } - - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', // disable proxy buffering so events flush immediately - }) - res.write('retry: 5000\n\n') // tell EventSource to reconnect after 5s if dropped - res.write(': connected\n\n') - - const client = { res, level, ping: null } - bucket.add(client) - - client.ping = setInterval(() => { - try { - res.write(': ping\n\n') - } catch { - /* write after close — cleanup below handles it */ - } - }, KEEPALIVE_MS) - - const cleanup = () => drop(bucket, client) - req.on('close', cleanup) - res.on('error', cleanup) -} - -// The ONLY way a client leaves a bucket. Clearing the keepalive here (rather -// than only in the close handler) matters: a client dropped because its write -// threw never fires `req.close`, so its interval would otherwise keep firing on -// a dead socket for the life of the process. -function drop(bucket, client) { - clearInterval(client.ping) - bucket.delete(client) -} - -function writeTo(bucket, client, payload) { - try { - client.res.write(payload) - } catch (err) { - log.warn('sse write failed; dropping client', { message: err.message }) - drop(bucket, client) - } -} - -// Fan an ingested event out. The admin channel gets it verbatim, always. Public -// subscribers are filtered and projected per their own rung — so two viewers on -// the same channel can legitimately receive different versions of one frame, or -// one of them nothing at all. -async function broadcast(event) { - if (!event || !event.kind) return - - if (clients.admin.size) { - const frame = `data: ${JSON.stringify(event)}\n\n` - for (const client of [...clients.admin]) writeTo(clients.admin, client, frame) - } - - if (!clients.public.size) return - - let config - try { - config = await visibility.getConfig() - } catch (err) { - // Fail closed: without a config we cannot prove a frame is safe to send. - log.error('visibility config unavailable; withholding public frame', err) - return - } - - // Most frames land on one rung set, so cache the serialised payload per level - // instead of re-projecting and re-stringifying for every subscriber. - const byLevel = new Map() - for (const client of [...clients.public]) { - let frame = byLevel.get(client.level) - if (frame === undefined) { - frame = visibility.kindVisibleTo(event.kind, client.level, config) - ? `data: ${JSON.stringify(visibility.projectFeature(visibility.KIND_FEATURE.get(event.kind), event, client.level, config))}\n\n` - : null - byLevel.set(client.level, frame) - } - if (frame) writeTo(clients.public, client, frame) - } -} - -// Close every open stream (graceful shutdown). Clears each keepalive timer too — -// without that the intervals keep the event loop alive after the streams are -// gone, and the process won't exit. -function closeAll() { - for (const bucket of Object.values(clients)) { - for (const client of [...bucket]) { - drop(bucket, client) - try { - client.res.end() - } catch { - /* ignore */ - } - } - } -} - -function stats() { - return { publicClients: clients.public.size, adminClients: clients.admin.size } -} - -module.exports = { subscribe, broadcast, closeAll, stats, PUBLIC_KINDS } diff --git a/server/src/utils/shardIngest.js b/server/src/utils/shardIngest.js deleted file mode 100644 index 13b62cc..0000000 --- a/server/src/utils/shardIngest.js +++ /dev/null @@ -1,314 +0,0 @@ -// ── Shard event ingest dispatcher ────────────────────────────────────────── -// -// The single entry point for every event that arrives on the uo-link WebSocket -// feed (and for backfilled /history events on reconnect). It routes by kind: -// • state-changing kinds update shard_online / shard_economy / shard_houses, -// • notable kinds are appended to the append-only shard_events log, -// • every kind is fanned out to the SSE broadcaster (which decides public vs -// admin visibility). -// High-frequency kinds (char.vitals, economy.supply) are deliberately NOT logged -// to shard_events — they only update state — keeping the event log lean. -// -// Dependencies are injected (defaulting to the real models) so the routing can -// be unit-tested with mocked writes. - -const shardEventsModel = require('../model/shardEvents/shardEvents.model') -const shardStateModel = require('../model/shardState/shardState.model') -const shardLinksModel = require('../model/shardLinks/shardLinks.model') -const shardMarketModel = require('../model/shardMarket/shardMarket.model') -const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model') -const settingsModel = require('../model/settings/settings.model') -const broadcaster = require('./shardBroadcast') -const pushDispatch = require('./pushDispatch') -const defaultLog = require('./logger')('shard-ingest') - -// Notable kinds appended to the shard_events log. High-frequency/session kinds -// (char.vitals, economy.supply, mob.login/logout, account.login.attempt, -// gold.change, vendor.buy/sell) are excluded on purpose. house.decay is handled -// specially — logged only on the transition INTO IDOC. -const LOGGED_KINDS = new Set([ - 'vendor.sale', - 'player.death', - 'player.murdered', - 'mob.killed', - 'quest.complete', - 'skill.gain', - 'fame.change', - 'karma.change', - 'audit.set', - 'audit.command', - 'admin.audit', - 'cheat.fastwalk', - 'link.request', - 'server.hello', - 'server.shutdown', - 'server.crashed', - // Protocol 2.0: a real-time guild join (the board itself is state, not logged). - 'guild.join', - // Protocol 2.0 provisioning audit (admin channel only — not in PUBLIC_KINDS). - 'account.audit', - 'account.unlinked', -]) - -// Tracks the current shard boot id so a restart (changed bootId on server.hello) -// can be detected and stale online state dropped. Module-level so it survives -// across events within a process; reset() is exposed for tests. -const state = { bootId: null } -function reset() { - state.bootId = null -} - -// Should this event be written to the append-only log? -function shouldLog(event) { - if (event.kind === 'house.decay') return String(event.to).toUpperCase() === 'IDOC' - return LOGGED_KINDS.has(event.kind) -} - -// ServUO's stock Server.cfg name. An operator who never set one publishes this -// verbatim, so it carries no more information than a blank — matched -// case-insensitively and trim-tolerantly, but ONLY as an exact whole value: a -// shard genuinely called "My Shard Reborn" keeps its name. -const STOCK_SHARD_NAME = 'my shard' - -/** - * The name to publish for the shard: its own, or this instance's when it has - * effectively not given one. - * - * Deliberately not a general "blank means brand" rule applied across the wire — - * it is scoped to this one field, where the two names denote the same thing. - */ -async function resolveShardName(shard, deps) { - const given = String(shard ?? '').trim() - if (given !== '' && given.toLowerCase() !== STOCK_SHARD_NAME) return given - try { - return (await deps.settings.getInstanceName()) || given - } catch { - // A ruleset that publishes the stock name is still better than one that - // fails to store because the settings read hiccuped. - return given - } -} - -// Apply the state-change side effect for a kind (if any). Returns a promise. -async function applyStateChange(event, deps) { - const { shardState, uoLinkConfig, log } = deps - switch (event.kind) { - case 'server.hello': { - const incoming = event.bootId || null - if (incoming && state.bootId && incoming !== state.bootId) { - log.warn('shard restarted (bootId changed) — clearing online roster', { - from: state.bootId, - to: incoming, - }) - await shardState.clearOnline() - } - if (incoming) state.bootId = incoming - await uoLinkConfig.recordStatus({ pluginConnected: true, bootId: incoming, lastEventAt: event.t }) - return - } - case 'server.shutdown': - case 'server.crashed': - // Shard is going away — nobody is online anymore. - await shardState.clearOnline() - await uoLinkConfig.recordStatus({ pluginConnected: false }) - return - case 'mob.login': { - const who = event.who || {} - await shardState.upsertOnline({ - serial: who.serial, - name: who.name, - acct: who.acct, - webId: event.webId, - map: event.map, - x: event.x, - y: event.y, - z: event.z, - }) - return - } - case 'mob.logout': { - const who = event.who || {} - if (who.serial) await shardState.setOffline(who.serial) - return - } - case 'char.vitals': - await shardState.upsertOnline({ - serial: event.serial, - hits: event.hits, - hitsMax: event.hitsMax, - mana: event.mana, - manaMax: event.manaMax, - stam: event.stam, - stamMax: event.stamMax, - str: event.str, - dex: event.dex, - int: event.int, - map: event.map, - x: event.x, - y: event.y, - }) - return - case 'economy.supply': - await shardState.addEconomySample({ accounts: event.accounts, gold: event.gold, t: event.t }) - return - case 'house.decay': - await shardState.upsertHouse({ - serial: event.serial, - stage: event.to, - map: event.map, - x: event.x, - y: event.y, - z: event.z, - region: event.region, - name: event.name, - ownerSerial: event.ownerSerial, - ownerAcct: event.ownerAcct, - builtOn: event.builtOn, - lastRefreshed: event.lastRefreshed, - }) - return - case 'champ.update': - await shardState.upsertChamp(event) - return - case 'champ.remove': - await shardState.removeChamp(event.serial) - return - case 'page.new': - case 'page.updated': - await shardState.upsertPage(event) - return - case 'page.closed': - await shardState.removePage(event.pageId) - return - // ── Protocol 2.0 boards ────────────────────────────────────────────── - case 'guild.update': - await shardState.upsertGuild(event) - return - case 'guild.remove': - await shardState.removeGuild(event.id) - return - case 'city.update': - // Upserts the board AND captures term history (idempotent). - await shardState.upsertGovernor(event) - return - case 'presence.online': - await shardState.setPresence(event) - return - case 'house.update': - await shardState.upsertHouseRegistry(event) - return - case 'house.remove': - await shardState.removeHouse(event.serial) - return - // ── Protocol 3.0 ───────────────────────────────────────────────────── - // The shard re-emits its whole ruleset on every sidecar connect, so this is - // an overwrite, not an append — and deliberately NOT in LOGGED_KINDS: it - // would put a duplicate row in the event log on every reconnect, and - // server.hello already marks each of those. - case 'world.ruleset': - // A shard whose operator never edited Server.cfg publishes ServUO's stock - // "My Shard". That is the shard saying *unnamed*, not a name, so the site - // answers with its own — the rules page reading "My Shard" under a header - // reading UOMysticmoon is the shard failing to introduce itself. - // - // Normalized HERE rather than on read because the ruleset is also live: the - // same `event` object is handed to the SSE broadcast a few lines below, and - // a read-time fix would be undone by the next reconnect's frame. - event.shard = await resolveShardName(event.shard, deps) - await shardState.setRuleset(event) - return - // Board state, like guild.update — the newest frame for a system replaces the - // previous one, so it is NOT in LOGGED_KINDS. Logging would append a row every - // time anyone's score moved the top ten, which is a board, not an event. - case 'points.board': - await shardState.upsertPointsBoard(event) - return - // Player-vendor market index. Each frame is authoritative for one shop, so - // the model replaces that vendor's whole listing set rather than merging. - // - // NOT in LOGGED_KINDS, and this is the strongest case of the three v3 kinds: - // one frame carries up to 250 listings, the sweep re-emits a shop on any - // price change, and appending each of those to the event log would make - // shard_events mostly a price history nobody reads. The market IS the state. - case 'vendor.listing': - await deps.shardMarket.upsertVendor(event) - return - case 'vendor.listing.remove': - await deps.shardMarket.removeVendor(event.serial) - return - case 'account.unlinked': - // A player ran [unlink in game (or a site-side unlink echoed back) — drop - // our local link mirror so attribution stops immediately. - if (event.account) await deps.shardLinks.removeByAccount(event.account) - return - // guild.join / account.audit → logged; region.enter → broadcast-only. - default: - // No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and - // broadcasting still happen in ingest(). - } -} - -// Ingest one event. Returns { logged, stored } for tests/stats. `fromBackfill` -// suppresses the SSE broadcast (a reconnect replay shouldn't re-animate the -// live ticker). Never throws — a bad single event must not kill the feed. -// Resolve the injectable dependencies to their live defaults (tests override a -// subset). Split out so ingest() isn't penalised for the fan of `|| default`s. -function resolveDeps(deps) { - return { - shardEvents: deps.shardEvents || shardEventsModel, - shardState: deps.shardState || shardStateModel, - shardLinks: deps.shardLinks || shardLinksModel, - shardMarket: deps.shardMarket || shardMarketModel, - uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel, - settings: deps.settings || settingsModel, - broadcast: deps.broadcast || broadcaster.broadcast, - pushDispatch: deps.pushDispatch || pushDispatch.fromShardEvent, - log: deps.log || defaultLog, - } -} - -async function ingest(event, deps = {}) { - const d = resolveDeps(deps) - - if (!event || typeof event.kind !== 'string') return { logged: false, stored: false } - // ws.hello / pong are transport frames, not game events. - if (event.kind === 'ws.hello' || event.kind === 'pong') return { logged: false, stored: false } - - const t = Number.isFinite(event.t) ? event.t : Date.now() - let stored = false - let logged = false - - try { - await applyStateChange(event, d) - } catch (err) { - d.log.warn('state-change write failed', { kind: event.kind, message: err.message }) - } - - if (shouldLog(event)) { - logged = true - try { - stored = await d.shardEvents.append({ kind: event.kind, t, bootId: state.bootId, payload: event }) - } catch (err) { - d.log.warn('event log write failed', { kind: event.kind, message: err.message }) - } - } - - if (!deps.fromBackfill) { - // Broadcast is async since v3 (it reads the visibility config to decide what - // each subscriber may see). Fire-and-forget, like the push fan-out below: a - // slow config read must never delay or fail ingest. - Promise.resolve(d.broadcast(event)).catch((err) => - d.log.warn('broadcast failed', { kind: event.kind, message: err.message }), - ) - // Opt-in push fan-out, off the same event source as the SSE broadcast. - // Fire-and-forget (a slow/dead ntfy relay must never delay or fail ingest); - // fromShardEvent is self-guarding, but .catch() covers any lookup rejection. - Promise.resolve(d.pushDispatch(event, { shardLinks: d.shardLinks })).catch((err) => - d.log.warn('push dispatch failed', { kind: event.kind, message: err.message }), - ) - } - - return { logged, stored } -} - -module.exports = { ingest, shouldLog, reset, LOGGED_KINDS, state } diff --git a/server/src/utils/shardSales.js b/server/src/utils/shardSales.js deleted file mode 100644 index 3625f91..0000000 --- a/server/src/utils/shardSales.js +++ /dev/null @@ -1,26 +0,0 @@ -// Recent player-vendor sales for a set of game accounts. Shared by the player -// self endpoint (the caller's linked accounts) and the admin user-detail -// endpoint (a target user's linked accounts). Reads the site's own ingested -// event log — no sidecar round-trip — and filters to sales whose owning account -// is in the set. Newest 50, already newest-first from shardEvents.list. - -const shardEvents = require('../model/shardEvents/shardEvents.model') - -async function salesForAccounts(accounts) { - const set = accounts instanceof Set ? accounts : new Set(accounts) - if (set.size === 0) return [] - const events = await shardEvents.list({ kind: 'vendor.sale', limit: 500 }) - return events - .filter((e) => e.payload && set.has(e.payload.ownerAcct)) - .slice(0, 50) - .map((e) => ({ - t: e.t, - itemType: e.payload.itemType, - amount: e.payload.amount, - price: e.payload.price, - commission: e.payload.commission, - ownerAcct: e.payload.ownerAcct, - })) -} - -module.exports = { salesForAccounts } diff --git a/server/src/utils/shardVisibility.js b/server/src/utils/shardVisibility.js deleted file mode 100644 index 4d65605..0000000 --- a/server/src/utils/shardVisibility.js +++ /dev/null @@ -1,435 +0,0 @@ -// ── 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('./auth') -const log = require('./logger')('shard-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, -} diff --git a/server/src/utils/spawnAtlasParse.js b/server/src/utils/spawnAtlasParse.js deleted file mode 100644 index 5d909f5..0000000 --- a/server/src/utils/spawnAtlasParse.js +++ /dev/null @@ -1,686 +0,0 @@ -// 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 records -// → streaming regex, never a DOM. See parsePoints(). -// Data/Regions.xml 129 KB, genuinely nested inside -// Data/Locations/*.xml nested / -// Config/ChampionSpawns.xml 4.8 KB, / -// → 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 + 4) - i = end === -1 ? text.length : end + 3 - continue - } - if (text.startsWith('', 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 + 2) - i = end === -1 ? text.length : end + 2 - continue - } - if (text.startsWith('', 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` `` and -// `Regions.xml` `` 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 `` 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 = /([\s\S]*?)<\/Points>/g - -function tagValue(block, name) { - const match = block.match(new RegExp(`<${name}>([\\s\\S]*?)`)) - return match ? decodeEntities(match[1]).trim() : '' -} - -/** - * Parse a `Spawns/.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 ``, 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. `` 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 `` may contain further `` elements, and the - * inner ones frequently omit `name` and `priority` (`` - * 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/.xml` into landmark points. - * - * The file nests `` arbitrarily deep and puts coordinates only on - * ``: 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, -} diff --git a/server/src/utils/spawnAtlasSource.js b/server/src/utils/spawnAtlasSource.js deleted file mode 100644 index 10d307b..0000000 --- a/server/src/utils/spawnAtlasSource.js +++ /dev/null @@ -1,336 +0,0 @@ -// 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: `{ "