Compare commits
15 Commits
91964c8898
...
feature/mo
| Author | SHA1 | Date | |
|---|---|---|---|
| 92c6972344 | |||
| 9b16f39a52 | |||
| 75f4d29e93 | |||
| 9083e4135a | |||
| 732927a6bb | |||
| 50d719cf46 | |||
| b30e82cde2 | |||
| 2cb549e9e5 | |||
| adff20be7b | |||
| 87230c879a | |||
| 0c4eacfa4a | |||
| a99ead4ee4 | |||
| 5bdb6a7e10 | |||
| f7d27f7a06 | |||
| 5b5006c365 |
44
.env.example
44
.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,
|
||||
# `<id>@<version>=<install manifest URL>`, 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
|
||||
|
||||
@@ -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
|
||||
|
||||
274
README.md
274
README.md
@@ -8,16 +8,19 @@
|
||||
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||
[](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<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
|
||||
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin"]
|
||||
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin · player"]
|
||||
ctrl["Controllers"]
|
||||
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
|
||||
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
|
||||
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
|
||||
|
||||
subgraph shardutil["Shard integration (utils/)"]
|
||||
ingest["shardIngest.js<br/>WS ingest dispatcher"]
|
||||
restcli["uoLinkClient.js<br/>REST client (never throws)"]
|
||||
end
|
||||
|
||||
loader["modules/loader.js<br/>scans the volume · mounts · registries · lifecycle"]
|
||||
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
|
||||
end
|
||||
|
||||
bot["bot/<br/>Discord bot"]
|
||||
end
|
||||
|
||||
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>uoLinkConfig · shard_online/economy/houses/events")]
|
||||
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>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<br/>(Rust) — the only bridge exposed"]
|
||||
servuo["ServUO shard<br/>(C# plugin)"]
|
||||
modsrv["server/ — routers, models, schema fragment<br/>reaches core only through ctx"]
|
||||
modcli["client/dist/entry.js — prebuilt ESM chunk<br/>React shared via window.__rg"]
|
||||
end
|
||||
|
||||
game["The game<br/>whatever the module talks to<br/>(for Module-uo: a ServUO shard,<br/>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<br/>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)
|
||||
@@ -306,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`):
|
||||
@@ -324,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 `/<id>/*`, `/admin/<id>/*` and `/player/<id>/*` — 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
|
||||
@@ -335,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/<prefix>`, `/api/v1/admin/<prefix>` and `/api/v1/player/<prefix>`; 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`.
|
||||
@@ -380,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
|
||||
@@ -412,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://<shard-host>:8080
|
||||
WebSocket URL ws://<shard-host>: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 <token>` 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 `<id>_` 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).
|
||||
|
||||
---
|
||||
|
||||
@@ -513,6 +560,8 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
||||
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
|
||||
| `UPLOAD_DIR` | `<server>/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) |
|
||||
| `MODULES_DIR` | `<repo>/modules` | where installed modules are scanned from (`/app/modules`, bind-mounted, in Compose) |
|
||||
| `MODULES` | — | the module set this deployment runs, resolved at every start: `<id>@<version>=<install manifest URL>`, 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) |
|
||||
@@ -534,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` / `<server>/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 |
|
||||
|---|---|
|
||||
|
||||
@@ -18,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'
|
||||
@@ -49,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'
|
||||
@@ -71,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'
|
||||
|
||||
@@ -82,8 +62,8 @@ export default function App() {
|
||||
<AuthProvider>
|
||||
<SiteProvider>
|
||||
{/* Inside the auth and site contexts, because a feature provider is a
|
||||
hook that may well read either — the shard one does, indirectly, by
|
||||
asking an endpoint whose answer depends on the session. Outside the
|
||||
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. */}
|
||||
@@ -110,18 +90,6 @@ export default function App() {
|
||||
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||
<Route path="/site/about" element={<About />} />
|
||||
<Route path="/site/status" element={<Status />} />
|
||||
<Route path="/site/shard" element={<Shard />} />
|
||||
<Route path="/site/shard/activity" element={<ShardActivity />} />
|
||||
<Route path="/site/champs" element={<ChampSpawns />} />
|
||||
<Route path="/site/guilds" element={<Guilds />} />
|
||||
<Route path="/site/governors" element={<Governors />} />
|
||||
<Route path="/site/houses" element={<Houses />} />
|
||||
<Route path="/site/rules" element={<Rules />} />
|
||||
<Route path="/site/atlas" element={<Atlas />} />
|
||||
<Route path="/site/atlas/:slug" element={<AtlasCreature />} />
|
||||
<Route path="/site/leaderboards" element={<Leaderboards />} />
|
||||
<Route path="/site/market" element={<Market />} />
|
||||
<Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
|
||||
<Route path="/wiki" element={<Wiki />} />
|
||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||
{/* Installed modules' public pages, namespaced `/<id>/…` — the
|
||||
@@ -198,31 +166,14 @@ export default function App() {
|
||||
<Route path="activity" element={<ActivityAdmin />} />
|
||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||
<Route path="shard" element={<ShardAdmin />} />
|
||||
<Route path="shard-visibility" element={<ShardVisibility />} />
|
||||
<Route path="shard-atlas" element={<SpawnAtlasAdmin />} />
|
||||
<Route
|
||||
path="shard-ops"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<ShardOps />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="houses"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<HousesAdmin />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route path="characters" element={<AdminCharacters />} />
|
||||
<Route path="characters/:serial" element={<AdminCharacter />} />
|
||||
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
||||
<Route path="users" element={<UsersAdmin />} />
|
||||
<Route path="users/:id" element={<UserDetail />} />
|
||||
<Route path="invites" element={<InvitesAdmin />} />
|
||||
{/* 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. */}
|
||||
<Route path="modules" element={<ModulesAdmin />} />
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
||||
RequireAuth + AdminLayout. A module cannot supply its own auth
|
||||
@@ -253,8 +204,11 @@ export default function App() {
|
||||
</RequirePlayer>
|
||||
}
|
||||
>
|
||||
<Route path="/player" element={<PlayerCharacters />} />
|
||||
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
|
||||
{/* The portal index resolves to the first nav row this viewer can
|
||||
reach rather than naming a page: `PlayerCharacters` was a UO
|
||||
page and left with the client half (MODULE_SYSTEM.md §2.7.1).
|
||||
With the UO module installed that is still Characters. */}
|
||||
<Route path="/player" element={<PlayerIndex />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
||||
{/* Installed modules' player-portal pages, at /player/<id>/…. This
|
||||
|
||||
@@ -48,10 +48,13 @@ function safeParse(text) {
|
||||
// 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 stays core's own binding surface. Its `atlas` and
|
||||
// `shard` namespaces are module bindings that only still live here because
|
||||
// Phase 3 has not moved them yet.
|
||||
export { req as request }
|
||||
// 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 -----
|
||||
@@ -138,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'),
|
||||
@@ -333,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'),
|
||||
@@ -420,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 }),
|
||||
@@ -443,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 }),
|
||||
@@ -505,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'),
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3, gap: 10 }}>
|
||||
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>
|
||||
{label}
|
||||
{Number.isFinite(entry.rank) && (
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}> · #{entry.rank}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
|
||||
{(entry.points ?? 0).toLocaleString()}
|
||||
{max > 0 && <span className="dim"> / {max.toLocaleString()}</span>}
|
||||
</span>
|
||||
</div>
|
||||
{/* Only systems with a real cap get a bar; an uncapped score has nothing to
|
||||
be a fraction of, and a full-width bar would imply completion. */}
|
||||
{max > 0 && (
|
||||
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TitleChip({ children, tone = 'var(--muted)' }) {
|
||||
return (
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.72rem', padding: '3px 9px', borderRadius: 999,
|
||||
border: `1px solid ${tone}55`, color: tone, whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatTile({ value, label }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
|
||||
<div className="display" style={{ fontSize: '1.35rem', color: 'var(--head)' }}>{value}</div>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 4 }}>{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Vital({ label, cur, max }) {
|
||||
const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0
|
||||
return (
|
||||
<div className="panel" style={{ padding: '12px 14px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
||||
<span className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>{label}</span>
|
||||
<span className="display" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>{cur ?? '—'}<span className="dim" style={{ fontSize: '0.8rem' }}> / {max ?? '—'}</span></span>
|
||||
</div>
|
||||
<div style={{ height: 6, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CharacterSheet({ char, moderation = false }) {
|
||||
if (!char) return null
|
||||
const stats = char.stats || {}
|
||||
const resist = stats.resist || {}
|
||||
// Skills the character actually has, best first.
|
||||
const skills = (char.skills || [])
|
||||
.filter((s) => (s.value || s.base || 0) > 0)
|
||||
.sort((a, b) => (b.value || 0) - (a.value || 0))
|
||||
const equipment = char.equipment || []
|
||||
// Best standing first, so the character's strongest loyalty leads. Guarded for
|
||||
// an older shard plugin that sends no `points` block at all.
|
||||
const points = (Array.isArray(char.points) ? char.points : [])
|
||||
.filter((p) => p && (p.points || 0) > 0)
|
||||
.sort((a, b) => (b.points || 0) - (a.points || 0))
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
{/* Identity */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>{char.name || 'Unknown'}</h2>
|
||||
{char.title && <span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>{char.title}</span>}
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 999,
|
||||
border: '1px solid var(--line)', fontSize: '0.74rem',
|
||||
color: char.online ? '#7fd0a4' : 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: char.online ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{char.online ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
|
||||
</div>
|
||||
|
||||
{/* Titles + standing (guild led / governorship) — all optional */}
|
||||
{(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: -8 }}>
|
||||
{char.governorOf && char.governorOf.map((city) => (
|
||||
<TitleChip key={`gov-${city}`} tone="#c9a24b">Governor of {city}</TitleChip>
|
||||
))}
|
||||
{char.guild && (
|
||||
<TitleChip tone="var(--accent)">
|
||||
Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name}
|
||||
</TitleChip>
|
||||
)}
|
||||
{displayTitles(char.titles).map((t) => <TitleChip key={t}>{t}</TitleChip>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Staff moderation for this character's account (self-gates to staff). */}
|
||||
{moderation && char.acct && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>Account <strong style={{ color: 'var(--ink)' }}>{char.acct}</strong></span>
|
||||
<ShardAccountActions account={char.acct} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Core stats */}
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>
|
||||
<div className="grid-3" style={{ gap: 12 }}>
|
||||
<StatTile value={stats.str ?? '—'} label="Strength" />
|
||||
<StatTile value={stats.dex ?? '—'} label="Dexterity" />
|
||||
<StatTile value={stats.int ?? '—'} label="Intelligence" />
|
||||
</div>
|
||||
<div className="grid-3" style={{ gap: 12, marginTop: 12 }}>
|
||||
<Vital label="Hits" cur={stats.hits} max={stats.hitsMax} />
|
||||
<Vital label="Mana" cur={stats.mana} max={stats.manaMax} />
|
||||
<Vital label="Stamina" cur={stats.stam} max={stats.stamMax} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Resistances */}
|
||||
{Object.keys(resist).length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Resistances</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => (
|
||||
<div key={k} className="panel" style={{ padding: '10px 16px', textAlign: 'center', minWidth: 84 }}>
|
||||
<div className="display" style={{ color: 'var(--head)', fontSize: '1.1rem' }}>{resist[k] ?? 0}</div>
|
||||
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.66rem', textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: 2 }}>{RESIST_LABELS[k]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{skills.length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Skills <span className="dim">({skills.length})</span></div>
|
||||
<div className="grid-2" style={{ gap: '8px 18px' }}>
|
||||
{skills.map((s) => {
|
||||
const cap = s.cap || 100
|
||||
const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100))
|
||||
return (
|
||||
<div key={s.n}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 }}>
|
||||
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>{s.n}</span>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem' }}>{s.value}</span>
|
||||
</div>
|
||||
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Loyalty & points — one entry per system this character has scored in */}
|
||||
{points.length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>
|
||||
Loyalty & points <span className="dim">({points.length})</span>
|
||||
</div>
|
||||
<div className="grid-2" style={{ gap: '8px 18px' }}>
|
||||
{points.map((p) => (
|
||||
<PointsRow key={p.system} entry={p} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Equipment */}
|
||||
{equipment.length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{equipment.map((it) => {
|
||||
const label = itemName(it)
|
||||
const layer = it.layer || 'Item'
|
||||
// The layer only earns its own line once the headline is a real
|
||||
// name; when it IS the headline, repeating it is just noise.
|
||||
const detail = [label === layer ? null : layer, `id ${it.itemId}`, it.hue ? `hue ${it.hue}` : null]
|
||||
return (
|
||||
<div key={it.serial} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: 5, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.05)' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{label}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem' }}>{detail.filter(Boolean).join(' · ')}</div>
|
||||
</div>
|
||||
{it.mods && Object.keys(it.mods).length > 0 && (
|
||||
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
|
||||
{Object.entries(it.mods).map(([k, v]) => (
|
||||
<span key={k} className="pill" style={{ fontSize: '0.7rem', padding: '2px 8px' }}>{k} {v}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
|
||||
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: 8 }}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Fold the settled roster results into totals. `complete` is false when any
|
||||
// account's roster failed (a partial result — shown as a dash rather than a
|
||||
// misleadingly low count).
|
||||
function summarizeRosters(rosters) {
|
||||
let chars = 0
|
||||
let online = 0
|
||||
let complete = true
|
||||
for (const r of rosters) {
|
||||
if (r.status !== 'fulfilled') {
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
const cs = r.value.chars || []
|
||||
chars += cs.length
|
||||
online += cs.filter((c) => c.online).length
|
||||
}
|
||||
return { chars, online, complete }
|
||||
}
|
||||
|
||||
export default function CharacterStats({ scope }) {
|
||||
const [stats, setStats] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const accounts = await scope.accounts()
|
||||
const linked = accounts.length
|
||||
if (linked === 0) {
|
||||
if (!cancelled) setStats({ linked: 0 })
|
||||
return
|
||||
}
|
||||
// Roster is a live round-trip and can be unavailable (503); tolerate a
|
||||
// partial result so a restarting shard doesn't blank the whole row.
|
||||
const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account)))
|
||||
if (!cancelled) setStats({ linked, ...summarizeRosters(rosters) })
|
||||
} catch {
|
||||
if (!cancelled) setStats({ error: true })
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [scope])
|
||||
|
||||
// Hidden until we know an account is linked (or while first loading).
|
||||
if (!stats || stats.error || stats.linked === 0) return null
|
||||
|
||||
// Counts depend on live rosters; show a dash if none came back.
|
||||
const count = (n) => (stats.complete || stats.chars > 0 ? n : '—')
|
||||
|
||||
return (
|
||||
<section className="grid-3" style={{ gap: 14, marginBottom: 26 }}>
|
||||
<Tile value={count(stats.chars)} label="Characters" />
|
||||
<Tile value={count(stats.online)} label="Online now" />
|
||||
<Tile value={stats.linked} label={stats.linked === 1 ? 'Linked account' : 'Linked accounts'} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<form onSubmit={onSubmit}>
|
||||
{!compact && (
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Choose the username and password you’ll type into the game client. These are your
|
||||
<strong style={{ color: 'var(--head)' }}> game</strong> credentials — separate from your website login.
|
||||
</p>
|
||||
)}
|
||||
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||
<span className="field-label">Game account name</span>
|
||||
<input
|
||||
type="text" autoComplete="off" value={account}
|
||||
onChange={(e) => setAccount(e.target.value)} className="input" placeholder="e.g. darrow"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||
<span className="field-label">Game password</span>
|
||||
<input
|
||||
type="password" autoComplete="new-password" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} className="input"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
||||
{msg && <p className="sans" style={{ margin: '0 0 12px', color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</p>}
|
||||
|
||||
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Creating…' : 'Create game account'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<form onSubmit={submit} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: compact ? 0 : 6 }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
{!compact && <span className="field-label">Link code</span>}
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder="AB12CD"
|
||||
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Linking…' : 'Link account'}
|
||||
</button>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountRoster({ scope, account, charTo }) {
|
||||
const [roster, setRoster] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [unavailable, setUnavailable] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(''); setUnavailable(false)
|
||||
try {
|
||||
setRoster(await scope.roster(account))
|
||||
} catch (err) {
|
||||
if (err.status === 503) setUnavailable(true)
|
||||
else setError(err.message || 'Could not load this account.')
|
||||
}
|
||||
}, [scope, account])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (unavailable) {
|
||||
return (
|
||||
<div>
|
||||
<p className="sans" style={{ margin: '0 0 8px', color: '#e0b070', fontSize: '0.85rem' }}>The game server is restarting — try again shortly.</p>
|
||||
<button className="pill" onClick={load}>Retry</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (error) return <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
||||
if (!roster) return <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>Loading…</p>
|
||||
|
||||
const chars = roster.chars || []
|
||||
if (chars.length === 0) return <p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>No characters on this account.</p>
|
||||
|
||||
return (
|
||||
<div className="grid-2" style={{ gap: 12 }}>
|
||||
{chars.map((c) => (
|
||||
<Link
|
||||
key={c.serial}
|
||||
to={charTo(c.serial)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', border: '1px solid var(--line)', borderRadius: 10, textDecoration: 'none', background: 'rgba(255,255,255,0.02)' }}
|
||||
>
|
||||
<span style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '1rem', textTransform: 'uppercase' }}>
|
||||
{(c.name || '?').charAt(0)}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="display" style={{ color: 'var(--head)', fontSize: '1.02rem' }}>{c.name}</div>
|
||||
<div className="sans" style={{ fontSize: '0.76rem', color: c.online ? '#7fd0a4' : 'var(--muted)' }}>{c.online ? 'Online' : 'Offline'}</div>
|
||||
</div>
|
||||
<span className="sans dim" style={{ fontSize: '1.1rem' }}>›</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Compact per-account "Unlink" button for the admin (readOnly) view. Confirms,
|
||||
// then calls onUnlink(account) and reloads. Errors surface inline.
|
||||
function UnlinkButton({ account, onUnlink }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
async function go() {
|
||||
if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await onUnlink(account)
|
||||
} catch (err) {
|
||||
const byStatus = { 403: 'Protected account — refused.', 404: 'Not linked.' }
|
||||
setError(byStatus[err.status] || err.message || 'Could not unlink.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<button type="button" onClick={go} disabled={busy} className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }}>
|
||||
{busy ? 'Unlinking…' : 'Unlink'}
|
||||
</button>
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.76rem' }}>{error}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) {
|
||||
const [accounts, setAccounts] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
// Whether the site currently offers game-account creation (public flag). Only
|
||||
// relevant for the self-service (non-readOnly) view with a createAccount scope.
|
||||
const [signupOk, setSignupOk] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
setAccounts(await scope.accounts())
|
||||
} catch {
|
||||
setError(readOnly ? 'Could not load this user’s game accounts.' : 'Could not load your game accounts.')
|
||||
}
|
||||
}, [scope, readOnly])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (readOnly || !scope.createAccount) return
|
||||
let active = true
|
||||
api.publicSettings()
|
||||
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
|
||||
.catch(() => {})
|
||||
return () => { active = false }
|
||||
}, [readOnly, scope])
|
||||
|
||||
const canCreate = !readOnly && Boolean(scope.createAccount) && signupOk
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!accounts) return <Loading />
|
||||
|
||||
// No linked accounts. In read-only (admin viewing another user) this is just an
|
||||
// empty state; otherwise it's the link-your-account prompt.
|
||||
if (accounts.length === 0) {
|
||||
if (readOnly) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||
This user has not linked a game account.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Already play? In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
|
||||
one-time code, then enter it below to see your characters, stats, skills and vendors here.
|
||||
</p>
|
||||
<LinkForm scope={scope} onLinked={load} />
|
||||
</div>
|
||||
{canCreate && (
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Create a new game account</div>
|
||||
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Linked — characters grouped by account.
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
|
||||
{accounts.map((a) => (
|
||||
<section key={a.account}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
||||
{a.account}
|
||||
</div>
|
||||
{onUnlink && <UnlinkButton account={a.account} onUnlink={async (acct) => { await onUnlink(acct); await load() }} />}
|
||||
</div>
|
||||
{moderation && <ShardAccountActions account={a.account} style={{ marginBottom: 12 }} />}
|
||||
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
|
||||
</section>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
|
||||
<LinkForm scope={scope} onLinked={load} compact />
|
||||
{canCreate && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>Create another game account</div>
|
||||
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} compact />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 (
|
||||
<section className="panel" style={{ padding: 20 }}>
|
||||
<div
|
||||
className="sans"
|
||||
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Players online
|
||||
</span>
|
||||
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)', lineHeight: 1 }}>
|
||||
{loading ? '—' : total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="sans dim" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
|
||||
Population is unavailable right now.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{rows.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>
|
||||
{total > 0 ? 'Locations are settling…' : 'The realm is quiet.'}
|
||||
</p>
|
||||
) : (
|
||||
rows.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
fontSize: '0.9rem',
|
||||
color: 'var(--ink)',
|
||||
}}
|
||||
>
|
||||
<span>{r.label}</span>
|
||||
{/* tabular figures keep the right-aligned counts in a clean column */}
|
||||
<span className="dim" style={{ fontVariantNumeric: 'tabular-nums' }}>{r.count}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="sans" style={{ display: 'flex', flexDirection: 'column', gap: 8, ...style }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
|
||||
<button onClick={kick} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'kick' ? '…' : 'Kick'}</button>
|
||||
<button onClick={() => { setBanOpen((v) => !v); setOk(''); setErr('') }} disabled={!!busy} className="btn btn-sq" style={{ ...btn, borderColor: '#d98b84', color: '#d98b84' }}>Ban…</button>
|
||||
<button onClick={unban} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'unban' ? '…' : 'Unban'}</button>
|
||||
{ok && <span style={{ color: '#7fd0a4', fontSize: '0.8rem' }}>{ok}</span>}
|
||||
{err && <span style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
|
||||
</div>
|
||||
|
||||
{banOpen && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 8, padding: '10px 12px', border: '1px solid var(--line)', borderRadius: 8, background: 'rgba(217,139,132,0.06)' }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Duration (sec, blank = permanent)</span>
|
||||
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" style={{ maxWidth: 150 }} />
|
||||
</label>
|
||||
<label style={{ display: 'block', flex: 1, minWidth: 160 }}>
|
||||
<span className="field-label">Reason (optional)</span>
|
||||
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
|
||||
</label>
|
||||
<button onClick={ban} disabled={busy === 'ban'} className="btn btn-primary btn-sq" style={{ borderColor: '#d98b84', background: '#d98b84', ...btn }}>
|
||||
{busy === 'ban' ? 'Banning…' : `Confirm ban ${account}`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// ── Core's fill for the `site.footer.status` extension slot ────────────────
|
||||
//
|
||||
// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1; the contract is
|
||||
// MODULE_API.md §3.7.
|
||||
//
|
||||
// This is the whole of what used to be four lines inline in SiteFooter.jsx, and
|
||||
// it is a file now for one reason: `/site/shard` is a UO page, so the link goes
|
||||
// when the client half goes, and core should be deleting a registration rather
|
||||
// than editing its footer under extraction pressure.
|
||||
//
|
||||
// Note what core kept and what it handed over. Core owns the position in the row
|
||||
// and the separator around it, and passes `linkStyle` so the row stays visually
|
||||
// one row. The label, the destination, and the decision to render at all are
|
||||
// this file's — which is exactly the division a module inherits.
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function ShardStatusLink({ linkStyle }) {
|
||||
return (
|
||||
<Link to="/site/shard" style={linkStyle}>
|
||||
Shard Status
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export default function SiteFooter() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="site-footer-info">
|
||||
<span>{siteTitle} is an independent private shard project.</span>
|
||||
<span>{siteTitle} is an independent, privately-run game server.</span>
|
||||
<span style={{ color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
{contactEmail}
|
||||
|
||||
@@ -13,13 +13,12 @@ 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 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. Which module
|
||||
// answers for a given flag is the registry's business now, not this file's —
|
||||
// core registers `useShardFlags` for the ten below and Phase 3 hands them over
|
||||
// (modules/featureGate.js).
|
||||
// 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
|
||||
@@ -33,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' },
|
||||
]
|
||||
|
||||
@@ -66,8 +56,8 @@ export default function SiteHeader() {
|
||||
// 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
|
||||
|
||||
@@ -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 (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 12 }}>Recent vendor sales</div>
|
||||
{sales.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No vendor sales recorded yet.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{sales.map((s) => (
|
||||
<li key={`${s.t}-${s.itemType}-${s.price}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} — {Number(s.price || 0).toLocaleString()}gp
|
||||
</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(s.t)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -62,3 +62,39 @@ export function isAllowedPath(pathname, allowed) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
|
||||
252
client/src/lib/moduleAdmin.js
Normal file
252
client/src/lib/moduleAdmin.js
Normal file
@@ -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)
|
||||
}
|
||||
@@ -204,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)
|
||||
@@ -408,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.
|
||||
*
|
||||
@@ -489,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
|
||||
|
||||
@@ -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, ' ')
|
||||
}
|
||||
@@ -1,70 +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)
|
||||
}
|
||||
|
||||
// The same answer in the shape core's generic feature seam takes: a Set-like of
|
||||
// the flags this viewer may see, or null while we do not know yet
|
||||
// (modules/featureGate.js). Core registers THIS as the provider for the `uo`
|
||||
// namespace (main.jsx), so the ten shard-gated rows in the public header are
|
||||
// already resolved through the module seam rather than beside it — when Phase 3
|
||||
// moves those rows into the module, the registration moves with this file and
|
||||
// core is left with nothing to delete.
|
||||
export function useShardFlags() {
|
||||
const features = useShardFeatures()
|
||||
return features ? features.set : 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 }
|
||||
}
|
||||
@@ -3,10 +3,7 @@ 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, registerExtension, registerFeatureProvider } from './modules/registry.js'
|
||||
import { useShardFlags } from './lib/useShardFeatures.js'
|
||||
import ShardStatusLink from './components/ShardStatusLink.jsx'
|
||||
import UserShardSections from './routes/admin/views/UserShardSections.jsx'
|
||||
import { declareSlot } from './modules/registry.js'
|
||||
import './styles/theme.css'
|
||||
|
||||
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
|
||||
@@ -17,18 +14,12 @@ import './styles/theme.css'
|
||||
// (docs/website/MODULE_API.md §3.2).
|
||||
publishSharedDependencies()
|
||||
|
||||
// Core registers through the same seam a module uses, and registers FIRST — the
|
||||
// client twin of the server's `registries.registerCore()` (MODULE_SYSTEM.md
|
||||
// §1.9). The ten shard-gated rows in the public header are core's only because
|
||||
// Phase 3 has not moved them yet; routing them through the registry now means
|
||||
// SiteHeader holds one mechanism instead of two, and the extraction becomes a
|
||||
// deletion rather than a rewrite made under extraction pressure.
|
||||
//
|
||||
// The owner id is `core`, which is what a nav row with no `moduleId` resolves
|
||||
// against (modules/featureGate.js). The namespace is `uo`, so a module that
|
||||
// wants to read these flags — the `uo` module itself, once it owns them — asks
|
||||
// for them by the name they will always have had.
|
||||
registerFeatureProvider('core', 'uo', useShardFlags)
|
||||
// Core registered a feature provider here until slice 3, under owner id `core`
|
||||
// and namespace `uo`, so that the seam was exercised by real content from the
|
||||
// day it was built. That prediction paid out exactly as written: the extraction
|
||||
// deleted the registration and the hook it named, and SiteHeader was not touched.
|
||||
// There is nothing for core to register now — no core nav row carries a
|
||||
// `feature` — and the filter is a correct no-op until a module supplies one.
|
||||
|
||||
// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
|
||||
//
|
||||
@@ -48,15 +39,22 @@ declareSlot('site.footer.status')
|
||||
// resource, one extension point, two halves. The module with routes under
|
||||
// /api/v1/admin/users/:id is the module with something to show on that page.
|
||||
declareSlot('admin.users.detail')
|
||||
// The invite-acceptance page's optional next step. Core owns invites — staff are
|
||||
// invited too — and owned the game-account step inside them until slice 3, which
|
||||
// meant core reading a `gameAccountSignup` flag and posting to a shard route.
|
||||
//
|
||||
// Named for the place, like the other two: it is "the point after an invite has
|
||||
// been accepted and before the invitee is sent on", not "create a game account".
|
||||
// Whether there is a step at all is the filling module's decision, made from
|
||||
// data core does not have; core renders the shell and a skip control, and hands
|
||||
// over `onDone`. With the slot unfilled the invitee goes straight to the portal,
|
||||
// which is what core's own code did whenever the flag was off.
|
||||
declareSlot('player.invite.accepted')
|
||||
|
||||
// And core fills both itself, under owner id `core`, with the components that
|
||||
// were inline in SiteFooter.jsx and UserDetail.jsx until this slice. The page
|
||||
// renders exactly what it rendered before, and the mechanism is exercised by
|
||||
// core's own content from the day it lands rather than first proved by the
|
||||
// change that depends on it. Slice 3 deletes these three lines and the two files
|
||||
// they name, and the module registers the same two slots on its way in.
|
||||
registerExtension('core', 'site.footer.status', ShardStatusLink)
|
||||
registerExtension('core', 'admin.users.detail', UserShardSections)
|
||||
// Core filled the first two itself until slice 3, with the components that were
|
||||
// inline in SiteFooter.jsx and UserDetail.jsx. Both are gone: the module fills
|
||||
// all three, and core's own fills had to go for it to be able to — the first
|
||||
// fill wins, and core registered first (§3.7).
|
||||
|
||||
// Render on DOMContentLoaded rather than immediately, and that is the one line
|
||||
// of core's boot the module system changes.
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
// Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.5 states the problem);
|
||||
// the contract is docs/website/MODULE_API.md §3.3.
|
||||
//
|
||||
// Ten of the sixteen rows in the public header carry a `feature`, and every one
|
||||
// of them is a shard surface an admin can disable or gate to a higher audience.
|
||||
// The provider that answers those questions — `useShardFeatures` — moves out
|
||||
// with the module, so core cannot keep calling it directly and still be a core.
|
||||
// It keeps a generic seam instead, and the module fills it.
|
||||
// Nine of the sixteen rows in the public header used to carry a `feature`, and
|
||||
// every one of them was a shard surface an admin can disable or gate to a higher
|
||||
// audience. The provider that answered those questions moved out with the module
|
||||
// in Phase 3 slice 3, and core cannot call it directly and still be a core. It
|
||||
// keeps this generic seam instead, and the module fills it.
|
||||
//
|
||||
// **The namespace comes from the registration, not from the string.** A row's
|
||||
// `feature` is resolved by the provider its OWN module registered, so a module
|
||||
// author writes `feature: 'status'` exactly as it reads today: nothing parses a
|
||||
// prefix, and a typo'd namespace is not a thing that can exist. Core's own rows
|
||||
// carry no `moduleId` and resolve against the owner id `core`, which is what
|
||||
// core registers `useShardFeatures` under until Phase 3 moves those rows into
|
||||
// the module and they arrive stamped `uo` instead.
|
||||
// carry no `moduleId` and resolve against the owner id `core` — which nothing
|
||||
// registers now that the shard rows are gone, and that is the correct resting
|
||||
// state rather than a gap: no core nav row carries a `feature`.
|
||||
//
|
||||
// Everything here fails OPEN, and that is deliberate and unchanged from
|
||||
// useShardFeatures' own posture: this is presentation, the gate is server-side
|
||||
// Everything here fails OPEN, and that is deliberate: this is presentation, the
|
||||
// gate is server-side
|
||||
// (a disabled feature 404s and an out-of-rung one 403s whether or not a link was
|
||||
// rendered), so an unknown answer shows the link rather than blanking the nav.
|
||||
// The one thing a UI mistake must never do here is hide a page from someone
|
||||
|
||||
@@ -79,8 +79,18 @@ export function registerRoutes(id, byArea) {
|
||||
* core group, `order` sorts within it, and an unknown group name appends rather
|
||||
* than dropping the item — a mis-typed group must cost a position, never a link.
|
||||
*
|
||||
* `icon` is a component core renders exactly as it renders its own rows' icons
|
||||
* (1.3.0). It exists because without it the six UO rows would have extracted as
|
||||
* the only text-only entries in a sidebar where every other row has a glyph,
|
||||
* which reads as breakage rather than as a design. Core does not supply a
|
||||
* fallback: a module that omits it gets no icon, the same as a core row that
|
||||
* omits it, and inventing one would be core making a presentation choice for
|
||||
* content it knows nothing about. Note that `icon` is already among the fields
|
||||
* an override may not touch (lib/navOverrides.js) — the concept predates a
|
||||
* module being able to supply one.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?}>}} spec
|
||||
* @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?, icon?}>}} spec
|
||||
*/
|
||||
export function registerNav(id, spec) {
|
||||
const { area, items } = spec || {}
|
||||
@@ -92,10 +102,10 @@ export function registerNav(id, spec) {
|
||||
/**
|
||||
* The hook that answers "which of this module's features may this viewer see".
|
||||
*
|
||||
* Core keeps a generic flag context and owns none of the semantics — `uo` fills
|
||||
* its namespace with today's `useShardFeatures` (MODULE_SYSTEM.md §1.5). With no
|
||||
* module installed the nav filter is a correct no-op, because no core nav item
|
||||
* carries a `feature` today.
|
||||
* Core keeps a generic flag context and owns none of the semantics
|
||||
* (MODULE_SYSTEM.md §1.5). With no module installed the nav filter is a correct
|
||||
* no-op, because no core nav item carries a `feature` — which has been literally
|
||||
* true since Phase 3 slice 3 took the nine shard-gated rows out.
|
||||
*/
|
||||
export function registerFeatureProvider(id, namespace, hook) {
|
||||
providers.set(namespace, { id, hook })
|
||||
|
||||
@@ -37,7 +37,7 @@ import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
|
||||
import { useAsync } from '../lib/useAsync.js'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import { request, ApiError } from '../api/client.js'
|
||||
import { request, ApiError, BASE } from '../api/client.js'
|
||||
|
||||
// The UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These
|
||||
// seven are what the smallest UO page already needs beyond React and the router:
|
||||
@@ -66,11 +66,16 @@ const ui = {
|
||||
useSite,
|
||||
}
|
||||
|
||||
// The request PRIMITIVE, not the `api` object (§3.5). `api.atlas` and `api.shard`
|
||||
// are module bindings that only still live in core's client because Phase 3 has
|
||||
// not moved them; a module builds its own namespace over `request` and owns the
|
||||
// paths it calls — which is right, because it owns the routes at the other end.
|
||||
const api = { request, ApiError }
|
||||
// The request PRIMITIVE, not the `api` object (§3.5): a module builds its own
|
||||
// namespace over `request` and owns the paths it calls, which is right, because
|
||||
// it owns the routes at the other end.
|
||||
//
|
||||
// `BASE` was in §3.5 from the start and missing from this object until slice 3,
|
||||
// which is when something first needed it. `request` is fetch-only, so an
|
||||
// EventSource — the shard's live feed is two of them — has to build its own URL,
|
||||
// and the alternative is a module hardcoding `/api/v1`: an assertion about where
|
||||
// core mounts its API that core has never promised to keep.
|
||||
const api = { request, ApiError, BASE }
|
||||
|
||||
/**
|
||||
* Publish `window.__rg`. Called by main.jsx before it renders, and before any
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
// 1.3.0 — three additions, all from Phase 3 slice 3 needing them: a nav item may
|
||||
// carry an `icon` component (§3.3), core declares a third slot
|
||||
// `player.invite.accepted` (§3.7), and `window.__rg.api` gained `BASE`, which
|
||||
// §3.5 always documented and shared.js never published. Additive throughout: a
|
||||
// module written against 1.2.0 is unaffected. The server half is untouched and
|
||||
// bumps anyway, for the reason below.
|
||||
// 1.2.0 — `registry` gained `registerExtension` and core gained extension slots
|
||||
// (MODULE_API.md §3.7). The first change to window.__rg since 1.0.0, and an
|
||||
// addition: a module that never fills a slot is unaffected. The server half is
|
||||
@@ -20,4 +26,4 @@
|
||||
// but the two halves state ONE version: a module declares a single coreApi range
|
||||
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||
// answered 1.1.0 would be two answers to one question.
|
||||
export const MODULE_API_VERSION = '1.2.0'
|
||||
export const MODULE_API_VERSION = '1.3.0'
|
||||
|
||||
@@ -43,9 +43,9 @@ const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18
|
||||
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
|
||||
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
|
||||
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
||||
const IconShard = () => <Icon><path d="M12 2l7 6-7 14-7-14z" /><path d="M5 8h14" /></Icon>
|
||||
const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
|
||||
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
|
||||
const IconModules = () => <Icon><path d="M12 3l8 4.5-8 4.5-8-4.5z" /><path d="M4 12l8 4.5 8-4.5" /><path d="M4 16.5L12 21l8-4.5" /></Icon>
|
||||
|
||||
// Nav is grouped into collapsible categories. A group with no `title` renders
|
||||
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
|
||||
@@ -76,8 +76,6 @@ export const NAV = [
|
||||
items: [
|
||||
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/houses', label: 'Houses', icon: IconShard, roles: ['admin', 'moderator'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -86,20 +84,20 @@ export const NAV = [
|
||||
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
|
||||
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
|
||||
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
|
||||
// Admin-only, matching the server: every route under /admin/modules
|
||||
// re-gates to `admin` on top of the group's staff gate, because installing
|
||||
// a module runs its code in this process.
|
||||
{ to: '/admin/modules', label: 'Modules', icon: IconModules, roles: ['admin'] },
|
||||
{ to: '/admin/appearance', label: 'Appearance', icon: IconPalette, roles: ['admin'] },
|
||||
{ to: '/admin/navigation', label: 'Navigation', icon: IconNav, roles: ['admin'] },
|
||||
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
|
||||
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
|
||||
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
|
||||
{ to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] },
|
||||
{ to: '/admin/shard-visibility', label: 'Shard Visibility', icon: IconShard, roles: ['admin'] },
|
||||
{ to: '/admin/shard-atlas', label: 'Spawn Atlas', icon: IconShard, roles: ['admin'] },
|
||||
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ to: '/admin/characters', label: 'My Characters', icon: IconShard },
|
||||
{ to: '/admin/account', label: 'Account', icon: IconUser },
|
||||
],
|
||||
},
|
||||
@@ -136,18 +134,12 @@ const TITLES = {
|
||||
'/admin/hero': 'Hero Editor',
|
||||
'/admin/moderation': 'Moderation',
|
||||
'/admin/moderation/appeals': 'Appeals',
|
||||
'/admin/shard-ops': 'In-Game Ops',
|
||||
'/admin/houses': 'House Registry',
|
||||
'/admin/settings': 'Site Settings',
|
||||
'/admin/appearance': 'Appearance',
|
||||
'/admin/navigation': 'Navigation',
|
||||
'/admin/activity': 'Activity Log',
|
||||
'/admin/bot-activity': 'Web Bot Activity',
|
||||
'/admin/discord-bot': 'Discord Bot',
|
||||
'/admin/shard': 'Shard (uo-link)',
|
||||
'/admin/shard-visibility': 'Shard Visibility',
|
||||
'/admin/shard-atlas': 'Spawn Atlas',
|
||||
'/admin/characters': 'My Characters',
|
||||
'/admin/auth-providers': 'Authentication',
|
||||
'/admin/users': 'Users',
|
||||
'/admin/invites': 'Invites',
|
||||
@@ -170,7 +162,6 @@ function moduleTitle(baseNav, pathname) {
|
||||
// Fallback page title for dynamic sub-routes not in the exact-match TITLES map.
|
||||
function sectionTitle(pathname) {
|
||||
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
|
||||
if (pathname.startsWith('/admin/characters')) return 'My Characters'
|
||||
if (pathname.startsWith('/admin/users/')) return 'User'
|
||||
return 'Admin'
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import CharacterSheet from '../../../components/CharacterSheet.jsx'
|
||||
import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// A staff member's own character sheet inside the admin shell. Owner-checked —
|
||||
// the endpoint only returns a sheet for a character on the caller's linked account.
|
||||
export default function AdminCharacter() {
|
||||
const { serial } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.admin.shard.char(serial), [serial])
|
||||
const restarting = error && error.status === 503
|
||||
const forbidden = error && error.status === 403
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 760 }}>
|
||||
<p style={{ margin: '0 0 18px' }}>
|
||||
<Link to="/admin/characters" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
|
||||
← Back to my characters
|
||||
</Link>
|
||||
</p>
|
||||
{loading && <Loading />}
|
||||
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
||||
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
|
||||
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
|
||||
{!loading && !error && data && <CharacterSheet char={data} moderation />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import CharacterStats from '../../../components/CharacterStats.jsx'
|
||||
import GameAccounts from '../../../components/GameAccounts.jsx'
|
||||
import VendorSales from '../../../components/VendorSales.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Staff link their OWN in-game account and view their characters — the same
|
||||
// shared component players use, pointed at the staff self-service endpoints.
|
||||
// Sits inside the Admin shell, which supplies the "My Characters" page header;
|
||||
// stat tiles bring it to parity with the Player Portal's Characters page.
|
||||
export default function AdminCharacters() {
|
||||
return (
|
||||
<section style={{ maxWidth: 760 }}>
|
||||
<CharacterStats scope={api.admin.shard} />
|
||||
<GameAccounts scope={api.admin.shard} charTo={(serial) => `/admin/characters/${serial}`} />
|
||||
<VendorSales fetchSales={api.admin.shard.sales} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../../lib/useShardFeed.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Staff-only FULL house registry (admin + moderator). Owner, price, co-owners and
|
||||
// decay — everything the public board hides. Loaded from /admin/shard/houses, kept
|
||||
// live from the admin SSE channel (house.update / house.remove).
|
||||
const HOUSE_KINDS = new Set(['house.update', 'house.remove', 'house.decay'])
|
||||
|
||||
const DECAY_TONE = {
|
||||
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
|
||||
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
|
||||
}
|
||||
|
||||
function DecayBadge({ decay, isIdoc }) {
|
||||
const label = isIdoc ? 'IDOC' : decay
|
||||
if (!label) return null
|
||||
const tone = DECAY_TONE[label] || 'var(--muted)'
|
||||
return (
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 8px' }}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ownerLabel(h) {
|
||||
return h.ownerName || h.ownerAcct || null
|
||||
}
|
||||
|
||||
function HouseRow({ h }) {
|
||||
const owner = ownerLabel(h)
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<strong className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{h.name || 'An unnamed house'}
|
||||
</strong>
|
||||
<DecayBadge decay={h.decay} isIdoc={h.isIdoc} />
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
|
||||
{owner ? <>Owned by <span style={{ color: 'var(--ink)' }}>{owner}</span></> : 'No owner'}
|
||||
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||
{h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{h.price != null && (
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||
<div style={{ fontSize: '0.92rem', color: 'var(--head)', fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()}</div>
|
||||
<div className="dim" style={{ fontSize: '0.64rem', letterSpacing: '0.04em', textTransform: 'uppercase' }}>placement value</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HousesAdmin() {
|
||||
const { loading, error, data } = useAsync(() => api.admin.shard.houses())
|
||||
// Full registry deltas ride the admin SSE channel (never the public one).
|
||||
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, filter: HOUSE_KINDS, max: 80 })
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (!ev.serial) continue
|
||||
if (ev.kind === 'house.update') {
|
||||
map.set(ev.serial, { ...ev, ownerName: ev.owner?.name ?? ev.ownerName, ownerAcct: ev.owner?.acct ?? ev.ownerAcct })
|
||||
} else if (ev.kind === 'house.remove') {
|
||||
map.delete(ev.serial)
|
||||
} else if (ev.kind === 'house.decay') {
|
||||
const cur = map.get(ev.serial) || { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y }
|
||||
map.set(ev.serial, { ...cur, isIdoc: String(ev.to).toUpperCase() === 'IDOC' })
|
||||
}
|
||||
}
|
||||
return [...map.values()]
|
||||
}, [data, events])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
const rows = needle
|
||||
? board.filter((h) => [h.name, h.region, h.map, ownerLabel(h)].some((v) => v && String(v).toLowerCase().includes(needle)))
|
||||
: board
|
||||
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||
}, [board, q])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load the house registry." />
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
|
||||
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.82rem', margin: 0 }}>
|
||||
{board.length.toLocaleString()} houses
|
||||
<span className="dim" style={{ marginLeft: 10, color: connected ? '#7fd0a4' : 'var(--muted)' }}>{connected ? '● live' : '○ offline'}</span>
|
||||
</p>
|
||||
<input className="input sans" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by owner, region…" style={{ flex: 'none', width: 230, maxWidth: '55%', fontSize: '0.84rem' }} />
|
||||
</div>
|
||||
{board.length === 0 ? (
|
||||
<div className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>No houses are being tracked right now.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{filtered.map((h) => <HouseRow key={h.serial} h={h} />)}
|
||||
</div>
|
||||
)}
|
||||
{board.length > 0 && filtered.length === 0 && (
|
||||
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No houses match “{q}”.</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
424
client/src/routes/admin/views/ModulesAdmin.jsx
Normal file
424
client/src/routes/admin/views/ModulesAdmin.jsx
Normal file
@@ -0,0 +1,424 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { dateTime } from '../../../lib/format.js'
|
||||
import { statusOf, actionsFor, declarationNoteFor, needsRestart, parseHosts } from '../../../lib/moduleAdmin.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Installed modules: install from a release URL, enable, disable, uninstall,
|
||||
// purge, and restart the server so the changes take effect.
|
||||
//
|
||||
// Phase 4, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.2. Everything that
|
||||
// decides what a row SAYS and which buttons it offers lives in
|
||||
// lib/moduleAdmin.js, which is plain JS and has tests; this file renders it.
|
||||
//
|
||||
// Two things about this screen are unlike the rest of the admin panel and are
|
||||
// deliberate:
|
||||
//
|
||||
// 1. **Restart is a banner, not a per-row button.** A restart is a property of
|
||||
// the server, not of a module. Offering it on five rows would suggest
|
||||
// otherwise, and an operator who installed three modules should restart
|
||||
// once.
|
||||
// 2. **Disable is the only action that takes effect immediately.** Everything
|
||||
// else is "true after the next boot", because the loader reads the volume
|
||||
// at require time (§1.12). The buttons say which they are.
|
||||
|
||||
const TONE = {
|
||||
ok: '#7fd0a4',
|
||||
warn: 'var(--accent)',
|
||||
bad: '#d98b84',
|
||||
idle: 'var(--muted)',
|
||||
}
|
||||
|
||||
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
|
||||
|
||||
function Pill({ tone, children }) {
|
||||
return (
|
||||
<span
|
||||
className="badge"
|
||||
style={{ color: TONE[tone] || 'var(--muted)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Install ────────────────────────────────────────────────────────────────
|
||||
|
||||
function InstallForm({ sourceHosts, onInstalled }) {
|
||||
const [url, setUrl] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [result, setResult] = useState(null)
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setResult(null)
|
||||
if (!url.trim()) return setError('Paste the URL of a release install manifest.')
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await api.admin.installModule(url.trim())
|
||||
setResult(res)
|
||||
setUrl('')
|
||||
await onInstalled()
|
||||
} catch (err) {
|
||||
// The server's message is written to be read by whoever pasted the URL —
|
||||
// which host was refused, which hash did not match, what the archive
|
||||
// contained. Replacing it with something friendlier would throw away the
|
||||
// only part that helps.
|
||||
setError(err.message || 'Could not install that module.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: 22, marginBottom: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>Install a module</div>
|
||||
<form onSubmit={submit} style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 380px' }}>
|
||||
<span className="field-label">Release install-manifest URL</span>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="input"
|
||||
placeholder="https://gitea.example.com/org/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Installing…' : 'Install'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
The bundle is downloaded, checked against the <code>sha256</code> its release published, and
|
||||
unpacked onto the modules volume. It starts serving after a restart.{' '}
|
||||
{sourceHosts.length === 0
|
||||
? 'No source hosts are allowed yet — add one below before installing.'
|
||||
: `Allowed hosts: ${sourceHosts.join(', ')}.`}
|
||||
</p>
|
||||
|
||||
{error && <p className="sans" style={{ margin: '12px 0 0', color: TONE.bad, fontSize: '0.85rem' }}>{error}</p>}
|
||||
{result && (
|
||||
<p className="sans" style={{ margin: '12px 0 0', color: TONE.ok, fontSize: '0.85rem' }}>
|
||||
{result.replaced ? 'Upgraded' : 'Installed'} {result.module?.name} v{result.module?.version}. Restart to load it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The restart banner ─────────────────────────────────────────────────────
|
||||
|
||||
function RestartBanner({ onDone }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
|
||||
async function restart() {
|
||||
// Said plainly, because it is true and because the failure mode is bad: a
|
||||
// deployment with no supervisor does not come back on its own.
|
||||
const ok = window.confirm(
|
||||
'Restart the server now?\n\n'
|
||||
+ 'The site will be briefly unavailable. It comes back on its own only if something is '
|
||||
+ 'supervising the process — the shipped Docker Compose file does. If you are running '
|
||||
+ '`npm start` by hand, you will have to start it again yourself.',
|
||||
)
|
||||
if (!ok) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.admin.restartServer()
|
||||
setSent(true)
|
||||
// Nothing is coming back on this connection: the process is exiting. Give
|
||||
// the supervisor a moment and then reload, which is what the operator was
|
||||
// about to do anyway.
|
||||
setTimeout(() => { if (onDone) onDone() }, 6000)
|
||||
} catch {
|
||||
// A failed request here is expected as often as not — the process can win
|
||||
// the race and drop the socket before the response lands.
|
||||
setSent(true)
|
||||
setTimeout(() => { if (onDone) onDone() }, 6000)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: 18, marginBottom: 22, borderColor: 'var(--accent)' }}>
|
||||
<div style={{ display: 'flex', gap: 14, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: '1 1 320px' }}>
|
||||
<div className="field-label" style={{ marginBottom: 4 }}>Restart needed</div>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.84rem', color: 'var(--muted)' }}>
|
||||
{sent
|
||||
? 'Restarting. This page will reload once the server is back.'
|
||||
: 'Modules are read from disk when the server starts, so an install, an uninstall or a re-enable only takes effect after a restart.'}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy || sent} onClick={restart}>
|
||||
{sent ? 'Restarting…' : 'Restart the server'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The source allowlist ───────────────────────────────────────────────────
|
||||
|
||||
function SourceHosts({ hosts, onSaved }) {
|
||||
const [value, setValue] = useState(hosts.join(', '))
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => { setValue(hosts.join(', ')) }, [hosts])
|
||||
|
||||
async function save(e) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSaved(false)
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.admin.setModuleSources(value)
|
||||
setSaved(true)
|
||||
await onSaved()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save the allowlist.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseHosts(value)
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: 22, marginTop: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>Where modules may be installed from</div>
|
||||
<form onSubmit={save} style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 380px' }}>
|
||||
<span className="field-label">Allowed hosts</span>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
className="input"
|
||||
placeholder="gitea.example.com, releases.example.org"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={busy} className="btn btn-sq">{busy ? 'Saving…' : 'Save'}</button>
|
||||
</form>
|
||||
|
||||
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
Installing a module runs its code inside this server, so only hosts listed here may be
|
||||
installed from — over HTTPS, and re-checked on every redirect. An empty list blocks all
|
||||
installs.{' '}
|
||||
{parsed.length > 0 && <>Will be saved as: <code>{parsed.join(', ')}</code>.</>}
|
||||
</p>
|
||||
|
||||
{error && <p className="sans" style={{ margin: '10px 0 0', color: TONE.bad, fontSize: '0.85rem' }}>{error}</p>}
|
||||
{saved && !error && <p className="sans" style={{ margin: '10px 0 0', color: TONE.ok, fontSize: '0.85rem' }}>Saved.</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── One module ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ModuleRow({ m, onChanged, onError }) {
|
||||
const [busy, setBusy] = useState('')
|
||||
const status = statusOf(m)
|
||||
const actions = actionsFor(m)
|
||||
const note = declarationNoteFor(m)
|
||||
|
||||
async function run(name, fn) {
|
||||
setBusy(name)
|
||||
try {
|
||||
await fn()
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
onError(err.message || `Could not ${name} ${m.id}.`)
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const disable = () => run('disable', () => api.admin.disableModule(m.id))
|
||||
const enable = () => run('enable', () => api.admin.enableModule(m.id))
|
||||
|
||||
function uninstall() {
|
||||
// The purge choice is made HERE and only here, because purge.sql lives
|
||||
// inside the directory the uninstall is about to delete — there is no
|
||||
// "purge it later" (§2.7.2 decision 5). Two prompts rather than one, so
|
||||
// "delete the data too" is never something you agree to by reflex.
|
||||
if (!window.confirm(`Uninstall ${m.name}?\n\nIts files are removed. Its data is kept unless you ask otherwise next.`)) return
|
||||
let purge = false
|
||||
if (m.canPurge) {
|
||||
purge = window.confirm(
|
||||
`Also permanently delete ${m.name}'s data?\n\n`
|
||||
+ 'This drops its tables and cannot be undone. This is the only moment it can be offered — '
|
||||
+ 'the script that does it is part of the files being removed.\n\n'
|
||||
+ 'OK deletes the data. Cancel keeps it.',
|
||||
)
|
||||
}
|
||||
return run('uninstall', () => api.admin.uninstallModule(m.id, { purge }))
|
||||
}
|
||||
|
||||
function purge() {
|
||||
if (!window.confirm(`Permanently delete ${m.name}'s data?\n\nThis drops its tables and cannot be undone.`)) return
|
||||
return run('purge', () => api.admin.purgeModule(m.id))
|
||||
}
|
||||
|
||||
const forget = () => run('forget', () => api.admin.uninstallModule(m.id))
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className="adm-td" style={{ color: 'var(--text)' }}>
|
||||
<div style={{ fontWeight: 600 }}>{m.name}</div>
|
||||
<div className="dim" style={{ fontSize: '0.76rem' }}>
|
||||
{/* A declared module that has never installed has no version to show —
|
||||
only the one MODULES asks for, which the status column carries. */}
|
||||
{m.id}{m.version ? ` · v${m.version}` : ''}
|
||||
</div>
|
||||
{m.capabilities?.length > 0 && (
|
||||
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>{m.capabilities.join(' · ')}</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="adm-td">
|
||||
<Pill tone={status.tone}>{status.label}</Pill>
|
||||
<div className="dim" style={{ fontSize: '0.74rem', marginTop: 4, maxWidth: 380 }}>{status.detail}</div>
|
||||
{/* The environment's declaration, on its own line: a module can be
|
||||
running fine while its declared upgrade is failing, and the status
|
||||
above can only be one of those two things. */}
|
||||
{note && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.74rem',
|
||||
marginTop: 4,
|
||||
maxWidth: 380,
|
||||
color: note.tone === 'warn' ? TONE.warn : 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
{note.text}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="adm-td dim" style={{ fontSize: '0.74rem' }}>
|
||||
{/* Provenance. Null for a directory placed on the volume by hand, which
|
||||
stays a supported install — so it is shown as that, not as missing.
|
||||
A declared module can also reach a boot with no provenance: the
|
||||
no-op path never fetches, so it has no sha256 to record and no
|
||||
reason to write a row. Saying "by hand" there would be the one
|
||||
wrong answer. */}
|
||||
{m.source ? (
|
||||
<>
|
||||
<div style={{ wordBreak: 'break-all', maxWidth: 260 }}>{m.source}</div>
|
||||
{m.sha256 && <div style={{ marginTop: 2 }}>sha256 {m.sha256.slice(0, 12)}…</div>}
|
||||
</>
|
||||
) : (
|
||||
<span>{m.declared ? 'From the declared module set' : 'Placed on the volume by hand'}</span>
|
||||
)}
|
||||
{m.installedAt && <div style={{ marginTop: 2 }}>{dateTime(m.installedAt)}</div>}
|
||||
</td>
|
||||
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<div style={{ display: 'inline-flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
{actions.disable.shown && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={Boolean(busy)} onClick={disable}>
|
||||
{busy === 'disable' ? 'Stopping…' : 'Disable'}
|
||||
</button>
|
||||
)}
|
||||
{actions.enable.shown && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={Boolean(busy)} onClick={enable}>
|
||||
{busy === 'enable' ? 'Enabling…' : 'Enable'}
|
||||
</button>
|
||||
)}
|
||||
{actions.purge.shown && (
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ fontSize: '0.72rem', ...DANGER, opacity: actions.purge.enabled ? 1 : 0.45 }}
|
||||
disabled={Boolean(busy) || !actions.purge.enabled}
|
||||
title={actions.purge.enabled ? undefined : actions.purge.reason}
|
||||
onClick={purge}
|
||||
>
|
||||
{busy === 'purge' ? 'Purging…' : 'Purge data'}
|
||||
</button>
|
||||
)}
|
||||
{actions.uninstall.shown && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem', ...DANGER }} disabled={Boolean(busy)} onClick={uninstall}>
|
||||
{busy === 'uninstall' ? 'Removing…' : 'Uninstall'}
|
||||
</button>
|
||||
)}
|
||||
{actions.forget.shown && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={Boolean(busy)} onClick={forget}>
|
||||
{busy === 'forget' ? 'Clearing…' : 'Clear the row'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ModulesAdmin() {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [actionError, setActionError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
setData(await api.admin.listModules())
|
||||
} catch {
|
||||
setError('Could not load installed modules.')
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!data) return <Loading />
|
||||
|
||||
const modules = data.modules || []
|
||||
const sourceHosts = data.sourceHosts || []
|
||||
|
||||
return (
|
||||
<section>
|
||||
{needsRestart(modules) && <RestartBanner onDone={() => window.location.reload()} />}
|
||||
|
||||
<InstallForm sourceHosts={sourceHosts} onInstalled={load} />
|
||||
|
||||
{actionError && (
|
||||
<p className="sans" style={{ margin: '0 0 14px', color: TONE.bad, fontSize: '0.85rem' }}>{actionError}</p>
|
||||
)}
|
||||
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Module</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th">Installed from</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modules.length === 0 && (
|
||||
<tr>
|
||||
<td className="adm-td" colSpan={4} style={{ color: 'var(--muted)' }}>
|
||||
No modules installed. Paste a release install-manifest URL above to add one.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{modules.map((m) => (
|
||||
<ModuleRow key={m.id} m={m} onChanged={load} onError={setActionError} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<SourceHosts hosts={sourceHosts} onSaved={load} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -35,7 +35,8 @@ import { NAV as PLAYER_NAV } from '../../player/PlayerPortalLayout.jsx'
|
||||
// Three things shape the screen:
|
||||
//
|
||||
// • The palette is filtered to the editing admin's OWN visible rows (§8.1) —
|
||||
// the base array run through their role and this shard's feature gates. An
|
||||
// the base array run through their role and the feature gates of whichever
|
||||
// module registered each row (client/src/modules/featureGate.js). An
|
||||
// admin cannot drag in, and so can never accidentally advertise, something
|
||||
// they cannot see themselves. An override on a row they cannot see is
|
||||
// carried through their save untouched rather than quietly reset.
|
||||
@@ -458,8 +459,8 @@ export default function NavEditor() {
|
||||
<section style={{ maxWidth: 860, display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.7 }}>
|
||||
Rename, reorder and hide the entries in each navigation. The pages themselves are unchanged — this
|
||||
only decides what is advertised, and it can never show anyone a link their role or this shard’s
|
||||
visibility settings would hide.
|
||||
only decides what is advertised, and it can never show anyone a link their role, or the visibility
|
||||
settings of an installed module, would hide.
|
||||
</p>
|
||||
|
||||
{/* ── Tabs ───────────────────────────────────────────────── */}
|
||||
@@ -552,8 +553,8 @@ export default function NavEditor() {
|
||||
</div>
|
||||
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
|
||||
Only entries you can see yourself are listed. Anything hidden from you by your role or by Shard
|
||||
Visibility keeps whatever it was already set to.
|
||||
Only entries you can see yourself are listed. Anything hidden from you by your role, or by a
|
||||
module’s visibility settings, keeps whatever it was already set to.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -35,18 +35,6 @@ const FIELDS = [
|
||||
],
|
||||
fallback: 'disabled',
|
||||
},
|
||||
{
|
||||
key: 'game_account_signup',
|
||||
label: 'Game-account creation',
|
||||
help: 'Whether players can create a GAME account (for the game client) from the site. The game server’s own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them. When enabled, a “Create a game account” form appears in the player portal.',
|
||||
options: [
|
||||
{ value: 'disabled', label: 'Disabled — link an existing account only' },
|
||||
{ value: 'website', label: 'Website — the site creates game accounts' },
|
||||
{ value: 'hybrid', label: 'Hybrid — site or in-game (recommended)' },
|
||||
{ value: 'game', label: 'Game only — created in the game client, not the site' },
|
||||
],
|
||||
fallback: 'disabled',
|
||||
},
|
||||
]
|
||||
|
||||
export default function SettingsAdmin() {
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useShardFeed } from '../../../lib/useShardFeed.js'
|
||||
import { describe, kindLabel } from '../../../lib/shardEvents.js'
|
||||
import { ago } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Full live feed from the admin SSE channel — every kind, incl. staff audit,
|
||||
// cheat detection and login attempts that the public channel never carries.
|
||||
function AdminLiveFeed() {
|
||||
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, max: 60 })
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Live feed (all events)</h3>
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Waiting for shard events…</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 360, overflowY: 'auto' }}>
|
||||
{events.map((e) => (
|
||||
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.6rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>{kindLabel(e.kind)}</span>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
|
||||
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// uo-link sidecar control panel. The auth token is write-only over this API —
|
||||
// stored encrypted, never returned — same convention as the Discord bot token.
|
||||
// Saving (re)starts the WS ingest client, so Enabled/URL/token changes take
|
||||
// effect immediately with no redeploy.
|
||||
|
||||
function Toggle({ checked, onChange, label }) {
|
||||
return (
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_COLOR = {
|
||||
connected: '#7fd0a4',
|
||||
reconnecting: '#e0b070',
|
||||
error: '#d98b84',
|
||||
disconnected: 'var(--muted)',
|
||||
}
|
||||
|
||||
function StatusPanel({ config }) {
|
||||
const color = STATUS_COLOR[config.status] || 'var(--muted)'
|
||||
const ingest = config.ingest || {}
|
||||
const health = config.health || {}
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
|
||||
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
|
||||
{config.status || 'disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
{config.statusDetail && (
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
|
||||
)}
|
||||
<div className="sans dim" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 16px', fontSize: '0.78rem', marginTop: 2 }}>
|
||||
<span>Shard link: <strong style={{ color: 'var(--ink)' }}>{config.pluginConnected ? 'up' : 'down'}</strong></span>
|
||||
<span>WS ingest: <strong style={{ color: 'var(--ink)' }}>{ingest.connected ? 'connected' : 'offline'}</strong></span>
|
||||
<span>Reconnects: <strong style={{ color: 'var(--ink)' }}>{ingest.reconnects ?? 0}</strong></span>
|
||||
<span>SSE clients: <strong style={{ color: 'var(--ink)' }}>{(config.sse?.publicClients ?? 0) + (config.sse?.adminClients ?? 0)}</strong></span>
|
||||
{config.lastEventAt && <span style={{ gridColumn: '1 / -1' }}>Last event: {new Date(config.lastEventAt).toLocaleString()}</span>}
|
||||
{health.uptime && <span style={{ gridColumn: '1 / -1' }}>Sidecar uptime: {health.uptime}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Town crier ──────────────────────────────────────────────────────────────
|
||||
function TownCrier() {
|
||||
const [id, setId] = useState('')
|
||||
const [text, setText] = useState('')
|
||||
const [durationSec, setDurationSec] = useState(3600)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function post() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||
if (!id.trim() || lines.length === 0) {
|
||||
setBusy(false)
|
||||
return setError('An id and at least one line are required.')
|
||||
}
|
||||
try {
|
||||
await api.admin.postTownCrier({ id: id.trim(), lines, durationSec: Number(durationSec) || undefined })
|
||||
setMsg(`Posted “${id.trim()}”.`)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not post.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
async function remove() {
|
||||
if (!id.trim()) return setError('Enter the id to remove.')
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
await api.admin.deleteTownCrier(id.trim())
|
||||
setMsg(`Removed “${id.trim()}”.`)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not remove.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Town crier</h3>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||
Broadcast a message that every in-game town crier announces until it expires. Re-posting the same id replaces it.
|
||||
</p>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Message id</span>
|
||||
<input type="text" value={id} onChange={(e) => setId(e.target.value)} className="input" placeholder="news-42" autoComplete="off" style={{ maxWidth: 220 }} />
|
||||
</label>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Lines (one per line)</span>
|
||||
<textarea value={text} onChange={(e) => setText(e.target.value)} className="input" rows={3} placeholder={'Hear ye!\nMarket tax is now 5%.'} style={{ resize: 'vertical' }} />
|
||||
</label>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Duration (seconds)</span>
|
||||
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={1} max={86400} style={{ maxWidth: 160 }} />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button onClick={post} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Working…' : 'Post message'}</button>
|
||||
<button onClick={remove} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>Remove by id</button>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ShardAdmin() {
|
||||
const [config, setConfig] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [wsUrl, setWsUrl] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [protocol, setProtocol] = useState(3)
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [saveError, setSaveError] = useState('')
|
||||
const pollRef = useRef(null)
|
||||
const initializedRef = useRef(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const c = await api.admin.getUoLinkConfig()
|
||||
setConfig(c)
|
||||
// Seed the editable fields once; later polls only refresh the status panel
|
||||
// so they never clobber what the admin is mid-typing.
|
||||
if (!initializedRef.current) {
|
||||
setBaseUrl(c.baseUrl || '')
|
||||
setWsUrl(c.wsUrl || '')
|
||||
setProtocol(c.protocol || 3)
|
||||
setEnabled(c.enabled)
|
||||
initializedRef.current = true
|
||||
}
|
||||
} catch {
|
||||
setError('Could not load uo-link config.')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
pollRef.current = setInterval(load, 5000)
|
||||
return () => clearInterval(pollRef.current)
|
||||
}, [load])
|
||||
|
||||
async function save() {
|
||||
setBusy(true); setMsg(''); setSaveError('')
|
||||
try {
|
||||
const body = { baseUrl, wsUrl, protocol: Number(protocol), enabled }
|
||||
if (token) body.token = token
|
||||
const saved = await api.admin.saveUoLinkConfig(body)
|
||||
setConfig(saved)
|
||||
setToken('')
|
||||
setMsg('Saved.')
|
||||
} catch (err) {
|
||||
setSaveError(err.message || 'Could not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!config) return <Loading />
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Shard (uo-link)</h2>
|
||||
|
||||
<StatusPanel config={config} />
|
||||
|
||||
<Toggle checked={enabled} onChange={setEnabled} label="Enable the shard integration" />
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Base URL (REST)</span>
|
||||
<input type="text" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} className="input" autoComplete="off" placeholder="http://127.0.0.1:8080" />
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">WebSocket URL (feed)</span>
|
||||
<input type="text" value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} className="input" autoComplete="off" placeholder="ws://127.0.0.1:8080/ws" />
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Auth token</span>
|
||||
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} className="input" autoComplete="new-password" placeholder={config.hasToken ? '•••••••• configured — leave blank to keep' : 'Shared secret from sidecar.toml'} />
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', maxWidth: 140 }}>
|
||||
<span className="field-label">Protocol</span>
|
||||
<input type="number" value={protocol} onChange={(e) => setProtocol(e.target.value)} className="input" min={1} max={99} />
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
|
||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Saving…' : 'Save changes'}</button>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{saveError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{saveError}</span>}
|
||||
</div>
|
||||
|
||||
<TownCrier />
|
||||
|
||||
<AdminLiveFeed />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useShardFeed } from '../../../lib/useShardFeed.js'
|
||||
import { describe } from '../../../lib/shardEvents.js'
|
||||
import { ago } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// In-game staff operations: the uo-link write plane (broadcast / kick / ban /
|
||||
// unban) and the help-page support queue, plus a live audit log. Open to admins
|
||||
// and moderators. The acting staff member (`actor`) is attached server-side from
|
||||
// the session — nothing here sends it — so every action is attributable.
|
||||
|
||||
function Flash({ ok, err }) {
|
||||
if (ok) return <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{ok}</span>
|
||||
if (err) return <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Broadcast ────────────────────────────────────────────────────────────────
|
||||
function Broadcast() {
|
||||
const [text, setText] = useState('')
|
||||
const [hue, setHue] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [ok, setOk] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
async function send() {
|
||||
if (!text.trim()) return setErr('Enter a message.')
|
||||
setBusy(true); setOk(''); setErr('')
|
||||
try {
|
||||
await api.admin.shardOps.broadcast({ text: text.trim(), hue: hue === '' ? undefined : Number(hue) })
|
||||
setOk('Broadcast sent.')
|
||||
setText('')
|
||||
} catch (e) {
|
||||
setErr(e.message || 'Could not broadcast.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Broadcast</h3>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
||||
A system message shown to everyone online right now.
|
||||
</p>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Message</span>
|
||||
<input type="text" value={text} onChange={(e) => setText(e.target.value)} className="input" maxLength={300} placeholder="Server restart in 5 minutes" autoComplete="off" />
|
||||
</label>
|
||||
<label style={{ display: 'block', maxWidth: 140 }}>
|
||||
<span className="field-label">Hue (optional)</span>
|
||||
<input type="number" value={hue} onChange={(e) => setHue(e.target.value)} className="input" min={0} max={3000} placeholder="53" />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button onClick={send} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Sending…' : 'Broadcast'}</button>
|
||||
<Flash ok={ok} err={err} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Account actions (kick / ban / unban) ─────────────────────────────────────
|
||||
function AccountActions() {
|
||||
const [account, setAccount] = useState('')
|
||||
const [durationSec, setDurationSec] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
const [busy, setBusy] = useState('')
|
||||
const [ok, setOk] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const acct = account.trim()
|
||||
function guard() {
|
||||
if (!acct) {
|
||||
setErr('Enter an account name.')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function run(label, fn, done) {
|
||||
if (!guard()) return
|
||||
setBusy(label); setOk(''); setErr('')
|
||||
try {
|
||||
const r = await fn()
|
||||
setOk(done(r))
|
||||
} catch (e) {
|
||||
setErr(e.message || 'Action failed.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const kick = () =>
|
||||
run('kick', () => api.admin.shardOps.kick({ account: acct }), (r) => {
|
||||
const n = r?.sessions != null ? r.sessions : null
|
||||
const plural = n === 1 ? '' : 's'
|
||||
const sessions = n != null ? ` (${n} session${plural})` : ''
|
||||
return `Kicked ${acct}${sessions}.`
|
||||
})
|
||||
const ban = () =>
|
||||
run(
|
||||
'ban',
|
||||
() =>
|
||||
api.admin.shardOps.ban({
|
||||
account: acct,
|
||||
durationSec: durationSec === '' ? undefined : Number(durationSec),
|
||||
reason: reason.trim() || undefined,
|
||||
}),
|
||||
() => {
|
||||
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
|
||||
return `Banned ${acct}${when}.`
|
||||
},
|
||||
)
|
||||
const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`)
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Account actions</h3>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
||||
Kick, ban or unban a game account. Bans work even if the account is offline; the shard refuses to act on staff at or above co-owner.
|
||||
</p>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Account</span>
|
||||
<input type="text" value={account} onChange={(e) => setAccount(e.target.value)} className="input" placeholder="griefer42" autoComplete="off" style={{ maxWidth: 260 }} />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<label style={{ display: 'block', maxWidth: 200 }}>
|
||||
<span className="field-label">Ban duration (seconds, blank = permanent)</span>
|
||||
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" />
|
||||
</label>
|
||||
<label style={{ display: 'block', flex: 1, minWidth: 200 }}>
|
||||
<span className="field-label">Ban reason (optional)</span>
|
||||
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={kick} disabled={!!busy} className="btn btn-sq">{busy === 'kick' ? 'Kicking…' : 'Kick'}</button>
|
||||
<button onClick={ban} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'ban' ? 'Banning…' : 'Ban'}</button>
|
||||
<button onClick={unban} disabled={!!busy} className="btn btn-sq">{busy === 'unban' ? 'Unbanning…' : 'Unban'}</button>
|
||||
<Flash ok={ok} err={err} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Support (help-page) queue ────────────────────────────────────────────────
|
||||
function PageRow({ page, onDone }) {
|
||||
const [message, setMessage] = useState('')
|
||||
const [busy, setBusy] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
async function respond(close) {
|
||||
if (!message.trim()) return setErr('Enter a reply first.')
|
||||
setBusy(close ? 'respond-close' : 'respond'); setErr('')
|
||||
try {
|
||||
await api.admin.shardOps.respondPage(page.pageId, { message: message.trim(), close })
|
||||
onDone()
|
||||
} catch (e) {
|
||||
setErr(e.message || 'Could not send.')
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
async function close() {
|
||||
setBusy('close'); setErr('')
|
||||
try {
|
||||
await api.admin.shardOps.closePage(page.pageId)
|
||||
onDone()
|
||||
} catch (e) {
|
||||
setErr(e.message || 'Could not close.')
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<span className="sans" style={{ fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)' }}>{page.type || 'Page'}</span>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
|
||||
{page.sender?.name || page.pageId}
|
||||
{page.handled && <span className="dim" style={{ fontSize: '0.72rem' }}> · claimed{page.handler ? ` by ${page.handler}` : ''}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{page.sentMs ? ago(page.sentMs) : ''}</span>
|
||||
</div>
|
||||
{page.message && <p className="sans" style={{ margin: 0, color: 'var(--ink)', fontSize: '0.88rem', lineHeight: 1.5 }}>{page.message}</p>}
|
||||
<div className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||
{page.map || '—'}{page.x != null ? ` (${page.x}, ${page.y})` : ''}
|
||||
</div>
|
||||
<textarea value={message} onChange={(e) => setMessage(e.target.value)} className="input" rows={2} placeholder="A GM is on the way." style={{ resize: 'vertical' }} />
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={() => respond(false)} disabled={!!busy} className="btn btn-sq">{busy === 'respond' ? 'Sending…' : 'Reply'}</button>
|
||||
<button onClick={() => respond(true)} disabled={!!busy} className="btn btn-primary btn-sq">{busy === 'respond-close' ? 'Sending…' : 'Reply & close'}</button>
|
||||
<button onClick={close} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'close' ? 'Closing…' : 'Close'}</button>
|
||||
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SupportQueue() {
|
||||
const [pages, setPages] = useState(null)
|
||||
const [err, setErr] = useState('')
|
||||
const pollRef = useRef(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setPages(await api.admin.shardOps.pages())
|
||||
} catch {
|
||||
setErr('Could not load the support queue.')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
pollRef.current = setInterval(load, 7000)
|
||||
return () => clearInterval(pollRef.current)
|
||||
}, [load])
|
||||
|
||||
let queueBody
|
||||
if (pages == null) {
|
||||
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading…</p>
|
||||
} else if (pages.length === 0) {
|
||||
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>The queue is empty.</p>
|
||||
} else {
|
||||
queueBody = (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{pages.map((p) => <PageRow key={p.pageId} page={p} onDone={load} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Support queue</h3>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
||||
Open help pages from players. A reply reaches them in game (or on their next login).
|
||||
</p>
|
||||
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>}
|
||||
{queueBody}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Audit log ────────────────────────────────────────────────────────────────
|
||||
// Seeded from the stored admin.audit history, then kept live from the admin SSE
|
||||
// channel (which carries every kind — we filter to admin.audit here).
|
||||
function AuditLog() {
|
||||
const [seed, setSeed] = useState([])
|
||||
const { events } = useShardFeed({ url: api.adminShardStreamUrl, filter: new Set(['admin.audit']), max: 50 })
|
||||
|
||||
useEffect(() => {
|
||||
api.admin.shardOps
|
||||
.audit(50)
|
||||
.then((rows) => setSeed(rows.map((r) => ({ ...r, _id: `seed-${r.id}` }))))
|
||||
.catch(() => setSeed([]))
|
||||
}, [])
|
||||
|
||||
// Live events on top; fall back to the seed for anything older than the live tail.
|
||||
const oldestLive = events.length ? Math.min(...events.map((e) => e.t || 0)) : Infinity
|
||||
const rows = [...events, ...seed.filter((s) => (s.t || 0) < oldestLive)].slice(0, 60)
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)', marginBottom: 12 }}>Audit log</h3>
|
||||
{rows.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No moderation actions recorded yet.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 320, overflowY: 'auto' }}>
|
||||
{rows.map((e) => (
|
||||
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
|
||||
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ShardOps() {
|
||||
return (
|
||||
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
<Broadcast />
|
||||
<AccountActions />
|
||||
<SupportQueue />
|
||||
<AuditLog />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// ── Admin · Shard visibility ────────────────────────────────────────────────
|
||||
//
|
||||
// Who may see which shard surface, and which sensitive fields within it.
|
||||
// Admin-only, because this decides what ANONYMOUS visitors get.
|
||||
//
|
||||
// Two things the UI must communicate honestly, because they are not negotiable
|
||||
// server-side (see docs/link/v3.md §3.4):
|
||||
// • acct / webId are admin-only always and are not listed as editable fields.
|
||||
// • an event kind the server doesn't know about never reaches anyone below
|
||||
// admin, whatever is set here.
|
||||
//
|
||||
// Defaults reproduce the behavior the site had before this panel existed, so a
|
||||
// fresh install shows "everything as it was" rather than an empty form.
|
||||
|
||||
const RUNG_LABEL = {
|
||||
anonymous: 'Everyone',
|
||||
logged_in: 'Signed in',
|
||||
player: 'Linked players',
|
||||
staff: 'Staff',
|
||||
admin: 'Admins only',
|
||||
}
|
||||
|
||||
const RUNG_HINT = {
|
||||
anonymous: 'Visible to anyone, signed in or not.',
|
||||
logged_in: 'Any signed-in account, linked or not.',
|
||||
player: 'Accounts with a linked game account. Staff always qualify.',
|
||||
staff: 'Admins and moderators.',
|
||||
admin: 'Admins only.',
|
||||
}
|
||||
|
||||
const FEATURE_LABEL = {
|
||||
status: 'Shard status',
|
||||
activity: 'Activity feed',
|
||||
champs: 'Champion spawns',
|
||||
guilds: 'Guilds',
|
||||
governors: 'Town governors',
|
||||
houses: 'Houses / IDOC',
|
||||
presence: 'Players online',
|
||||
ruleset: 'Shard rules',
|
||||
atlas: 'Spawn atlas',
|
||||
leaderboards: 'Leaderboards',
|
||||
market: 'Marketplace',
|
||||
}
|
||||
|
||||
const FEATURE_HINT = {
|
||||
status: 'Connection state, online count, gold-supply series.',
|
||||
activity: 'Deaths, kills, skill gains, quests, logins.',
|
||||
champs: 'The live champion / mini-champ / sea-boss board.',
|
||||
guilds: 'Guild rosters, alliances and leaders.',
|
||||
governors: 'City Loyalty governors, elections and term history.',
|
||||
houses: 'Houses in danger (IDOC). Owner and price are separate fields below.',
|
||||
presence: 'Population aggregate and the staff-online widget.',
|
||||
ruleset: 'Skill/stat caps, house limits, vet rewards and the rest of the ruleset.',
|
||||
atlas: 'The spawn atlas and bestiary. Static shard content, not live state.',
|
||||
leaderboards: 'Point and loyalty standings across every points system.',
|
||||
market: 'The shard-wide player-vendor index.',
|
||||
}
|
||||
|
||||
const FIELD_LABEL = {
|
||||
owner: 'House owner',
|
||||
price: 'House price',
|
||||
location: 'In-game location (map + coordinates)',
|
||||
connect: 'Server connect address',
|
||||
// Keyed on the WIRE field, which for a leaderboard entry is `name` — the
|
||||
// projection matches literal JSON keys, so the rule cannot be spelled after the
|
||||
// field's meaning. The label is what carries the meaning to the admin.
|
||||
name: 'Character names on leaderboards',
|
||||
ownerName: 'Vendor owner name',
|
||||
// One rule, one key — `location` is a nested object on both the wire frame and
|
||||
// the stored read model precisely so that hiding it takes the facet, the
|
||||
// coordinates, the region and the house together.
|
||||
ownerSerial: 'Vendor owner character id',
|
||||
}
|
||||
|
||||
function RungSelect({ value, onChange, ladder, disabled }) {
|
||||
return (
|
||||
<select
|
||||
className="input"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ maxWidth: 200 }}
|
||||
>
|
||||
{ladder.map((rung) => (
|
||||
<option key={rung} value={rung}>
|
||||
{RUNG_LABEL[rung] || rung}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureRow({ name, settings, defaults, ladder, onPatch }) {
|
||||
const fields = Object.entries(settings.fields || {})
|
||||
const changed =
|
||||
defaults &&
|
||||
(settings.enabled !== defaults.enabled ||
|
||||
settings.audience !== defaults.audience ||
|
||||
settings.stream !== defaults.stream ||
|
||||
JSON.stringify(settings.fields) !== JSON.stringify(defaults.fields))
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
opacity: settings.enabled ? 1 : 0.62,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
|
||||
{FEATURE_LABEL[name] || name}
|
||||
{changed && (
|
||||
<span
|
||||
className="sans"
|
||||
style={{ marginLeft: 8, fontSize: '0.62rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)' }}
|
||||
>
|
||||
changed
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '4px 0 0', fontSize: '0.82rem', color: 'var(--muted)', lineHeight: 1.5 }}>
|
||||
{FEATURE_HINT[name]}
|
||||
</p>
|
||||
</div>
|
||||
<label
|
||||
className="sans"
|
||||
style={{ flex: 'none', display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enabled}
|
||||
onChange={(e) => onPatch(name, { enabled: e.target.checked })}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 20, alignItems: 'flex-end' }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Who can see it</span>
|
||||
<RungSelect
|
||||
value={settings.audience}
|
||||
ladder={ladder}
|
||||
disabled={!settings.enabled}
|
||||
onChange={(audience) => onPatch(name, { audience })}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 4, fontSize: '0.75rem' }}>
|
||||
{RUNG_HINT[settings.audience]}
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="sans"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)', paddingBottom: 22 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.stream}
|
||||
disabled={!settings.enabled}
|
||||
onChange={(e) => onPatch(name, { stream: e.target.checked })}
|
||||
/>
|
||||
Live updates
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{fields.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 12 }}>
|
||||
<span className="field-label" style={{ display: 'block', marginBottom: 8 }}>
|
||||
Sensitive fields
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
|
||||
{fields.map(([field, rung]) => (
|
||||
<label key={field} style={{ display: 'block' }}>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginBottom: 4 }}>
|
||||
{FIELD_LABEL[field] || field}
|
||||
</span>
|
||||
<RungSelect
|
||||
value={rung}
|
||||
ladder={ladder}
|
||||
disabled={!settings.enabled}
|
||||
onChange={(level) =>
|
||||
onPatch(name, { fieldRules: { ...settings.fields, [field]: level } })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ShardVisibility() {
|
||||
const [config, setConfig] = useState(null)
|
||||
const [defaults, setDefaults] = useState(null)
|
||||
const [ladder, setLadder] = useState([])
|
||||
const [lockedFields, setLockedFields] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await api.admin.getShardVisibility()
|
||||
setConfig(data.features)
|
||||
setDefaults(data.defaults)
|
||||
setLadder(data.ladder || [])
|
||||
setLockedFields(data.lockedFields || [])
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not load visibility settings.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
function patch(name, changes) {
|
||||
setMsg('')
|
||||
setConfig((prev) => {
|
||||
const next = { ...prev[name], ...changes }
|
||||
// `fieldRules` in the API is `fields` in the effective config.
|
||||
if (changes.fieldRules) {
|
||||
next.fields = changes.fieldRules
|
||||
delete next.fieldRules
|
||||
}
|
||||
return { ...prev, [name]: next }
|
||||
})
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
const body = {}
|
||||
for (const [name, s] of Object.entries(config)) {
|
||||
body[name] = {
|
||||
enabled: s.enabled,
|
||||
audience: s.audience,
|
||||
stream: s.stream,
|
||||
fieldRules: s.fields || {},
|
||||
}
|
||||
}
|
||||
const data = await api.admin.saveShardVisibility(body)
|
||||
setConfig(data.features)
|
||||
setMsg('Saved. Changes take effect within a few seconds, including on open live streams.')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function resetToDefaults() {
|
||||
setMsg('')
|
||||
setConfig(structuredClone(defaults))
|
||||
}
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error && !config) return <ErrorState message={error} onRetry={load} />
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<header>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||
Shard visibility
|
||||
</h2>
|
||||
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||
Choose who can see each shard surface on the public site, and how much detail they get.
|
||||
Turning a feature off hides it entirely — its pages return “not found” rather than
|
||||
revealing that it exists. “Live updates” controls whether the feature streams changes in
|
||||
real time; the pages still work without it, they just refresh on load.
|
||||
</p>
|
||||
{lockedFields.length > 0 && (
|
||||
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.82rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||
Not configurable: <strong style={{ color: 'var(--ink)' }}>{lockedFields.join(', ')}</strong> —
|
||||
game account names and website user ids are never shown below admin, on any surface. They
|
||||
aren’t visible in game either, so publishing them would disclose something the shard
|
||||
itself doesn’t.
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{Object.entries(config).map(([name, settings]) => (
|
||||
<FeatureRow
|
||||
key={name}
|
||||
name={name}
|
||||
settings={settings}
|
||||
defaults={defaults?.[name]}
|
||||
ladder={ladder}
|
||||
onPatch={patch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={save} disabled={saving} className="btn btn-primary btn-sq">
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
<button onClick={resetToDefaults} disabled={saving} className="btn btn-sq">
|
||||
Restore defaults
|
||||
</button>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// ── Admin · Spawn atlas ─────────────────────────────────────────────────────
|
||||
//
|
||||
// The atlas re-derives itself from the shard's ServUO tree on every boot, so
|
||||
// this panel exists for the three things a restart cannot do:
|
||||
//
|
||||
// • point it at a different tree,
|
||||
// • apply a map change without restarting, and
|
||||
// • answer a refresh that was parsed but deliberately NOT applied because it
|
||||
// would remove a facet.
|
||||
//
|
||||
// That last one is the reason the panel is worth building. Losing a facet looks
|
||||
// exactly like a half-copied or mid-update tree, and boot cannot tell them
|
||||
// apart — so it stages the decision for a human instead of guessing. Until
|
||||
// someone decides here, the site keeps serving the atlas it already had.
|
||||
|
||||
// A refresh reports its outcome rather than throwing (the boot path must never
|
||||
// be stopped by a bad tree), so these are answers, not errors — the panel says
|
||||
// what happened in the shard's terms instead of showing a failure box.
|
||||
const OUTCOME = {
|
||||
imported: (r) =>
|
||||
`Imported — ${r.counts?.points?.toLocaleString() ?? '?'} spawners, ${r.counts?.creatures?.toLocaleString() ?? '?'} creatures.`,
|
||||
unchanged: (r) =>
|
||||
r.reason === 'refresh previously rejected'
|
||||
? 'Unchanged — this exact tree was already reviewed and declined.'
|
||||
: 'Unchanged — the tree matches what is already loaded.',
|
||||
needsReview: () => 'Staged for review: this refresh would remove a facet, so it was not applied.',
|
||||
unavailable: (r) => `The tree could not be read: ${r.reason || 'unknown reason'}`,
|
||||
skipped: () => 'No ServUO path is configured, so there is nothing to import.',
|
||||
failed: (r) => `Refresh failed: ${r.reason || 'unknown reason'}`,
|
||||
rejected: () => 'Declined. It will not be offered again until the tree changes.',
|
||||
}
|
||||
|
||||
const describe = (result) => (OUTCOME[result?.status] || (() => `Result: ${result?.status}`))(result)
|
||||
|
||||
function Row({ label, children }) {
|
||||
return (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
padding: '7px 0',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
<span className="dim">{label}</span>
|
||||
<span style={{ color: 'var(--head)', textAlign: 'right', wordBreak: 'break-all' }}>{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PendingReview({ pending, busy, onApprove, onReject }) {
|
||||
const declined = pending.status === 'rejected'
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
border: `1px solid ${declined ? 'var(--line)' : '#c58f4a'}`,
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
background: declined ? 'transparent' : 'rgba(197,143,74,0.08)',
|
||||
}}
|
||||
>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
|
||||
{declined ? 'A refresh was declined' : 'A refresh is waiting for you'}
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '6px 0 12px', fontSize: '0.86rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
{declined ? (
|
||||
<>
|
||||
This tree was reviewed and declined, so it is not offered again until the files change.
|
||||
Approving now applies it anyway.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
The tree parses cleanly but would <strong>remove {pending.removedFacets?.length || 0} facet
|
||||
</strong>
|
||||
{(pending.removedFacets?.length || 0) === 1 ? '' : 's'} the site is currently serving. That
|
||||
is what a half-copied or mid-update tree looks like as well as a real map change, so it was
|
||||
not applied. Approving re-parses the tree as it is right now — if you have since fixed the
|
||||
mount, what lands is the corrected import.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<Row label="Would remove">{(pending.removedFacets || []).join(', ') || '—'}</Row>
|
||||
<Row label="Would add">{(pending.addedFacets || []).join(', ') || '—'}</Row>
|
||||
<Row label="Detected">{pending.detectedAt ? new Date(pending.detectedAt).toLocaleString() : '—'}</Row>
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 14, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={onApprove}>
|
||||
Approve and import
|
||||
</button>
|
||||
{!declined && (
|
||||
<button type="button" className="btn btn-sq" disabled={busy} onClick={onReject}>
|
||||
Keep the current atlas
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SpawnAtlas() {
|
||||
const [status, setStatus] = useState(null)
|
||||
const [path, setPath] = useState('')
|
||||
const [force, setForce] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await api.admin.atlas.status()
|
||||
setStatus(data)
|
||||
setPath(data.path || '')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not load atlas status.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
// Every mutating action shares this: run it, report what it said, then reload
|
||||
// status so the panel reflects the world rather than what we assumed happened.
|
||||
async function run(action, fn) {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
const result = await fn()
|
||||
setMsg(describe(result))
|
||||
const fresh = await api.admin.atlas.status()
|
||||
setStatus(fresh)
|
||||
setPath(fresh.path || '')
|
||||
} catch (err) {
|
||||
setError(err.message || `Could not ${action}.`)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function savePath() {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
const fresh = await api.admin.atlas.setPath(path.trim())
|
||||
setStatus(fresh)
|
||||
setPath(fresh.path || '')
|
||||
setMsg(
|
||||
fresh.path === ''
|
||||
? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.'
|
||||
: fresh.treeReadable
|
||||
? 'Saved. The tree is readable — import when you are ready.'
|
||||
: 'Saved, but the tree could not be read from here. Check the mount and permissions.',
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save the path.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error && !status) return <ErrorState message={error} />
|
||||
|
||||
const counts = status?.counts || null
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<header>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||
Spawn atlas
|
||||
</h2>
|
||||
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||
The bestiary and spawn map on the public site, parsed from the shard’s own ServUO files.
|
||||
It refreshes itself on every server start; everything here is for the times you don’t want
|
||||
to wait for one. Nothing on this page touches the sidecar — the atlas is shard content, not
|
||||
shard state, and stays complete while the shard is down.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{status?.pending && (
|
||||
<PendingReview
|
||||
pending={status.pending}
|
||||
busy={busy}
|
||||
onApprove={() => run('approve the refresh', () => api.admin.atlas.approve())}
|
||||
onReject={() => run('decline the refresh', () => api.admin.atlas.reject())}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 10px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||
What is loaded
|
||||
</h3>
|
||||
<Row label="Imported">
|
||||
{status?.importedAt ? new Date(status.importedAt).toLocaleString() : 'Never'}
|
||||
</Row>
|
||||
<Row label="Facets">{status?.facets?.length ? status.facets.join(', ') : '—'}</Row>
|
||||
{counts && (
|
||||
<>
|
||||
<Row label="Spawners">{counts.points?.toLocaleString() ?? '—'}</Row>
|
||||
<Row label="Creatures">{counts.creatures?.toLocaleString() ?? '—'}</Row>
|
||||
<Row label="Regions / landmarks">
|
||||
{`${counts.regions?.toLocaleString() ?? '—'} / ${counts.landmarks?.toLocaleString() ?? '—'}`}
|
||||
</Row>
|
||||
<Row label="Champion altars">{counts.champions?.toLocaleString() ?? '—'}</Row>
|
||||
</>
|
||||
)}
|
||||
<Row label="Tree readable">
|
||||
{!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'}
|
||||
</Row>
|
||||
<Row label="Tree changed since import">
|
||||
{status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'}
|
||||
</Row>
|
||||
</section>
|
||||
|
||||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||
ServUO tree
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
Where the website reads the shard’s spawn files from — the same host, a bind mount or a
|
||||
shared volume. This setting wins over the <code>SERVUO_PATH</code> deploy default, so the
|
||||
mount can move without a redeploy. Leave it blank to turn the atlas off.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="/srv/servuo"
|
||||
style={{ flex: '1 1 320px', minWidth: 0 }}
|
||||
/>
|
||||
<button type="button" className="btn btn-sq" disabled={busy} onClick={savePath}>
|
||||
Save path
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||
Re-import
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
Applies a map change without restarting. An unchanged tree costs nothing — the source files
|
||||
are hashed first and skipped when they match. A refresh that would remove a facet still
|
||||
comes back here for approval rather than being applied.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy || !status?.configured}
|
||||
onClick={() => run('import the atlas', () => api.admin.atlas.import(force))}
|
||||
>
|
||||
{busy ? 'Working…' : 'Import now'}
|
||||
</button>
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: '0.85rem', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} />
|
||||
Re-import even if the tree is unchanged
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(msg || error) && (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -171,8 +171,9 @@ export default function UserDetail() {
|
||||
|
||||
<SecurityAdmin userId={id} />
|
||||
{/* Whatever the installed module has to say about this user, or nothing
|
||||
at all. Core's own UO sections fill it today (UserShardSections.jsx,
|
||||
registered in main.jsx) — MODULE_API.md §3.7. */}
|
||||
at all — core filled this with its own UO sections until Phase 3 slice
|
||||
3, and now nothing does unless a module is installed
|
||||
(MODULE_API.md §3.7). */}
|
||||
<Slot name="admin.users.detail" userId={id} />
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
// ── Core's fill for the `admin.users.detail` extension slot ────────────────
|
||||
//
|
||||
// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1. Every section below
|
||||
// is UO, and every one of them leaves core with the client half in slice 3 —
|
||||
// this file exists so that when they do, core deletes a registration and a file
|
||||
// instead of unpicking a page.
|
||||
//
|
||||
// Core registers it through the same seam a module uses
|
||||
// (`registerExtension('core', …)` in main.jsx), which is the client twin of the
|
||||
// server's `registries.registerCore()` and the same trick `useShardFlags`
|
||||
// already uses for the feature seam. The mechanism is therefore exercised by
|
||||
// core's own content from the day it lands, rather than first proved by the
|
||||
// change that depends on it.
|
||||
//
|
||||
// The slot hands over `userId` and nothing else — deliberately, not `scope`.
|
||||
// `api.admin.userShard` is a UO binding that leaves core in slice 3, so a slot
|
||||
// that passed it would be handing a module something core is about to delete.
|
||||
// An extension builds its own client for the routes it registered at the other
|
||||
// end (MODULE_API.md §3.5), and this file does exactly what the module will.
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { ago } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
import CharacterStats from '../../../components/CharacterStats.jsx'
|
||||
import GameAccounts from '../../../components/GameAccounts.jsx'
|
||||
import VendorSales from '../../../components/VendorSales.jsx'
|
||||
|
||||
// Its own copy, not an export from UserDetail.jsx: six lines of presentational
|
||||
// furniture that is not in the §3.4 kit, so a module filling this slot would
|
||||
// vendor the same thing. Core's copy stays behind with core's own security
|
||||
// panel, which is the other caller.
|
||||
function SectionTitle({ children }) {
|
||||
return (
|
||||
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Currently-online characters on the user's accounts, with where they are. The
|
||||
// per-character Online/Offline badge lives in the roster; this adds location.
|
||||
function OnlineNow({ scope }) {
|
||||
const { data } = useAsync(() => scope.online(), [scope])
|
||||
if (!data) return null
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Online now</SectionTitle>
|
||||
{data.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.map((c) => (
|
||||
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
|
||||
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
|
||||
</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
|
||||
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Shard "standing": city governorships held and guilds led by this user's
|
||||
// accounts (both reliable current-state lookups). Renders nothing when empty.
|
||||
function Standing({ scope }) {
|
||||
const { data } = useAsync(() => scope.standing(), [scope])
|
||||
if (!data) return null
|
||||
const govs = data.governorOf || []
|
||||
const guilds = data.guildsLed || []
|
||||
if (govs.length === 0 && guilds.length === 0) return null
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Standing</SectionTitle>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{govs.map((g) => (
|
||||
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
|
||||
Governor of {g.city}
|
||||
</span>
|
||||
))}
|
||||
{guilds.map((g) => (
|
||||
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
|
||||
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// One house row — the many optional detail fields are gathered here so the
|
||||
// Houses list stays a simple map.
|
||||
function HouseRow({ house: h }) {
|
||||
const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown')
|
||||
const coords = h.x != null ? ` · ${h.x}, ${h.y}` : ''
|
||||
const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : ''
|
||||
const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''
|
||||
return (
|
||||
<li
|
||||
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
|
||||
{h.name || 'Unnamed house'}
|
||||
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
|
||||
{location}
|
||||
{coords}
|
||||
{owner}
|
||||
{shares}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
|
||||
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
|
||||
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
|
||||
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Houses owned by the user's accounts, IDOC first (flagged).
|
||||
function Houses({ scope }) {
|
||||
const { data } = useAsync(() => scope.houses(), [scope])
|
||||
if (!data) return null
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Houses</SectionTitle>
|
||||
{data.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this user’s accounts.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{data.map((h) => (
|
||||
<HouseRow key={h.serial} house={h} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
export default function UserShardSections({ userId }) {
|
||||
// Memoized so the child components' effects (keyed on `scope`) don't refetch
|
||||
// on every render — the same reason UserDetail memoized it before this moved.
|
||||
const scope = useMemo(() => api.admin.userShard(userId), [userId])
|
||||
return (
|
||||
<>
|
||||
<CharacterStats scope={scope} />
|
||||
<SectionTitle>Linked accounts & characters</SectionTitle>
|
||||
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/characters/${serial}`} />
|
||||
<Standing scope={scope} />
|
||||
<OnlineNow scope={scope} />
|
||||
<Houses scope={scope} />
|
||||
<VendorSales fetchSales={scope.sales} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
|
||||
import CreateGameAccountForm from '../../components/CreateGameAccountForm.jsx'
|
||||
import Slot from '../../modules/Slot.jsx'
|
||||
import { extensionFor } from '../../modules/registry.js'
|
||||
|
||||
// Public, token-gated invite acceptance (/invite/:token). Validates the invite,
|
||||
// lets the invitee set a username + password (their email + role are pre-assigned),
|
||||
// creates the account at that role and logs them in. For a player invite it then
|
||||
// offers the built-in "create game account" step before sending them to the portal.
|
||||
// creates the account at that role and logs them in.
|
||||
//
|
||||
// For a PLAYER invite there may then be one more step, supplied by an installed
|
||||
// module through the `player.invite.accepted` slot: core rendered a UO
|
||||
// game-account form here itself until Phase 3 slice 3, reading a
|
||||
// `gameAccountSignup` flag out of its own settings and posting to a shard route.
|
||||
// Neither of those is core's. What core keeps is the shell, the skip control and
|
||||
// the destination; whether there is a step at all is the module's call, made
|
||||
// from data core does not have.
|
||||
export default function AcceptInvite() {
|
||||
const { token } = useParams()
|
||||
const navigate = useNavigate()
|
||||
@@ -16,7 +24,6 @@ export default function AcceptInvite() {
|
||||
|
||||
const [invite, setInvite] = useState(null) // fields email and role
|
||||
const [loadErr, setLoadErr] = useState('')
|
||||
const [signupOk, setSignupOk] = useState(false)
|
||||
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -30,14 +37,20 @@ export default function AcceptInvite() {
|
||||
api.getInvite(token)
|
||||
.then((iv) => active && setInvite(iv))
|
||||
.catch((err) => active && setLoadErr(err.status === 404 ? 'This invitation is invalid or has expired.' : 'Could not load this invitation.'))
|
||||
api.publicSettings()
|
||||
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
|
||||
.catch(() => {})
|
||||
return () => { active = false }
|
||||
}, [token])
|
||||
|
||||
const dest = invite && invite.role === 'player' ? '/player' : '/admin'
|
||||
|
||||
// Whether anything is installed that wants the post-acceptance step. Read
|
||||
// rather than rendered blind because it decides a NAVIGATION, not just what
|
||||
// appears: with nothing filled there is no screen to show, so the invitee goes
|
||||
// straight to their destination. This is the one legitimate reason to ask
|
||||
// whether a slot is filled — the answer changes control flow, not decoration
|
||||
// (decoration goes inside `<Slot wrap>`, which is why `hasExtension` is gone).
|
||||
const hasNextStep = Boolean(extensionFor('player.invite.accepted'))
|
||||
const finish = useCallback(() => navigate('/player', { replace: true }), [navigate])
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
@@ -48,8 +61,9 @@ export default function AcceptInvite() {
|
||||
await api.acceptInvite(token, username.trim(), password, { company })
|
||||
await refresh() // pull the freshly-issued session into context
|
||||
setAccepted(true)
|
||||
// Staff invites are web-only — no game step; go straight in.
|
||||
if (!(invite.role === 'player' && signupOk)) navigate(dest, { replace: true })
|
||||
// Staff invites go straight in, and so does a player invite when nothing
|
||||
// is installed that has a step to offer.
|
||||
if (!(invite.role === 'player' && hasNextStep)) navigate(dest, { replace: true })
|
||||
} catch (err) {
|
||||
if (err.status === 409) setError('That username is already taken, or the invite was already used.')
|
||||
else if (err.status === 404) setError('This invitation is invalid or has expired.')
|
||||
@@ -78,19 +92,22 @@ export default function AcceptInvite() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Accepted: optional game-account step (player invites) ──────────────────
|
||||
// ── Accepted: a module's optional next step (player invites) ───────────────
|
||||
//
|
||||
// Only reachable when the slot is filled — `onSubmit` navigates away otherwise
|
||||
// — so there is no empty-shell case to guard here.
|
||||
//
|
||||
// The subtitle is core's and says nothing about what the step is: naming it
|
||||
// would be core describing content it does not own, and the wrong description
|
||||
// is worse than a general one. "Skip" stays core's too, because where it goes
|
||||
// is core's decision, and it is rendered outside the slot deliberately — an
|
||||
// extension that throws must not take the way out with it.
|
||||
if (accepted) {
|
||||
return (
|
||||
<PlayerShell subtitle="Set up your game account">
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
Your account is ready. Create a game account now to play, or skip and do it later from your portal.
|
||||
</p>
|
||||
<CreateGameAccountForm
|
||||
submit={api.player.shard.createAccount}
|
||||
onCreated={() => navigate('/player', { replace: true })}
|
||||
/>
|
||||
<PlayerShell subtitle="One more step">
|
||||
<Slot name="player.invite.accepted" onDone={finish} />
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '18px 0 0' }}>
|
||||
<button type="button" onClick={() => navigate('/player', { replace: true })} className="btn" style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer' }}>
|
||||
<button type="button" onClick={finish} className="btn" style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer' }}>
|
||||
Skip for now →
|
||||
</button>
|
||||
</p>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import CharacterSheet from '../../components/CharacterSheet.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// A player's character sheet inside the portal. Owner-checked: the endpoint only
|
||||
// returns a sheet for a character on an account linked to the caller.
|
||||
export default function PlayerCharacter() {
|
||||
const { serial } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.player.shard.char(serial), [serial])
|
||||
const restarting = error && error.status === 503
|
||||
const forbidden = error && error.status === 403
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ margin: '0 0 18px' }}>
|
||||
<Link to="/player" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
|
||||
← Back to characters
|
||||
</Link>
|
||||
</p>
|
||||
{loading && <Loading />}
|
||||
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
||||
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
|
||||
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
|
||||
{!loading && !error && data && <CharacterSheet char={data} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import GameAccounts from '../../components/GameAccounts.jsx'
|
||||
import VendorSales from '../../components/VendorSales.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The logged-in player's characters. Shows the link prompt when no game account
|
||||
// is linked, otherwise their characters grouped by account (shared component),
|
||||
// plus their own home status and recent vendor sales.
|
||||
|
||||
const DECAY_TONE = {
|
||||
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
|
||||
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
|
||||
}
|
||||
|
||||
// The caller's own houses (home status). Only their own — never anyone else's.
|
||||
function MyHouses() {
|
||||
const { data } = useAsync(() => api.player.shard.houses(), [])
|
||||
if (!data || data.length === 0) return null
|
||||
return (
|
||||
<section style={{ marginTop: 30 }}>
|
||||
<div className="field-label" style={{ marginBottom: 12 }}>My houses</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{data.map((h) => {
|
||||
const label = h.isIdoc ? 'IDOC' : (h.decay || h.stage)
|
||||
const tone = h.isIdoc ? '#e05a5a' : (DECAY_TONE[label] || 'var(--muted)')
|
||||
return (
|
||||
<div key={h.serial} className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>{h.name || 'An unnamed house'}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{h.region || h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{label && (
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 9px' }}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.76rem' }}>
|
||||
Keep an eye on the decay status — refresh a house in game before it reaches IDOC.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PlayerCharacters() {
|
||||
return (
|
||||
<div>
|
||||
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/char/${serial}`} />
|
||||
<MyHouses />
|
||||
<VendorSales fetchSales={api.player.shard.sales} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo } from 'react'
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
import { applyNavOverrides } from '../../lib/navOverrides.js'
|
||||
import { firstDestinationFor } from '../../lib/adminNav.js'
|
||||
import { useNavOverrides } from '../../lib/useNavOverrides.js'
|
||||
import { withModuleNav } from '../../modules/nav.js'
|
||||
import { useFeatureGate } from '../../modules/features.jsx'
|
||||
@@ -32,29 +33,37 @@ function Icon({ children, size = 16 }) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
||||
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
|
||||
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
|
||||
|
||||
// Exported because Admin -> Navigation edits this list. It stays declared here;
|
||||
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
|
||||
// carries a gate — every player sees all three — but an installed module's rows
|
||||
// join this list before the merge and may carry a `feature`, so the filter after
|
||||
// it is not dead code.
|
||||
// carries a gate — every player sees both — but an installed module's rows join
|
||||
// this list before the merge and may carry a `feature`, so the filter after it
|
||||
// is not dead code.
|
||||
//
|
||||
// "Characters" was the first row and left with the client half in slice 3; the
|
||||
// UO module registers it again at `/player/uo/characters`, in this position,
|
||||
// with `order: 0`.
|
||||
export const NAV = [
|
||||
{ to: '/player', label: 'Characters', end: true, icon: IconUser },
|
||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
||||
]
|
||||
|
||||
// The sticky content header mirrors the active page. Character sheets live under
|
||||
// /player/char/:serial and keep their own in-page back link.
|
||||
// The sticky content header mirrors the active page. A module's pages are not
|
||||
// here and cannot be — core does not know what they are called — so they title
|
||||
// from their own nav row, the same rule AdminLayout's `moduleTitle` follows.
|
||||
const TITLES = {
|
||||
'/player': 'Characters',
|
||||
'/account': 'Account',
|
||||
'/account/appeals': 'Appeals',
|
||||
}
|
||||
|
||||
function moduleTitle(baseNav, pathname) {
|
||||
return baseNav
|
||||
.filter((i) => i.moduleId && (pathname === i.to || pathname.startsWith(`${i.to}/`)))
|
||||
.sort((a, b) => b.to.length - a.to.length)[0]?.label
|
||||
}
|
||||
|
||||
const navBtnBase = {
|
||||
textAlign: 'left',
|
||||
borderRadius: 8,
|
||||
@@ -80,9 +89,7 @@ export default function PlayerPortalLayout() {
|
||||
)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const title =
|
||||
TITLES[location.pathname] ||
|
||||
(location.pathname.startsWith('/player/char/') ? 'Character' : 'Player Portal')
|
||||
const title = TITLES[location.pathname] || moduleTitle(baseNav, location.pathname) || 'Player Portal'
|
||||
|
||||
async function signOut() {
|
||||
await logout()
|
||||
@@ -130,7 +137,13 @@ export default function PlayerPortalLayout() {
|
||||
borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
|
||||
})}
|
||||
>
|
||||
<n.icon />
|
||||
{/* Guarded, like AdminLayout's. `icon` is optional in the nav
|
||||
contract (§3.3) and every CORE row here has always had one, so
|
||||
an unguarded `<n.icon />` was fine right up until a module
|
||||
registered a row without — and then it was not a missing glyph,
|
||||
it was React error #130 and a blank portal. Found by the §7.7
|
||||
browser smoke; no DOM-less test can see it. */}
|
||||
{n.icon && <n.icon />}
|
||||
<span>{n.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
@@ -182,3 +195,28 @@ export default function PlayerPortalLayout() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What `/player` renders.
|
||||
*
|
||||
* It used to be `PlayerCharacters`, a UO page, which left the portal with no
|
||||
* index at all when the client half was extracted (slice 3). Rather than pick a
|
||||
* fixed destination or invent a core landing page, the index resolves to the
|
||||
* first row of the portal nav this viewer can actually reach — so with the UO
|
||||
* module installed a player still arrives at their characters, exactly as
|
||||
* before, and with nothing installed they arrive at Account.
|
||||
*
|
||||
* Resolved from the BASE nav, before overrides: where everybody lands is
|
||||
* behaviour, and an override is presentation (`firstDestinationFor`). `replace`
|
||||
* so the back button leaves the portal rather than bouncing off this redirect.
|
||||
*
|
||||
* The same question exists one area over — the admin index is a hardcoded
|
||||
* Dashboard — and if the two logged-in areas ever become one, this is the shape
|
||||
* that answers for both. Nothing here assumes a portal separate from admin.
|
||||
*/
|
||||
export function PlayerIndex() {
|
||||
const { user } = useAuth()
|
||||
const baseNav = useMemo(() => withModuleNav(NAV, 'player'), [])
|
||||
const to = firstDestinationFor(baseNav, user?.role, '/account')
|
||||
return <Navigate to={to} replace />
|
||||
}
|
||||
|
||||
@@ -10,19 +10,19 @@ export default function About() {
|
||||
<PageHeader eyebrow="About" title={`About ${siteShortName}`} />
|
||||
<div className="prose">
|
||||
<p>
|
||||
{siteShortName} is an independent, privately-run Ultima Online shard built by a small group of long-time players.
|
||||
It is not affiliated with or endorsed by the owners of Ultima Online — it is a labor of love for the old
|
||||
worlds and the friendships made in them.
|
||||
{siteShortName} is an independent, privately-run game server built by a small group of long-time
|
||||
players. It is not affiliated with or endorsed by the owners of the game it runs — it is a labor of
|
||||
love for the old worlds and the friendships made in them.
|
||||
</p>
|
||||
<p>
|
||||
Our aim is a calm, hand-tended world: a contested wilderness worth exploring, safe towns worth living in,
|
||||
and systems that reward curiosity over grind. We are building slowly and in the open, sharing news,
|
||||
Our aim is a calm, hand-tended world: somewhere worth exploring, somewhere worth living in, and
|
||||
systems that reward curiosity over grind. We are building slowly and in the open, sharing news,
|
||||
screenshots, and guides as the world comes online.
|
||||
</p>
|
||||
<h2>What to expect</h2>
|
||||
<ul>
|
||||
<li>A hybrid ruleset — safe towns, a dangerous wild.</li>
|
||||
<li>Custom crafting, housing, and exploration content.</li>
|
||||
<li>A world that is hand-tended rather than left to run itself.</li>
|
||||
<li>Custom content, and changes explained before they land.</li>
|
||||
<li>A small, friendly population and an active wiki.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// ── The spawn atlas ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// What the shard CONTAINS, as opposed to what it is doing: which creatures
|
||||
// spawn, where, and which champion altars are configured. There is no live feed
|
||||
// here and no `connected` indicator, deliberately — this is parsed from the
|
||||
// shard's own files and stays complete while the shard is down.
|
||||
//
|
||||
// Facet names come from the shard's data, never from a list in this file. A
|
||||
// shard running custom maps gets its own names in the filter with no code
|
||||
// change (docs/link/v3.md §6.1 R2).
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||
|
||||
const TABS = [
|
||||
{ key: 'creatures', label: 'Creatures' },
|
||||
{ key: 'champions', label: 'Champion altars' },
|
||||
{ key: 'places', label: 'Places' },
|
||||
]
|
||||
|
||||
function Chip({ active, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
padding: '5px 12px',
|
||||
borderRadius: 999,
|
||||
cursor: 'pointer',
|
||||
color: active ? 'var(--bg-deep)' : 'var(--muted)',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CreatureCard({ creature }) {
|
||||
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
|
||||
return (
|
||||
<Link
|
||||
to={`/site/atlas/${encodeURIComponent(creature.slug)}`}
|
||||
className="panel"
|
||||
style={{
|
||||
padding: '13px 15px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
className="display"
|
||||
style={{
|
||||
fontSize: '0.98rem',
|
||||
color: 'var(--head)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{creature.name}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||
{facets.length === 0
|
||||
? '—'
|
||||
: facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(creature.total)}</div>
|
||||
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>
|
||||
{num(creature.points)} spawners
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// The creature list owns its own paging rather than going through useAsync: a
|
||||
// "load more" appends to what is already on screen, which a hook that resets to
|
||||
// `{ loading: true, data: null }` on every dependency change cannot express.
|
||||
function Creatures({ q, facet }) {
|
||||
const [state, setState] = useState({ loading: true, error: null, items: [], total: 0 })
|
||||
const [more, setMore] = useState(false)
|
||||
|
||||
const load = useCallback(
|
||||
async (offset) => {
|
||||
const page = await api.atlas.creatures({ q, facet, limit: PAGE, offset })
|
||||
return page
|
||||
},
|
||||
[q, facet],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setState({ loading: true, error: null, items: [], total: 0 })
|
||||
load(0)
|
||||
.then((page) => {
|
||||
if (alive) setState({ loading: false, error: null, items: page.creatures || [], total: page.total || 0 })
|
||||
})
|
||||
.catch((error) => alive && setState({ loading: false, error, items: [], total: 0 }))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const loadMore = async () => {
|
||||
setMore(true)
|
||||
try {
|
||||
const page = await load(state.items.length)
|
||||
setState((s) => ({ ...s, items: [...s.items, ...(page.creatures || [])], total: page.total ?? s.total }))
|
||||
} catch {
|
||||
// A failed "load more" leaves what is already on screen alone; the button
|
||||
// simply stays available to retry.
|
||||
} finally {
|
||||
setMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.loading) return <Loading />
|
||||
if (state.error) return <ErrorState message="Could not load the bestiary right now." />
|
||||
if (state.items.length === 0) {
|
||||
return <EmptyState>Nothing in the atlas matches that.</EmptyState>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
||||
Showing {num(state.items.length)} of {num(state.total)}
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{state.items.map((c) => (
|
||||
<CreatureCard key={c.slug} creature={c} />
|
||||
))}
|
||||
</div>
|
||||
{state.items.length < state.total && (
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<button type="button" className="btn" onClick={loadMore} disabled={more}>
|
||||
{more ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// The CONFIGURED altar roster — where the altars are and what each summons. The
|
||||
// live board ("it is on level 3 right now") is a different page, /site/champs,
|
||||
// fed by the sidecar. Both exist; they are not the same thing.
|
||||
function Champions({ facet }) {
|
||||
const { loading, error, data } = useAsync(() => api.atlas.champions(facet), [facet])
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load the champion altars right now." />
|
||||
if (!data || data.length === 0) return <EmptyState>No champion altars are configured.</EmptyState>
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.map((champ) => (
|
||||
<div key={champ.slug} className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '0.98rem', color: 'var(--head)' }}>
|
||||
{champ.label || champ.name}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||
{champ.facet}
|
||||
{champ.group ? ` · ${champ.group}` : ''} · {champ.x}, {champ.y}
|
||||
</div>
|
||||
</div>
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.76rem', color: 'var(--muted)' }}>
|
||||
{champ.randomType ? 'Random champion' : champ.type || '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Regions and landmarks together: both answer "where is that?", and splitting
|
||||
// them into two tabs would make the visitor guess which list a name lives in.
|
||||
function Places({ q, facet }) {
|
||||
const { loading, error, data } = useAsync(
|
||||
() => Promise.all([api.atlas.regions({ q, facet }), api.atlas.landmarks({ q, facet })]),
|
||||
[q, facet],
|
||||
)
|
||||
const rows = useMemo(() => {
|
||||
if (!data) return []
|
||||
const [regions, landmarks] = data
|
||||
return [
|
||||
...regions.map((r) => ({ key: `r:${r.facet}:${r.name}`, name: r.name, facet: r.facet, detail: r.parent || r.type || 'Region', kind: 'Region' })),
|
||||
...landmarks.map((l) => ({ key: `l:${l.facet}:${l.group || ''}:${l.name}:${l.x}:${l.y}`, name: l.group ? `${l.group} — ${l.name}` : l.name, facet: l.facet, detail: `${l.x}, ${l.y}`, kind: 'Landmark' })),
|
||||
].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [data])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load places right now." />
|
||||
if (rows.length === 0) return <EmptyState>No regions or landmarks match that.</EmptyState>
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="panel" style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>{row.name}</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>{row.facet} · {row.detail}</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.66rem', letterSpacing: '0.06em', flex: 'none' }}>{row.kind}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Atlas() {
|
||||
const [tab, setTab] = useState('creatures')
|
||||
const [input, setInput] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [facet, setFacet] = useState('')
|
||||
const meta = useAsync(() => api.atlas.meta())
|
||||
|
||||
// Debounced: typing "lizardman" should be one request, not nine.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setQ(input.trim()), 250)
|
||||
return () => clearTimeout(timer)
|
||||
}, [input])
|
||||
|
||||
const facets = meta.data?.facets || []
|
||||
const counts = meta.data?.counts || null
|
||||
const imported = meta.data?.importedAt ? new Date(meta.data.importedAt) : null
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader
|
||||
eyebrow="Bestiary"
|
||||
title="Spawn atlas"
|
||||
lead="Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
|
||||
/>
|
||||
|
||||
{/* The atlas is only as good as its placement rate, so the page states
|
||||
it rather than implying every spawner resolved to a named place. */}
|
||||
{counts && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
|
||||
{num(counts.creatures)} creatures across {num(counts.points)} spawners
|
||||
{Number.isFinite(counts.unresolvedPoints) && counts.points
|
||||
? ` · ${Math.round(((counts.points - counts.unresolvedPoints) / counts.points) * 100)}% placed to a named region or landmark`
|
||||
: ''}
|
||||
{imported ? ` · parsed ${imported.toLocaleDateString()}` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
{TABS.map((t) => (
|
||||
<Chip key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
|
||||
{t.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab !== 'champions' && (
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={tab === 'creatures' ? 'Search creatures…' : 'Search regions and landmarks…'}
|
||||
style={{ width: '100%', marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{facets.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 18 }}>
|
||||
<Chip active={facet === ''} onClick={() => setFacet('')}>
|
||||
All facets
|
||||
</Chip>
|
||||
{facets.map((f) => (
|
||||
<Chip key={f} active={facet === f} onClick={() => setFacet(f)}>
|
||||
{f}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.error && <ErrorState message="Could not load the atlas right now." />}
|
||||
{!meta.error && !meta.loading && !imported && (
|
||||
<EmptyState>The spawn atlas has not been imported yet.</EmptyState>
|
||||
)}
|
||||
|
||||
{!meta.error && imported && (
|
||||
<>
|
||||
{tab === 'creatures' && <Creatures q={q} facet={facet} />}
|
||||
{tab === 'champions' && <Champions facet={facet} />}
|
||||
{tab === 'places' && <Places q={q} facet={facet} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// One creature: where it spawns, and what spawns alongside it.
|
||||
//
|
||||
// `places` is the point of the page — the aggregate that turns 62 raw
|
||||
// coordinates into "Shrines, Isamu-Jima, Yew". The individual spawners are
|
||||
// available underneath for the reader who actually wants a coordinate, but they
|
||||
// are secondary and collapsed by default.
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||
|
||||
// Spawn delays are stored in seconds. A raw "1200" tells the reader nothing.
|
||||
function delay(min, max) {
|
||||
const fmt = (s) => (s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`)
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) return null
|
||||
if (min === max) return fmt(min)
|
||||
return `${fmt(min)}–${fmt(max)}`
|
||||
}
|
||||
|
||||
function Panel({ title, right, children }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
||||
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
{right}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Places({ places }) {
|
||||
if (places.length === 0) {
|
||||
return <p className="sans dim" style={{ margin: 0 }}>No placed spawners.</p>
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{places.map((place) => (
|
||||
<div
|
||||
key={`${place.facet}:${place.label}`}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '6px 0',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ minWidth: 0, color: 'var(--head)' }}>{place.label}</span>
|
||||
<span className="dim" style={{ flex: 'none' }}>
|
||||
{place.facet} · {num(place.spawners)} spawner{place.spawners === 1 ? '' : 's'} · up to{' '}
|
||||
{num(place.maxAlive)} at once
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Spawners({ spawners, truncated }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (spawners.length === 0) return null
|
||||
return (
|
||||
<Panel
|
||||
title="Individual spawners"
|
||||
right={
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: '0.78rem' }}
|
||||
>
|
||||
{open ? 'Hide' : `Show ${num(spawners.length)}`}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{open && (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--muted)' }}>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Place</th>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Facet</th>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Coords</th>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Max</th>
|
||||
<th style={{ padding: '4px 0 8px 0' }}>Respawn</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{spawners.map((s) => (
|
||||
<tr key={s.id} style={{ borderTop: '1px solid var(--line)' }}>
|
||||
<td style={{ padding: '6px 8px 6px 0', color: 'var(--head)' }}>{s.label}</td>
|
||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.facet}</td>
|
||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.x}, {s.y}</td>
|
||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{num(s.maxCount)}</td>
|
||||
<td style={{ padding: '6px 0' }} className="dim">{delay(s.minDelay, s.maxDelay) || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{truncated && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||
Only the largest spawners are listed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AtlasCreature() {
|
||||
const { slug } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.atlas.creature(slug), [slug])
|
||||
|
||||
// A 404 here means "no such creature in this atlas", which is a real answer
|
||||
// and not a failure — a visitor following a stale link deserves to be told
|
||||
// that plainly rather than shown a generic error box.
|
||||
const missing = error?.status === 404 || error?.message === 'Not Found'
|
||||
|
||||
const facets = useMemo(
|
||||
() => Object.entries(data?.facets || {}).sort((a, b) => b[1] - a[1]),
|
||||
[data],
|
||||
)
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<p className="sans" style={{ marginBottom: 8 }}>
|
||||
<Link to="/site/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
|
||||
← Spawn atlas
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && !missing && <ErrorState message="Could not load that creature right now." />}
|
||||
{missing && <EmptyState>Nothing by that name spawns on this shard.</EmptyState>}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Bestiary"
|
||||
title={data.name}
|
||||
lead={`Up to ${num(data.total)} alive at once across ${num(data.points)} spawner${data.points === 1 ? '' : 's'}.`}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Panel
|
||||
title="Where it spawns"
|
||||
right={
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
{facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Places places={data.places || []} />
|
||||
</Panel>
|
||||
|
||||
<Spawners spawners={data.spawners || []} truncated={!!data.spawnersTruncated} />
|
||||
|
||||
{data.alsoHere?.length > 0 && (
|
||||
<Panel title="Shares a spawner with">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{data.alsoHere.map((other) => (
|
||||
<Link
|
||||
key={other.slug}
|
||||
to={`/site/atlas/${encodeURIComponent(other.slug)}`}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
padding: '4px 11px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--line)',
|
||||
color: 'var(--muted)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{other.name} <span className="dim">×{num(other.shared)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The champion-spawn board. Loaded once from /public/shard/champs, then kept live
|
||||
// by merging champ.update / champ.remove deltas from the public SSE feed. Three
|
||||
// families share the board, split by category into their own sections.
|
||||
const CHAMP_KINDS = new Set(['champ.update', 'champ.remove'])
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 'champion', title: 'Champion altars', blurb: 'Felucca-style altar spawns.' },
|
||||
{ id: 'mini', title: 'Mini champs', blurb: 'TerMur controllers — they re-arm on their own.' },
|
||||
{ id: 'sea', title: 'Sea bosses', blurb: 'High Seas world bosses, alive only while summoned.' },
|
||||
]
|
||||
|
||||
const STATUS_STYLE = {
|
||||
active: { bg: 'rgba(95,185,138,0.16)', fg: '#8fdcae', border: 'rgba(95,185,138,0.45)', label: 'Active' },
|
||||
cooldown: { bg: 'rgba(230,194,106,0.14)', fg: '#e6c26a', border: 'rgba(230,194,106,0.4)', label: 'Cooldown' },
|
||||
dormant: { bg: 'rgba(140,150,165,0.14)', fg: '#aab3c0', border: 'rgba(140,150,165,0.35)', label: 'Dormant' },
|
||||
}
|
||||
|
||||
// A short "in 4m" / "in 2h" for a future ISO timestamp (restartAt / expireAt).
|
||||
function until(iso) {
|
||||
if (!iso) return ''
|
||||
const ms = new Date(iso).getTime() - Date.now()
|
||||
if (!Number.isFinite(ms)) return ''
|
||||
if (ms <= 0) return 'due'
|
||||
const mins = Math.round(ms / 60000)
|
||||
if (mins < 60) return `in ${mins}m`
|
||||
const hrs = Math.round(mins / 60)
|
||||
return `in ${hrs}h`
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const s = STATUS_STYLE[status] || STATUS_STYLE.dormant
|
||||
return (
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
flex: 'none',
|
||||
fontSize: '0.68rem',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
padding: '3px 9px',
|
||||
borderRadius: 999,
|
||||
color: s.fg,
|
||||
background: s.bg,
|
||||
border: `1px solid ${s.border}`,
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// A slim progress bar (kills toward the next level, or a sea boss's hit points).
|
||||
function Meter({ value, max, tone = 'var(--accent)' }) {
|
||||
if (!max) return null
|
||||
const pct = Math.max(0, Math.min(100, (Number(value) / Number(max)) * 100))
|
||||
return (
|
||||
<div style={{ height: 6, borderRadius: 4, background: 'rgba(255,255,255,0.07)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: tone, borderRadius: 4 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Category-specific middle line + meter for one spawn.
|
||||
function ChampDetail({ s }) {
|
||||
const line = { display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.8rem', color: 'var(--muted)', marginTop: 8 }
|
||||
if (s.category === 'sea') {
|
||||
return (
|
||||
<>
|
||||
<div className="sans" style={line}>
|
||||
<span>{s.boss || s.type}</span>
|
||||
{s.hitsMax != null && <span>{Number(s.hits).toLocaleString()} / {Number(s.hitsMax).toLocaleString()} hp</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}><Meter value={s.hits} max={s.hitsMax} tone="#d9736f" /></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (s.category === 'mini') {
|
||||
return (
|
||||
<div className="sans" style={line}>
|
||||
<span>Level {s.level ?? 0}{s.maxLevel != null ? ` / ${s.maxLevel}` : ''}</span>
|
||||
<span>{s.status === 'active' ? 'Running' : 'Re-arming'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// champion
|
||||
let progress = ''
|
||||
if (s.status === 'cooldown') progress = until(s.restartAt) || 'restarting'
|
||||
else if (s.status === 'active') {
|
||||
progress = `${Number(s.kills || 0).toLocaleString()} / ${Number(s.maxKills || 0).toLocaleString()} kills`
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="sans" style={line}>
|
||||
<span>
|
||||
Level {s.level ?? 0}
|
||||
{s.bossUp && s.boss ? ` — ${s.boss}` : ''}
|
||||
</span>
|
||||
<span>{progress}</span>
|
||||
</div>
|
||||
{s.status === 'active' && (
|
||||
<div style={{ marginTop: 6 }}><Meter value={s.kills} max={s.maxKills} /></div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ChampCard({ s }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<strong className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{s.name || s.type || 'Spawn'}
|
||||
</strong>
|
||||
<StatusBadge status={s.status} />
|
||||
</div>
|
||||
<ChampDetail s={s} />
|
||||
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.74rem' }}>
|
||||
{s.map || '—'}{s.x != null ? ` (${s.x}, ${s.y})` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ChampSpawns() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.champs())
|
||||
const { events, connected } = useShardFeed({ filter: CHAMP_KINDS, max: 60 })
|
||||
|
||||
// Merge the initial snapshot with live deltas: seed a map by serial, then apply
|
||||
// buffered events oldest → newest (the buffer is newest-first) so live wins.
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const s of data || []) if (s && s.serial) map.set(s.serial, s)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (!ev || !ev.serial) continue
|
||||
if (ev.kind === 'champ.update') map.set(ev.serial, ev)
|
||||
else if (ev.kind === 'champ.remove') map.delete(ev.serial)
|
||||
}
|
||||
return [...map.values()]
|
||||
}, [data, events])
|
||||
|
||||
const byCategory = (id) =>
|
||||
board.filter((s) => (s.category || 'champion') === id).sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||
|
||||
const activeCount = board.filter((s) => s.status === 'active').length
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader eyebrow="Live" title="Champion spawns" lead="Every altar, mini-champ and sea boss across the shard, updating in real time." />
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the champion board right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>No champion spawns are being tracked right now.</p>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', marginTop: -12, marginBottom: 24 }}>
|
||||
{activeCount} active · {board.length} tracked
|
||||
</p>
|
||||
{SECTIONS.map((sec) => {
|
||||
const rows = byCategory(sec.id)
|
||||
if (rows.length === 0) return null
|
||||
return (
|
||||
<section key={sec.id} style={{ marginBottom: 28 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.1rem', color: 'var(--head)' }}>{sec.title}</h2>
|
||||
<p className="sans dim" style={{ margin: '2px 0 0', fontSize: '0.8rem' }}>{sec.blurb}</p>
|
||||
</div>
|
||||
<div className="grid-2" style={{ gap: 12 }}>
|
||||
{rows.map((s) => <ChampCard key={s.serial} s={s} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { crestFor } from '../../data/cityCrests.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The town-governor board (City Loyalty). Loaded from /public/shard/governors,
|
||||
// kept live by merging city.update deltas by city. Empty on shards without the
|
||||
// City Loyalty system. Each city card links to its term history (look-back).
|
||||
const GOV_KINDS = new Set(['city.update'])
|
||||
|
||||
const PHASE = {
|
||||
none: null,
|
||||
nominate: { label: 'Nominations open', color: '#7f8fd0' },
|
||||
vote: { label: 'Voting', color: '#e6c26a' },
|
||||
pending: { label: 'Result pending', color: '#c9a24b' },
|
||||
}
|
||||
|
||||
// A short "in 3d" / "in 5h" for a future ISO timestamp (autoPickAt).
|
||||
function until(iso) {
|
||||
if (!iso) return ''
|
||||
const ms = new Date(iso).getTime() - Date.now()
|
||||
if (!Number.isFinite(ms) || ms <= 0) return ''
|
||||
const mins = Math.round(ms / 60000)
|
||||
if (mins < 60) return `in ${mins}m`
|
||||
const hrs = Math.round(mins / 60)
|
||||
if (hrs < 24) return `in ${hrs}h`
|
||||
return `in ${Math.round(hrs / 24)}d`
|
||||
}
|
||||
|
||||
function fmtDate(ms) {
|
||||
if (ms == null) return ''
|
||||
return new Date(Number(ms)).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function CityCrest({ city, size = 44 }) {
|
||||
const c = crestFor(city)
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
flex: 'none', width: size, height: size, borderRadius: '50%',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.5, background: 'rgba(255,255,255,0.04)',
|
||||
border: `2px solid ${c.color}`, boxShadow: `0 0 10px ${c.color}22`,
|
||||
}}
|
||||
>
|
||||
{c.sigil}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsible term history for one city, fetched on demand from the ledger.
|
||||
function TermHistory({ city }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { loading, error, data } = useAsync(
|
||||
() => (open ? api.shard.governorHistory(city, 25) : Promise.resolve(null)),
|
||||
[open, city],
|
||||
)
|
||||
return (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0, fontSize: '0.76rem' }}
|
||||
>
|
||||
{open ? 'Hide past governors' : 'Past governors →'}
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{loading && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Loading…</p>}
|
||||
{error && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Could not load history.</p>}
|
||||
{data && data.length === 0 && (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>No recorded terms yet.</p>
|
||||
)}
|
||||
{data && data.length > 0 && (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
{data.map((t) => (
|
||||
<li key={`${t.startedAt}-${t.governor?.name ?? 'vacant'}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.8rem', color: 'var(--ink)' }}>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t.governor?.name || 'Vacant'}
|
||||
</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.72rem' }}>
|
||||
{fmtDate(t.startedAt)}{t.endedAt ? ` – ${fmtDate(t.endedAt)}` : ' – present'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CityCard({ c }) {
|
||||
const phase = PHASE[c.electionPhase] || null
|
||||
const gov = c.governor
|
||||
const candidatePlural = c.candidates === 1 ? '' : 's'
|
||||
return (
|
||||
<div className="panel" style={{ padding: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<CityCrest city={c.city} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<strong className="display" style={{ fontSize: '1.05rem', color: 'var(--head)' }}>
|
||||
{crestFor(c.city).label || c.city}
|
||||
</strong>
|
||||
{phase && (
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.66rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: phase.color, border: `1px solid ${phase.color}66`, borderRadius: 999, padding: '2px 8px' }}>
|
||||
{phase.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="sans" style={{ marginTop: 3, fontSize: '0.9rem', color: gov ? 'var(--ink)' : 'var(--muted)' }}>
|
||||
{gov ? (
|
||||
<>Governor <strong style={{ color: 'var(--head)' }}>{gov.name}</strong></>
|
||||
) : (
|
||||
'Seat vacant'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{c.electionPhase && c.electionPhase !== 'none' && (
|
||||
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.78rem' }}>
|
||||
{c.candidates ? `${c.candidates} candidate${candidatePlural}` : 'No candidates yet'}
|
||||
{c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TermHistory city={c.city} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Governors() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.governors())
|
||||
const { events, connected } = useShardFeed({ filter: GOV_KINDS, max: 30 })
|
||||
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const c of data || []) if (c && c.city) map.set(c.city, c)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (ev.kind === 'city.update' && ev.city) map.set(ev.city, ev)
|
||||
}
|
||||
return [...map.values()].sort((a, b) => (a.city || '').localeCompare(b.city || ''))
|
||||
}, [data, events])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader eyebrow="Live" title="Governors of Britannia" lead="Who rules each city, and where the next election stands." />
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the governor board right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>
|
||||
City Loyalty governance is not enabled on this shard.
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<div className="grid-2" style={{ gap: 12 }}>
|
||||
{board.map((c) => <CityCard key={c.city} c={c} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The guild board. Loaded once from /public/shard/guilds, then kept live by
|
||||
// merging guild.update / guild.remove deltas; guild.join drives a small "recently
|
||||
// joined" strip on top of the board.
|
||||
const GUILD_KINDS = new Set(['guild.update', 'guild.remove', 'guild.join'])
|
||||
|
||||
function Leader({ leader }) {
|
||||
if (!leader || !leader.name) return <span className="dim">—</span>
|
||||
return <span>{leader.name}</span>
|
||||
}
|
||||
|
||||
function GuildRow({ g }) {
|
||||
return (
|
||||
<div
|
||||
className="panel"
|
||||
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
|
||||
{g.abbr && (
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
flex: 'none',
|
||||
fontSize: '0.72rem',
|
||||
letterSpacing: '0.06em',
|
||||
color: 'var(--accent)',
|
||||
border: '1px solid rgba(201,162,75,0.4)',
|
||||
borderRadius: 5,
|
||||
padding: '1px 6px',
|
||||
}}
|
||||
>
|
||||
{g.abbr}
|
||||
</span>
|
||||
)}
|
||||
<strong
|
||||
className="display"
|
||||
style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{g.name || 'A guild'}
|
||||
</strong>
|
||||
</div>
|
||||
{g.alliance && (
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{g.alliance}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right', fontSize: '0.84rem', color: 'var(--ink)' }}>
|
||||
<div>
|
||||
<span style={{ color: '#7fd0a4' }}>{g.online ?? 0}</span>
|
||||
<span className="dim"> / {g.members ?? 0}</span>
|
||||
</div>
|
||||
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||
<Leader leader={g.leader} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Guilds() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.guilds())
|
||||
const { events, connected } = useShardFeed({ filter: GUILD_KINDS, max: 60 })
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
// Merge snapshot + live deltas by guild id (apply oldest → newest so live wins).
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const g of data || []) if (g && g.id != null) map.set(g.id, g)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (ev.kind === 'guild.update' && ev.id != null) map.set(ev.id, ev)
|
||||
else if (ev.kind === 'guild.remove' && ev.id != null) map.delete(ev.id)
|
||||
}
|
||||
return [...map.values()]
|
||||
}, [data, events])
|
||||
|
||||
// Recent joins strip (newest first, deduped, capped).
|
||||
const joins = useMemo(
|
||||
() => events.filter((e) => e.kind === 'guild.join' && e.who).slice(0, 6),
|
||||
[events],
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
const rows = needle
|
||||
? board.filter((g) =>
|
||||
[g.name, g.abbr, g.alliance].some((v) => v && v.toLowerCase().includes(needle)),
|
||||
)
|
||||
: board
|
||||
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||
}, [board, q])
|
||||
|
||||
const totalMembers = board.reduce((n, g) => n + (Number(g.members) || 0), 0)
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader eyebrow="Live" title="Guilds" lead="Every guild on the shard — rosters, alliances and who's online, updating in real time." />
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the guild board right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>No guilds are being tracked right now.</p>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
{joins.length > 0 && (
|
||||
<section className="panel" style={{ padding: '12px 16px', marginBottom: 18 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.66rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 8 }}>
|
||||
Recently joined
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
{joins.map((j) => (
|
||||
<div key={j._id} className="sans" style={{ fontSize: '0.84rem', color: 'var(--ink)' }}>
|
||||
<strong style={{ color: 'var(--head)' }}>{j.who.name}</strong>
|
||||
<span className="dim"> joined </span>
|
||||
{j.abbr ? `[${j.abbr}] ` : ''}{j.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
|
||||
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', margin: 0 }}>
|
||||
{board.length} guilds · {totalMembers.toLocaleString()} members
|
||||
</p>
|
||||
<input
|
||||
className="input sans"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search guilds…"
|
||||
style={{ flex: 'none', width: 190, maxWidth: '50%', fontSize: '0.84rem' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{filtered.map((g) => <GuildRow key={g.id} g={g} />)}
|
||||
</div>
|
||||
{filtered.length === 0 && (
|
||||
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No guilds match “{q}”.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// PUBLIC houses board: only houses in danger (IDOC), shown by location. Owner,
|
||||
// price, decay detail and the full registry are staff-only (admin Houses view).
|
||||
// Loaded from /public/shard/houses (IDOC-only), kept live by house.decay: a
|
||||
// house entering IDOC appears, one leaving it drops off.
|
||||
const HOUSE_KINDS = new Set(['house.decay'])
|
||||
|
||||
function HouseRow({ h }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#e05a5a', boxShadow: '0 0 8px rgba(224,90,90,0.7)' }}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{h.region || 'The wilderness'}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', letterSpacing: '0.06em', color: '#e05a5a', border: '1px solid #e05a5a66', borderRadius: 999, padding: '2px 9px' }}>
|
||||
IDOC
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Houses() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.houses())
|
||||
const { events, connected } = useShardFeed({ filter: HOUSE_KINDS, max: 60 })
|
||||
|
||||
// Merge the IDOC snapshot with live house.decay deltas by serial: entering IDOC
|
||||
// adds/updates the row; anything else (refreshed, collapsed) drops it.
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (ev.kind !== 'house.decay' || !ev.serial) continue
|
||||
if (String(ev.to).toUpperCase() === 'IDOC') {
|
||||
map.set(ev.serial, { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y, z: ev.z, isIdoc: true })
|
||||
} else {
|
||||
map.delete(ev.serial)
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort((a, b) => (a.region || '').localeCompare(b.region || ''))
|
||||
}, [data, events])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader eyebrow="Live" title="Houses in danger" lead="Homes that have fallen into IDOC — where to find them before they collapse." />
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the houses board right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>No houses are collapsing right now.</p>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<p className="sans" style={{ color: '#e0928a', fontSize: '0.8rem', marginTop: -12, marginBottom: 20 }}>
|
||||
{board.length} in danger
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{board.map((h) => <HouseRow key={h.serial} h={h} />)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
// Points / loyalty leaderboards (Protocol 3.0 §7). The shard carries ~25 separate
|
||||
// point currencies — Queen's Loyalty, Void Pool, Clean Up Britannia, the nine city
|
||||
// loyalties, the Doom/Khaldun/Kotl treasure systems — every one of them a standing
|
||||
// players build over months, and none of them visible anywhere but an in-game gump
|
||||
// until now.
|
||||
//
|
||||
// Loaded from /public/shard/points, then kept current from the live feed. Unlike
|
||||
// the ruleset (one frame = the whole thing), a points.board frame describes ONE
|
||||
// system, so live frames are merged over the fetched set by system key rather than
|
||||
// replacing it.
|
||||
const POINTS_KINDS = new Set(['points.board'])
|
||||
|
||||
// A board's display name may arrive as a literal (`nameString`), a cliloc id
|
||||
// (`nameNumber`), or both — Name is a ServUO TextDefinition. We have no cliloc
|
||||
// table on the site, so a cliloc-only board falls back to humanising its own
|
||||
// PointsType key, which is already close to a display name ("CleanUpBritannia" →
|
||||
// "Clean Up Britannia"). Better than showing a bare number.
|
||||
const humanise = (key) =>
|
||||
String(key || '')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
|
||||
const boardTitle = (b) => b.nameString || humanise(b.system)
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||
|
||||
// Merge live frames over the fetched boards. Newest frame per system wins; a
|
||||
// system that has never appeared in either is simply absent.
|
||||
function mergeBoards(fetched, events) {
|
||||
const bySystem = new Map()
|
||||
for (const b of Array.isArray(fetched) ? fetched : []) {
|
||||
if (b && b.system) bySystem.set(b.system, b)
|
||||
}
|
||||
// Events arrive newest-first, so walk backwards and let the newest land last.
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const ev = events[i]
|
||||
if (ev && ev.system) bySystem.set(ev.system, ev)
|
||||
}
|
||||
return [...bySystem.values()].sort((a, b) => boardTitle(a).localeCompare(boardTitle(b)))
|
||||
}
|
||||
|
||||
function Medal({ rank }) {
|
||||
// Gold / silver / bronze for the podium, plain for the rest.
|
||||
const tone = rank === 1 ? '#c9a24b' : rank === 2 ? '#b6bcc6' : rank === 3 ? '#b3805a' : 'var(--muted)'
|
||||
return (
|
||||
<span
|
||||
className="display"
|
||||
style={{
|
||||
flex: 'none', width: 26, textAlign: 'right', color: tone,
|
||||
fontSize: rank <= 3 ? '1rem' : '0.86rem',
|
||||
}}
|
||||
>
|
||||
{rank}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// One ranked player. `name` is absent rather than empty when an admin has gated
|
||||
// the leaderboards `name` field above this viewer's rung — the row still renders,
|
||||
// because the standing itself is the point.
|
||||
function Entry({ entry, best }) {
|
||||
const pct = best > 0 ? Math.max(2, Math.round((entry.points / best) * 100)) : 0
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||||
<Medal rank={entry.rank} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
color: entry.name ? 'var(--ink)' : 'var(--muted)',
|
||||
fontSize: '0.86rem', fontStyle: entry.name ? 'normal' : 'italic',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{entry.name || 'Name hidden'}
|
||||
</span>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
|
||||
{num(entry.points)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden', marginTop: 3 }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Board({ board }) {
|
||||
const { siteTitle } = useSite()
|
||||
const top = Array.isArray(board.top) ? board.top : []
|
||||
// Bars are relative to the board leader, not to maxPoints: most systems have no
|
||||
// cap (maxPoints 0), and where there is one the leader is often nowhere near it,
|
||||
// which would render every bar as a stub.
|
||||
const best = top.reduce((m, e) => Math.max(m, e.points || 0), 0)
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10 }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.02rem', color: 'var(--head)' }}>
|
||||
{boardTitle(board)}
|
||||
</h2>
|
||||
{Number.isFinite(board.players) && (
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem', flex: 'none' }}>
|
||||
{num(board.players)} ranked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{top.length === 0 ? (
|
||||
// A board nobody has scored on still gets a row, so the page reads as a set
|
||||
// of standings waiting to be filled rather than a stack of blanks. It is
|
||||
// deliberately NOT shaped like an Entry — no medal, no bar, an em dash where
|
||||
// a score goes — because a placeholder that looked like a real standing would
|
||||
// be a fabricated one. The first real entry replaces it.
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10, padding: '6px 0' }}>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
color: 'var(--muted)', fontSize: '0.86rem',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{siteTitle}
|
||||
</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.82rem', flex: 'none' }}>—</span>
|
||||
</div>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
|
||||
Nobody has earned points here yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{top.map((entry) => (
|
||||
<Entry key={`${board.system}-${entry.rank}-${entry.serial}`} entry={entry} best={best} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Number.isFinite(board.maxPoints) && board.maxPoints > 0 && (
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||
Maximum {num(board.maxPoints)} points
|
||||
</span>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Leaderboards() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.points())
|
||||
// Buffer generously: a single sweep can emit a frame for every system at once,
|
||||
// and a board dropped from the buffer would silently revert to its fetched copy.
|
||||
const { events, connected } = useShardFeed({ filter: POINTS_KINDS, max: 60 })
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const boards = useMemo(() => mergeBoards(data, events), [data, events])
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return boards
|
||||
// Match the board name, the raw system key, or any ranked player on it — the
|
||||
// last is what makes the filter useful ("where do I appear?").
|
||||
return boards.filter(
|
||||
(b) =>
|
||||
boardTitle(b).toLowerCase().includes(q) ||
|
||||
String(b.system).toLowerCase().includes(q) ||
|
||||
(b.top || []).some((e) => e.name && e.name.toLowerCase().includes(q)),
|
||||
)
|
||||
}, [boards, query])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader
|
||||
eyebrow="Live"
|
||||
title="Leaderboards"
|
||||
lead="Loyalty and points standings, straight from the shard — every currency the server tracks, updated as players climb."
|
||||
/>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem',
|
||||
color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the leaderboards right now." />}
|
||||
|
||||
{!loading && !error && boards.length === 0 && (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>
|
||||
The shard has not published any leaderboards yet.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!loading && !error && boards.length > 0 && (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Filter by board or player name…"
|
||||
aria-label="Filter leaderboards"
|
||||
style={{ maxWidth: 340, marginBottom: 14 }}
|
||||
/>
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<p className="sans dim">No board or ranked player matches “{query}”.</p>
|
||||
) : (
|
||||
<div className="grid-2" style={{ gap: 12, alignItems: 'start' }}>
|
||||
{shown.map((board) => (
|
||||
<Board key={board.system} board={board} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// ── The player-vendor marketplace ───────────────────────────────────────────
|
||||
//
|
||||
// What every player vendor on the shard is selling, for how much, and where it
|
||||
// is standing — the same index the in-game Vendor Search gump reads, honouring
|
||||
// the same per-vendor opt-out, reachable without logging in to the game.
|
||||
//
|
||||
// Three things this page must be honest about, all of them consequences of how
|
||||
// the data is gathered (docs/link/v3.md §8):
|
||||
//
|
||||
// • **The prices are not live.** The shard sweeps vendors round-robin, so a
|
||||
// shop can be a full cycle behind. The banner says how far, from `staleAt`.
|
||||
// A page that implied live prices would send people across the world to a
|
||||
// vendor whose item sold twenty minutes ago.
|
||||
// • **A shop can be truncated.** A commodity reseller with thousands of stacks
|
||||
// publishes only the first N, and saying so beats presenting a partial shop
|
||||
// as complete.
|
||||
// • **An item may have no name.** On a shard whose operator has not converted
|
||||
// a cliloc table, `displayName` is null and the honest render is the item id
|
||||
// — not an invented name.
|
||||
//
|
||||
// There is deliberately no live feed here. The market feature's SSE stream ships
|
||||
// disabled: a firehose of whole vendor inventories would be the site's single
|
||||
// biggest bandwidth consumer, and nothing on this page needs it.
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
|
||||
|
||||
const SORTS = [
|
||||
{ key: 'price_asc', label: 'Cheapest' },
|
||||
{ key: 'price_desc', label: 'Priciest' },
|
||||
{ key: 'recent', label: 'Recently seen' },
|
||||
]
|
||||
|
||||
// How old the index may be, in words. `staleAt` is the OLDEST vendor row, so
|
||||
// this is a worst case rather than an average — which is the number worth
|
||||
// showing, because the one stale shop is the one that wastes a trip.
|
||||
function staleness(staleAt) {
|
||||
if (!staleAt) return null
|
||||
const ms = Date.now() - new Date(staleAt).getTime()
|
||||
if (!Number.isFinite(ms) || ms < 0) return null
|
||||
const mins = Math.round(ms / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`
|
||||
const hours = Math.round(mins / 60)
|
||||
if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
|
||||
return `${Math.round(hours / 24)} days ago`
|
||||
}
|
||||
|
||||
// The item's name, or an honest statement that we do not have one. Never a
|
||||
// fabricated label — "Item 3922" would be indistinguishable from a real name.
|
||||
const itemLabel = (l) => l.displayName || l.name || `id ${l.itemId}`
|
||||
|
||||
function Chip({ active, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
padding: '5px 12px',
|
||||
borderRadius: 999,
|
||||
cursor: 'pointer',
|
||||
color: active ? 'var(--bg-deep)' : 'var(--muted)',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ListingRow({ listing }) {
|
||||
const v = listing.vendor || {}
|
||||
// `location` is one field the admin can gate away wholesale, so everything
|
||||
// that reads from it has to tolerate its absence rather than assuming a map.
|
||||
const loc = v.location || null
|
||||
const where = loc ? [loc.region, loc.map].filter(Boolean).join(', ') : null
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
className="display"
|
||||
style={{ fontSize: '0.98rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{listing.amount > 1 ? `${num(listing.amount)} × ` : ''}
|
||||
{itemLabel(listing)}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||
{v.serial ? (
|
||||
<Link to={`/site/market/vendors/${encodeURIComponent(v.serial)}`} style={{ color: 'inherit' }}>
|
||||
{v.shopName || 'an unnamed shop'}
|
||||
</Link>
|
||||
) : (
|
||||
v.shopName || 'an unnamed shop'
|
||||
)}
|
||||
{v.ownerName ? ` · ${v.ownerName}` : ''}
|
||||
{where ? ` · ${where}` : ''}
|
||||
{/* Priced by the container it sits in, exactly as the in-game search
|
||||
reports it — the price buys the whole container, not this item. */}
|
||||
{listing.child ? ' · sold with its container' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(listing.price)}</div>
|
||||
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>gold</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Market() {
|
||||
const [input, setInput] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [map, setMap] = useState('')
|
||||
const [region, setRegion] = useState('')
|
||||
const [sort, setSort] = useState('price_asc')
|
||||
const [minPrice, setMinPrice] = useState('')
|
||||
const [maxPrice, setMaxPrice] = useState('')
|
||||
// Applied prices are separate from the typed ones so the search fires when the
|
||||
// user is done, not on every digit of "250000".
|
||||
const [prices, setPrices] = useState({ min: '', max: '' })
|
||||
|
||||
const [state, setState] = useState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
|
||||
const [more, setMore] = useState(false)
|
||||
|
||||
const meta = useAsync(() => api.shard.marketMeta())
|
||||
|
||||
// Debounced: typing "vanquishing" should be one request, not eleven — and the
|
||||
// endpoint is rate-limited, so an undebounced box would 429 a fast typist.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setQ(input.trim()), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [input])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setPrices({ min: minPrice, max: maxPrice }), 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [minPrice, maxPrice])
|
||||
|
||||
const load = useCallback(
|
||||
(offset) =>
|
||||
api.shard.market({
|
||||
q,
|
||||
map,
|
||||
region,
|
||||
sort,
|
||||
minPrice: prices.min,
|
||||
maxPrice: prices.max,
|
||||
limit: PAGE,
|
||||
offset,
|
||||
}),
|
||||
[q, map, region, sort, prices],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
|
||||
load(0)
|
||||
.then((page) => {
|
||||
if (!alive) return
|
||||
setState({
|
||||
loading: false,
|
||||
error: null,
|
||||
listings: page.listings || [],
|
||||
total: page.total || 0,
|
||||
staleAt: page.staleAt || null,
|
||||
})
|
||||
})
|
||||
.catch((error) => alive && setState({ loading: false, error, listings: [], total: 0, staleAt: null }))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const loadMore = async () => {
|
||||
setMore(true)
|
||||
try {
|
||||
const page = await load(state.listings.length)
|
||||
setState((s) => ({
|
||||
...s,
|
||||
listings: [...s.listings, ...(page.listings || [])],
|
||||
total: page.total ?? s.total,
|
||||
staleAt: page.staleAt ?? s.staleAt,
|
||||
}))
|
||||
} catch {
|
||||
// A failed "load more" leaves what is on screen alone; the button stays
|
||||
// available to retry.
|
||||
} finally {
|
||||
setMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
const maps = meta.data?.maps || []
|
||||
const regions = meta.data?.regions || []
|
||||
const age = staleness(state.staleAt)
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader
|
||||
eyebrow="Marketplace"
|
||||
title="Player vendors"
|
||||
lead="Every shop on the shard, searchable from here — the same index the in-game vendor search reads, and it honours the same per-vendor opt-out."
|
||||
/>
|
||||
|
||||
{/* Not decoration. The sweep is round-robin, so the index is inherently
|
||||
up to one full cycle old and the page has to say so. */}
|
||||
{age && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
|
||||
Prices last refreshed {age}
|
||||
{meta.data?.vendors ? ` · ${num(meta.data.vendors)} shops` : ''}
|
||||
{meta.data?.items ? ` · ${num(meta.data.items)} listings` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Search listings…"
|
||||
style={{ width: '100%', marginBottom: 10 }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
value={minPrice}
|
||||
onChange={(e) => setMinPrice(e.target.value)}
|
||||
placeholder="Min price"
|
||||
style={{ maxWidth: 140 }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
value={maxPrice}
|
||||
onChange={(e) => setMaxPrice(e.target.value)}
|
||||
placeholder="Max price"
|
||||
style={{ maxWidth: 140 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
{SORTS.map((s) => (
|
||||
<Chip key={s.key} active={sort === s.key} onClick={() => setSort(s.key)}>
|
||||
{s.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Facet and region names come from the shard's own data, never a list in
|
||||
this file — a shard running custom maps gets its own names here with
|
||||
no code change (docs/link/v3.md §6.1 R2). */}
|
||||
{maps.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
<Chip active={map === ''} onClick={() => setMap('')}>All facets</Chip>
|
||||
{maps.map((m) => (
|
||||
<Chip key={m} active={map === m} onClick={() => setMap(m)}>{m}</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{regions.length > 0 && (
|
||||
<select
|
||||
className="input"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
style={{ width: '100%', marginBottom: 18 }}
|
||||
>
|
||||
<option value="">Anywhere</option>
|
||||
{regions.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{state.loading && <Loading />}
|
||||
{state.error && <ErrorState message="Could not load the marketplace right now." />}
|
||||
|
||||
{!state.loading && !state.error && state.listings.length === 0 && (
|
||||
<EmptyState>
|
||||
{meta.data?.vendors
|
||||
? 'Nothing on the shard matches that.'
|
||||
: 'No player vendors have been indexed yet.'}
|
||||
</EmptyState>
|
||||
)}
|
||||
|
||||
{!state.loading && !state.error && state.listings.length > 0 && (
|
||||
<>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
||||
Showing {num(state.listings.length)} of {num(state.total)}
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{state.listings.map((l) => (
|
||||
<ListingRow key={`${l.vendor?.serial}:${l.serial}`} listing={l} />
|
||||
))}
|
||||
</div>
|
||||
{state.listings.length < state.total && (
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<button type="button" className="btn" onClick={loadMore} disabled={more}>
|
||||
{more ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// One player vendor: where to find it and everything it is selling.
|
||||
//
|
||||
// The page a search result points at. Two states it has to render honestly and
|
||||
// which the search list cannot (docs/link/v3.md §8):
|
||||
//
|
||||
// • `truncated` — the shop holds more than the shard publishes per frame. A
|
||||
// commodity reseller with thousands of stacks is a real thing, and showing
|
||||
// 250 of 3,104 as if it were the whole shop would be a lie about the shard.
|
||||
// • a gated `location` — an admin may put vendor whereabouts behind a rung, in
|
||||
// which case there is nothing to render and the page says so rather than
|
||||
// showing an empty coordinate.
|
||||
|
||||
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
|
||||
|
||||
const itemLabel = (i) => i.displayName || i.name || `id ${i.itemId}`
|
||||
|
||||
export default function MarketVendor() {
|
||||
const { serial } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.shard.marketVendor(serial), [serial])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body"><Loading /></div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<ErrorState message="That shop is not in the index — it may have been dismissed or hidden." />
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<Link to="/site/market" className="sans">← Back to the marketplace</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const loc = data.location || null
|
||||
const items = data.items || []
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader
|
||||
eyebrow={data.ownerName ? `Run by ${data.ownerName}` : 'Player vendor'}
|
||||
title={data.shopName || 'An unnamed shop'}
|
||||
lead={
|
||||
loc
|
||||
? [loc.house, loc.region, loc.map].filter(Boolean).join(' · ') +
|
||||
(Number.isFinite(loc.x) ? ` — ${loc.x}, ${loc.y}` : '')
|
||||
: 'This shard does not publish vendor locations.'
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '-12px 0 18px' }}>
|
||||
{data.truncated
|
||||
? `Showing ${num(data.count)} of ${num(data.total)} listings — this shop holds more than the shard publishes.`
|
||||
: `${num(data.total)} listing${data.total === 1 ? '' : 's'}`}
|
||||
{data.updatedAt ? ` · last seen ${new Date(data.updatedAt).toLocaleString()}` : ''}
|
||||
</p>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<EmptyState>This shop has nothing priced for sale.</EmptyState>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{items.map((i) => (
|
||||
<div
|
||||
key={i.serial}
|
||||
className="panel"
|
||||
style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}
|
||||
>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>
|
||||
{i.amount > 1 ? `${num(i.amount)} × ` : ''}
|
||||
{itemLabel(i)}
|
||||
{i.child ? <span className="dim"> · sold with its container</span> : null}
|
||||
</span>
|
||||
<span className="sans" style={{ flex: 'none', color: 'var(--head)', fontSize: '0.88rem' }}>
|
||||
{num(i.price)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ marginTop: 20 }}>
|
||||
<Link to="/site/market" className="sans">← Back to the marketplace</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The shard ruleset. Loaded from /public/shard/ruleset, replaced wholesale by any
|
||||
// world.ruleset frame on the live feed (the shard re-emits the entire ruleset, so
|
||||
// there is nothing to merge — latest wins).
|
||||
//
|
||||
// Everything on this page is published BY THE SHARD from its own Config/*.cfg, so
|
||||
// it cannot drift the way a hand-written rules page does. That is the whole point
|
||||
// of the feature, and the page says so.
|
||||
const RULESET_KINDS = new Set(['world.ruleset'])
|
||||
|
||||
// Skill and stat caps arrive in tenths, the way ServUO stores them: 1000 is 100.0
|
||||
// skill. Showing the raw number would be actively misleading.
|
||||
const tenths = (v) => (Number.isFinite(v) ? (v / 10).toFixed(1) : null)
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : null)
|
||||
|
||||
const pct = (v) => (Number.isFinite(v) ? `${v}%` : null)
|
||||
|
||||
// The systems block is a flat bag of booleans; these are their display names, and
|
||||
// the order here is the order they render. A key the shard sends that we don't
|
||||
// know about still renders, humanised, rather than being silently dropped — a new
|
||||
// plugin must not go invisible against an older client.
|
||||
const SYSTEM_LABELS = {
|
||||
cityLoyalty: 'City Loyalty (governors)',
|
||||
vvv: 'Vice vs Virtue',
|
||||
factions: 'Factions',
|
||||
siege: 'Siege ruleset',
|
||||
chat: 'In-game chat',
|
||||
store: 'Ultima Store',
|
||||
dailyRares: 'Daily rares',
|
||||
honesty: 'Honesty virtue',
|
||||
shadowguard: 'Shadowguard',
|
||||
treasureMaps: 'Treasure maps',
|
||||
vetRewards: 'Veteran rewards',
|
||||
testCenter: 'Test Center',
|
||||
}
|
||||
|
||||
const humanise = (key) =>
|
||||
key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())
|
||||
|
||||
function Panel({ title, children }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: 18 }}>
|
||||
<h2
|
||||
className="display"
|
||||
style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// A label/value row. Rows whose value is null are dropped by the caller, so a
|
||||
// block never renders a dangling label for something the shard didn't publish.
|
||||
function Row({ label, value }) {
|
||||
return (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '5px 0',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
<span className="dim" style={{ minWidth: 0 }}>{label}</span>
|
||||
<strong style={{ flex: 'none', color: 'var(--head)' }}>{value}</strong>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Rows({ items }) {
|
||||
const rows = items.filter(([, value]) => value !== null && value !== undefined)
|
||||
if (rows.length === 0) return null
|
||||
return (
|
||||
<div>
|
||||
{rows.map(([label, value]) => (
|
||||
<Row key={label} label={label} value={value} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SystemPill({ label, on }) {
|
||||
const color = on ? '#8fdcae' : 'var(--muted)'
|
||||
return (
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 7,
|
||||
fontSize: '0.8rem',
|
||||
padding: '5px 11px',
|
||||
borderRadius: 999,
|
||||
color,
|
||||
background: on ? 'rgba(95,185,138,0.12)' : 'rgba(140,150,165,0.1)',
|
||||
border: `1px solid ${on ? 'rgba(95,185,138,0.4)' : 'var(--line)'}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{ width: 7, height: 7, borderRadius: '50%', background: color, flex: 'none' }}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Systems({ systems }) {
|
||||
// Known keys first in their declared order, then anything the shard added that
|
||||
// this build doesn't know about.
|
||||
const known = Object.keys(SYSTEM_LABELS).filter((k) => k in systems)
|
||||
const extra = Object.keys(systems).filter((k) => !(k in SYSTEM_LABELS))
|
||||
const keys = [...known, ...extra]
|
||||
if (keys.length === 0) return null
|
||||
return (
|
||||
<Panel title="Systems">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{keys.map((k) => (
|
||||
<SystemPill key={k} label={SYSTEM_LABELS[k] || humanise(k)} on={!!systems[k]} />
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Caps({ caps }) {
|
||||
return (
|
||||
<Panel title="Skill & stat caps">
|
||||
<Rows
|
||||
items={[
|
||||
['Individual skill cap', tenths(caps.skill)],
|
||||
['Total skill cap', tenths(caps.totalSkill)],
|
||||
['Total stat cap', num(caps.stat)],
|
||||
['Strength cap', num(caps.str)],
|
||||
['Dexterity cap', num(caps.dex)],
|
||||
['Intelligence cap', num(caps.int)],
|
||||
['Strength max', num(caps.strMax)],
|
||||
['Dexterity max', num(caps.dexMax)],
|
||||
['Intelligence max', num(caps.intMax)],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountsAndHousing({ accounts, housing, vetRewards }) {
|
||||
const items = []
|
||||
if (accounts) {
|
||||
items.push(['Accounts per IP', num(accounts.perIp)])
|
||||
items.push(['Character slots', num(accounts.charSlots)])
|
||||
items.push([
|
||||
'In-game account creation',
|
||||
accounts.autoCreate === undefined ? null : accounts.autoCreate ? 'Enabled' : 'Website only',
|
||||
])
|
||||
}
|
||||
if (housing) items.push(['Houses per account', num(housing.accountHouseLimit)])
|
||||
if (vetRewards?.enabled) {
|
||||
items.push(['Veteran reward interval', vetRewards.rewardIntervalDays
|
||||
? `${vetRewards.rewardIntervalDays} days`
|
||||
: null])
|
||||
}
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<Panel title="Accounts & housing">
|
||||
<Rows items={items} />
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Champions({ champions }) {
|
||||
const t = champions.rankThresholds
|
||||
return (
|
||||
<Panel title="Champion spawns">
|
||||
<Rows
|
||||
items={[
|
||||
['Power scrolls per spawn', num(champions.powerScrolls)],
|
||||
['Stat scrolls per spawn', num(champions.statScrolls)],
|
||||
['Scroll drop chance', pct(champions.scrollChance)],
|
||||
['Transcendence chance', pct(champions.transcendenceChance)],
|
||||
[
|
||||
'Red skulls per rank',
|
||||
Array.isArray(t) && t.length > 0 ? t.join(' · ') : null,
|
||||
],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Felucca({ loot }) {
|
||||
return (
|
||||
<Panel title="Felucca bonuses">
|
||||
<Rows
|
||||
items={[
|
||||
['Luck bonus', num(loot.feluccaLuckBonus)],
|
||||
['Loot budget bonus', num(loot.feluccaBudgetBonus)],
|
||||
['Max item properties', num(loot.feluccaMaxProps)],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Vendors({ vendors }) {
|
||||
return (
|
||||
<Panel title="Vendors">
|
||||
<Rows
|
||||
items={[
|
||||
['Restock delay', vendors.restockDelayMinutes
|
||||
? `${vendors.restockDelayMinutes} min`
|
||||
: null],
|
||||
['Max items sold at once', num(vendors.maxSell)],
|
||||
['Economy stock amount', num(vendors.economyStockAmount)],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Pvp({ vvv }) {
|
||||
return (
|
||||
<Panel title="Vice vs Virtue">
|
||||
<Rows
|
||||
items={[
|
||||
['Starting silver', num(vvv.startSilver)],
|
||||
['Enhanced rules', vvv.enhancedRules === undefined
|
||||
? null
|
||||
: vvv.enhancedRules ? 'On' : 'Off'],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function Schedule({ schedule }) {
|
||||
const items = []
|
||||
if (schedule.autoSaveEnabled && schedule.autoSaveFrequencyMinutes) {
|
||||
items.push(['World save', `every ${schedule.autoSaveFrequencyMinutes} min`])
|
||||
} else if (schedule.autoSaveEnabled === false) {
|
||||
items.push(['World save', 'Disabled'])
|
||||
}
|
||||
if (schedule.autoRestartEnabled) {
|
||||
const h = String(schedule.autoRestartHour ?? 0).padStart(2, '0')
|
||||
const m = String(schedule.autoRestartMinute ?? 0).padStart(2, '0')
|
||||
items.push(['Automatic restart', `${h}:${m} server time`])
|
||||
if (schedule.autoRestartFrequencyHours) {
|
||||
items.push(['Restart interval', `every ${schedule.autoRestartFrequencyHours}h`])
|
||||
}
|
||||
}
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<Panel title="Save & restart schedule">
|
||||
<Rows items={items} />
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Rules() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.ruleset())
|
||||
const { events, connected } = useShardFeed({ filter: RULESET_KINDS, max: 4 })
|
||||
|
||||
// The newest world.ruleset on the feed wins outright over the fetched copy —
|
||||
// the frame is a complete ruleset, not a delta.
|
||||
const ruleset = useMemo(() => events[0] || data || null, [data, events])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader
|
||||
eyebrow="Live"
|
||||
title="Shard ruleset"
|
||||
lead="Published by the server itself, straight from its configuration — so it cannot drift from how the shard actually plays."
|
||||
/>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem',
|
||||
color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the shard ruleset right now." />}
|
||||
|
||||
{!loading && !error && !ruleset && (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>
|
||||
The shard has not published its ruleset yet.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!loading && !error && ruleset && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Panel title="Shard">
|
||||
<Rows
|
||||
items={[
|
||||
['Name', ruleset.shard || null],
|
||||
['Expansion', ruleset.expansion || null],
|
||||
['Connect', ruleset.connect || null],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
{ruleset.systems && <Systems systems={ruleset.systems} />}
|
||||
{ruleset.caps && <Caps caps={ruleset.caps} />}
|
||||
<AccountsAndHousing
|
||||
accounts={ruleset.accounts}
|
||||
housing={ruleset.housing}
|
||||
vetRewards={ruleset.vetRewards}
|
||||
/>
|
||||
{ruleset.champions && <Champions champions={ruleset.champions} />}
|
||||
{ruleset.loot && <Felucca loot={ruleset.loot} />}
|
||||
{ruleset.vendors && <Vendors vendors={ruleset.vendors} />}
|
||||
{ruleset.vvv?.enabled && <Pvp vvv={ruleset.vvv} />}
|
||||
{ruleset.schedule && <Schedule schedule={ruleset.schedule} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export default function Screenshots() {
|
||||
<PageHeader
|
||||
eyebrow="Gallery"
|
||||
title="Gameplay Pictures"
|
||||
lead="Glimpses of towns, dungeons, events, and daily life on the shard."
|
||||
lead="Glimpses of the world, its events, and daily life on the server."
|
||||
/>
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the gallery right now." />}
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { describe } from '../../lib/shardEvents.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import PlayersOnline from '../../components/PlayersOnline.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
|
||||
// Flavor line under the online/offline banner: online, configured-but-down, or
|
||||
// not configured yet.
|
||||
function statusMessage(online, enabled) {
|
||||
if (online) return 'The gate to Britannia stands open.'
|
||||
if (enabled) return 'The link to the game world is down — checking back automatically.'
|
||||
return 'Live shard data is not configured yet.'
|
||||
}
|
||||
|
||||
// ── Gold-supply sparkline ───────────────────────────────────────────────────
|
||||
function Sparkline({ series }) {
|
||||
if (!series || series.length < 2) return null
|
||||
const w = 320
|
||||
const h = 56
|
||||
const golds = series.map((s) => Number(s.gold) || 0)
|
||||
const min = Math.min(...golds)
|
||||
const max = Math.max(...golds)
|
||||
const span = max - min || 1
|
||||
const pts = series
|
||||
.map((s, i) => {
|
||||
const x = (i / (series.length - 1)) * w
|
||||
const y = h - ((Number(s.gold) || 0) - min) / span * h
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
})
|
||||
.join(' ')
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} preserveAspectRatio="none" aria-hidden="true">
|
||||
<polyline points={pts} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Stat tile (matches Status.jsx) ──────────────────────────────────────────
|
||||
function Stat({ value, label }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
|
||||
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 6 }}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Shard() {
|
||||
const { loading, error, data } = useAsync(() =>
|
||||
Promise.all([
|
||||
api.shard.status(),
|
||||
api.shard.idoc(),
|
||||
api.shard.economy(60),
|
||||
api.shard.online(),
|
||||
]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
|
||||
)
|
||||
const { events, connected } = useShardFeed({ max: 30 })
|
||||
const { user } = useAuth()
|
||||
// Staff in-game location is privileged: only admins/moderators see it. Players
|
||||
// and the public see that staff are online but not where. The server enforces
|
||||
// this too (it omits the location fields entirely for non-privileged callers).
|
||||
const canSeeLocation = user?.role === 'admin' || user?.role === 'moderator'
|
||||
|
||||
const status = data?.status
|
||||
const online = status?.pluginConnected
|
||||
const gold = status?.economy?.gold
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader eyebrow="Live" title="Shard" />
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load shard data right now." />}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<ConnectionBanner online={online} status={status} />
|
||||
|
||||
{/* Stat tiles */}
|
||||
<section className="grid-2" style={{ gap: 14, marginBottom: 24 }}>
|
||||
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
|
||||
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
||||
</section>
|
||||
|
||||
{/* Live players-online breakdown (total + region buckets) */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<PlayersOnline />
|
||||
</div>
|
||||
|
||||
<StaffOnline list={data.online} canSeeLocation={canSeeLocation} />
|
||||
|
||||
{/* Economy sparkline */}
|
||||
{data.economy && data.economy.length > 1 && (
|
||||
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||
Gold supply over time
|
||||
</div>
|
||||
<Sparkline series={data.economy} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
{/* Latest IDOC */}
|
||||
<FeedList
|
||||
title="Houses in danger (IDOC)"
|
||||
empty="No houses are collapsing right now."
|
||||
items={data.idoc.map((h) => {
|
||||
const region = h.region ? ` — ${h.region}` : ''
|
||||
return {
|
||||
id: h.serial,
|
||||
text: `${h.name || 'A house'}${region}`,
|
||||
when: h.updatedAt,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Live ticker */}
|
||||
<section className="panel" style={{ padding: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
||||
Live feed
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<Link to="/site/shard/activity" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.78rem' }}>
|
||||
View all activity →
|
||||
</Link>
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||
Waiting for something to happen in the world…
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{events.map((ev) => (
|
||||
<li key={ev._id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(ev)}</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(ev.t)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Online/offline banner with the flavor line under it.
|
||||
function ConnectionBanner({ online, status }) {
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
padding: '24px 26px',
|
||||
border: `1px solid ${online ? 'rgba(95,185,138,0.45)' : '#5a4a2a'}`,
|
||||
borderRadius: 10,
|
||||
background: online
|
||||
? 'linear-gradient(180deg,rgba(22,46,34,0.5),rgba(16,26,20,0.4))'
|
||||
: 'linear-gradient(180deg,rgba(58,46,22,0.5),rgba(30,26,16,0.4))',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flex: 'none',
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
background: online ? 'var(--mode-live)' : 'var(--mode-maint)',
|
||||
boxShadow: `0 0 12px ${online ? 'rgba(95,185,138,0.7)' : 'rgba(230,194,106,0.7)'}`,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<strong className="display" style={{ display: 'block', fontSize: '1.2rem', color: online ? '#bfe6cf' : '#f0e3c4' }}>
|
||||
{online ? 'The shard is online' : 'The shard is offline'}
|
||||
</strong>
|
||||
<span className="sans" style={{ color: online ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
|
||||
{statusMessage(online, status?.enabled)}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Linked staff accounts currently online; in-game location is admin/mod-only.
|
||||
function StaffOnline({ list, canSeeLocation }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||
Staff online
|
||||
</div>
|
||||
{(!list || list.length === 0) ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No staff are online right now.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{list.map((p) => (
|
||||
<div key={p.serial} className="sans" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<span style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
|
||||
{p.name || p.serial}
|
||||
</span>
|
||||
{canSeeLocation && (
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>
|
||||
{p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedList({ title, items, empty }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: 20 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||
{title}
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>{empty}</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{items.map((it) => (
|
||||
<li key={it.id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.text}</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(it.when)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { describe, categoryOf, kindLabel, CATEGORIES } from '../../lib/shardEvents.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// Public activity feed: the full shard event log, filterable by category, with a
|
||||
// live tail that prepends new events as they happen.
|
||||
export default function ShardActivity() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.feed({ limit: 150 }))
|
||||
const { events: live } = useShardFeed({ max: 60 })
|
||||
const [cat, setCat] = useState('all')
|
||||
|
||||
// Merge the live tail with the loaded history, de-duped by kind+t, newest first.
|
||||
const merged = useMemo(() => {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const e of [...live, ...(data || [])]) {
|
||||
const key = `${e.kind}-${e.t}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
out.push(e)
|
||||
}
|
||||
return out.sort((a, b) => (b.t || 0) - (a.t || 0))
|
||||
}, [live, data])
|
||||
|
||||
const filtered = cat === 'all' ? merged : merged.filter((e) => categoryOf(e.kind) === cat)
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader eyebrow="Live" title="Shard Activity" />
|
||||
<p style={{ marginTop: -8, marginBottom: 18 }}>
|
||||
<Link to="/site/shard" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>← Back to shard</Link>
|
||||
</p>
|
||||
|
||||
{/* Category tabs */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 18 }}>
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setCat(c.id)}
|
||||
className="pill"
|
||||
style={cat === c.id ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : undefined}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the activity feed right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
filtered.length === 0 ? (
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.9rem' }}>Nothing here yet — events will appear as they happen in the world.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{filtered.map((e) => (
|
||||
<li key={e._id || `${e.kind}-${e.t}`} className="panel" style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>
|
||||
{kindLabel(e.kind)}
|
||||
</span>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', fontSize: '0.92rem' }}>{describe(e)}</span>
|
||||
<span className="sans dim" style={{ flex: 'none', fontSize: '0.76rem' }}>{ago(e.t)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export default function Status() {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader eyebrow="Live" title="Shard Status" />
|
||||
<PageHeader eyebrow="Live" title="Site Status" />
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load status right now." />}
|
||||
@@ -58,7 +58,7 @@ export default function Status() {
|
||||
{isLive ? 'Live — the gates are open' : 'Maintenance — building in progress'}
|
||||
</strong>
|
||||
<span style={{ color: isLive ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
|
||||
{statusMessage || (isLive ? 'The shard is online.' : 'The gates are closed while we shape the world. Public login is not open yet.')}
|
||||
{statusMessage || (isLive ? 'The site is open.' : 'The gates are closed while we shape the world. Public login is not open yet.')}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -4,12 +4,12 @@ import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
const CARDS = [
|
||||
{ kicker: 'Gallery', title: 'Gameplay Pictures', body: 'Screenshots from towns, dungeons, events, and daily life on the shard.', to: '/site/screenshots' },
|
||||
{ kicker: 'Updates', title: 'Development News', body: 'Progress notes, shard milestones, and public announcements.', to: '/site/news' },
|
||||
{ kicker: 'Gallery', title: 'Gameplay Pictures', body: 'Screenshots of the world, its events, and daily life on the server.', to: '/site/screenshots' },
|
||||
{ kicker: 'Updates', title: 'Development News', body: 'Progress notes, project milestones, and public announcements.', to: '/site/news' },
|
||||
{ kicker: 'Community', title: 'Five on Friday', body: 'Weekly questions, small previews, and notes from the team.', to: '/site/five-on-friday' },
|
||||
{ kicker: 'Long-form', title: 'Monthly Newsletter', body: 'Fuller summaries for players who want the whole picture.', to: '/site/newsletter' },
|
||||
{ kicker: 'Reference', title: 'Wiki', body: 'Guides and reference pages for the game world.', to: '/wiki' },
|
||||
{ kicker: 'Live', title: 'Shard Status', body: 'Launch state, test windows, and known issues.', to: '/site/status' },
|
||||
{ kicker: 'Live', title: 'Site Status', body: 'Launch state, test windows, and known issues.', to: '/site/status' },
|
||||
]
|
||||
|
||||
export default function Website() {
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function Wiki() {
|
||||
center
|
||||
eyebrow="Knowledge base"
|
||||
title={`${siteShortName} Wiki`}
|
||||
lead="A calm starting point for shard guides, the world and its lore, gameplay systems, and community rules."
|
||||
lead="A calm starting point for guides, the world and its lore, gameplay systems, and community rules."
|
||||
/>
|
||||
<SearchBox initial={activeQ || ''} onSubmit={runSearch} />
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { navItemVisibleTo, allowedPathsFor, isAllowedPath } from '../src/lib/adminNav.js'
|
||||
import { navItemVisibleTo, allowedPathsFor, isAllowedPath, firstDestinationFor } from '../src/lib/adminNav.js'
|
||||
|
||||
// Moderator confinement, derived from each row's `roles` (Phase 2 PR 8 —
|
||||
// MODULE_SYSTEM.md §1.4). This replaced two hardcoded path lists that had
|
||||
@@ -123,3 +123,50 @@ test('a nav that is not there does not throw', () => {
|
||||
assert.deepEqual(allowedPathsFor(null, 'moderator'), [])
|
||||
assert.equal(isAllowedPath('/admin', undefined), false)
|
||||
})
|
||||
|
||||
// ── firstDestinationFor: where an area's index goes (Phase 3 slice 3) ────────
|
||||
//
|
||||
// `/player` used to be `PlayerCharacters`, a UO page. When the client half was
|
||||
// extracted the portal had no index at all, and rather than pick a fixed page or
|
||||
// invent a core landing screen, the index resolves to the first row this viewer
|
||||
// can reach. The interesting properties are that it follows the ROLE and that it
|
||||
// reads the base nav, not the merged one.
|
||||
|
||||
const PORTAL_NAV = [
|
||||
{ to: '/account/appeals', label: 'Appeals' },
|
||||
{ to: '/account', label: 'Account', end: true },
|
||||
]
|
||||
|
||||
test('the index is the first row a viewer can actually reach', () => {
|
||||
assert.equal(firstDestinationFor(PORTAL_NAV, 'player', '/account'), '/account/appeals')
|
||||
})
|
||||
|
||||
test('a module row registered at the front becomes the index', () => {
|
||||
// The behaviour that makes this a non-regression: with module-uo installed,
|
||||
// Characters is the first row again and a player still lands on it.
|
||||
const withModule = [{ to: '/player/uo/characters', label: 'Characters', moduleId: 'uo' }, ...PORTAL_NAV]
|
||||
assert.equal(firstDestinationFor(withModule, 'player', '/account'), '/player/uo/characters')
|
||||
})
|
||||
|
||||
test('a row this role cannot see is skipped, not landed on', () => {
|
||||
const gated = [{ to: '/player/uo/staff', label: 'Staff', roles: ['admin'] }, ...PORTAL_NAV]
|
||||
assert.equal(firstDestinationFor(gated, 'player', '/account'), '/account/appeals')
|
||||
assert.equal(firstDestinationFor(gated, 'admin', '/account'), '/player/uo/staff')
|
||||
})
|
||||
|
||||
test('an empty or all-gated nav falls back rather than resolving to nothing', () => {
|
||||
assert.equal(firstDestinationFor([], 'player', '/account'), '/account')
|
||||
assert.equal(firstDestinationFor(null, 'player', '/account'), '/account')
|
||||
const allGated = [{ to: '/x', label: 'X', roles: ['admin'] }]
|
||||
assert.equal(firstDestinationFor(allGated, 'player', '/account'), '/account')
|
||||
})
|
||||
|
||||
test('it reads the grouped admin nav too, flattening in order', () => {
|
||||
// Same function for both areas, which is the point: the admin index is a
|
||||
// hardcoded Dashboard today, and if the two logged-in areas ever merge this is
|
||||
// what answers for the result.
|
||||
assert.equal(firstDestinationFor(NAV, 'moderator', '/admin/account'), '/admin')
|
||||
// An editor cannot see Dashboard's neighbours in Moderation, so they land on
|
||||
// the first row they can see wherever it is.
|
||||
assert.equal(firstDestinationFor(NAV, 'editor', '/admin/account'), '/admin')
|
||||
})
|
||||
|
||||
@@ -128,10 +128,10 @@ test('wiki() with no options sends no query string at all', async () => {
|
||||
assert.equal(calls[0].url, '/api/v1/public/wiki')
|
||||
})
|
||||
|
||||
test('path params are URL-encoded (a token/city with unsafe characters is escaped)', async () => {
|
||||
test('path params are URL-encoded (a token with unsafe characters is escaped)', async () => {
|
||||
willReply({ body: {} })
|
||||
await api.shard.governorHistory('Serpent’s Hold', 5)
|
||||
assert.match(calls[0].url, /\/governors\/Serpent%E2%80%99s%20Hold\/history\?limit=5/)
|
||||
await api.getInvite('a b/c?d')
|
||||
assert.equal(calls[0].url, '/api/v1/auth/invite/a%20b%2Fc%3Fd')
|
||||
})
|
||||
|
||||
test('DELETE self-service session revoke encodes the id and uses the DELETE method', async () => {
|
||||
@@ -141,43 +141,47 @@ test('DELETE self-service session revoke encodes the id and uses the DELETE meth
|
||||
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
|
||||
})
|
||||
|
||||
// ── spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────
|
||||
// The atlas lives at /public/atlas, NOT under /public/shard: it is static shard
|
||||
// content parsed from the shard's own files, so it must not look sidecar-backed.
|
||||
// Asserted here because the split is a design decision, not an accident of
|
||||
// spelling.
|
||||
test('atlas reads hit /public/atlas, not /public/shard', async () => {
|
||||
willReply({ body: { creatures: [] } })
|
||||
await api.atlas.creatures()
|
||||
assert.equal(calls[0].url, '/api/v1/public/atlas/creatures')
|
||||
})
|
||||
// ── admin: installed modules (MODULE_SYSTEM.md §2.7.2) ──────────────────
|
||||
//
|
||||
// These pin the URLs, because the destructive one differs from the harmless one
|
||||
// by a query parameter and nothing else.
|
||||
|
||||
test('atlas.creatures() sends only the filters that are set', async () => {
|
||||
willReply({ body: { creatures: [] } })
|
||||
await api.atlas.creatures({ q: 'lizard man', facet: 'Ter Mur', limit: 25 })
|
||||
const url = new URL(calls[0].url, 'http://x')
|
||||
assert.equal(url.pathname, '/api/v1/public/atlas/creatures')
|
||||
assert.equal(url.searchParams.get('q'), 'lizard man')
|
||||
assert.equal(url.searchParams.get('facet'), 'Ter Mur')
|
||||
assert.equal(url.searchParams.get('limit'), '25')
|
||||
assert.equal(url.searchParams.get('offset'), null) // 0 is not sent
|
||||
})
|
||||
|
||||
test('atlas.creature() encodes the slug and carries the facet filter through', async () => {
|
||||
test('module actions hit the right paths and methods', async () => {
|
||||
const cases = [
|
||||
[() => api.admin.listModules(), 'GET', '/api/v1/admin/modules'],
|
||||
[() => api.admin.installModule('https://x/y.json'), 'POST', '/api/v1/admin/modules'],
|
||||
[() => api.admin.enableModule('uo'), 'POST', '/api/v1/admin/modules/uo/enable'],
|
||||
[() => api.admin.disableModule('uo'), 'POST', '/api/v1/admin/modules/uo/disable'],
|
||||
[() => api.admin.purgeModule('uo'), 'POST', '/api/v1/admin/modules/uo/purge'],
|
||||
[() => api.admin.setModuleSources('a.com'), 'PUT', '/api/v1/admin/modules/sources'],
|
||||
[() => api.admin.restartServer(), 'POST', '/api/v1/admin/modules/restart'],
|
||||
]
|
||||
for (const [call, method, url] of cases) {
|
||||
calls = []
|
||||
willReply({ body: {} })
|
||||
await api.atlas.creature('lizardman/rare', { facet: 'Felucca' })
|
||||
assert.match(calls[0].url, /\/public\/atlas\/creatures\/lizardman%2Frare\?facet=Felucca$/)
|
||||
await call()
|
||||
assert.equal(calls[0].url, url)
|
||||
assert.equal(calls[0].opts.method || 'GET', method)
|
||||
}
|
||||
})
|
||||
|
||||
test('admin atlas actions use the right methods and bodies', async () => {
|
||||
test('uninstall only asks for a purge when it is told to', async () => {
|
||||
// The difference between "remove the module" and "remove the module and drop
|
||||
// every table it owns" is this query parameter, so a default that leaned the
|
||||
// wrong way would be irreversible.
|
||||
willReply({ body: {} })
|
||||
await api.admin.atlas.import(true)
|
||||
assert.equal(calls[0].url, '/api/v1/admin/shard/atlas/import')
|
||||
assert.equal(calls[0].opts.method, 'POST')
|
||||
assert.equal(calls[0].opts.body, JSON.stringify({ force: true }))
|
||||
await api.admin.uninstallModule('uo')
|
||||
assert.equal(calls[0].url, '/api/v1/admin/modules/uo')
|
||||
assert.equal(calls[0].opts.method, 'DELETE')
|
||||
|
||||
calls = []
|
||||
willReply({ body: {} })
|
||||
await api.admin.atlas.setPath('/srv/servuo')
|
||||
assert.equal(calls[1].opts.method, 'PUT')
|
||||
assert.equal(calls[1].opts.body, JSON.stringify({ path: '/srv/servuo' }))
|
||||
await api.admin.uninstallModule('uo', { purge: true })
|
||||
assert.equal(calls[0].url, '/api/v1/admin/modules/uo?purge=true')
|
||||
})
|
||||
|
||||
test('a module id is URL-encoded on the way into the path', async () => {
|
||||
willReply({ body: {} })
|
||||
await api.admin.disableModule('a b/c')
|
||||
assert.equal(calls[0].url, '/api/v1/admin/modules/a%20b%2Fc/disable')
|
||||
})
|
||||
|
||||
281
client/test/moduleAdmin.test.js
Normal file
281
client/test/moduleAdmin.test.js
Normal file
@@ -0,0 +1,281 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { statusOf, actionsFor, declarationNoteFor, needsRestart, parseHosts } from '../src/lib/moduleAdmin.js'
|
||||
|
||||
// lib/moduleAdmin.js — what the Modules screen says about a module and what it
|
||||
// lets you do to it. Phase 4, slice 2 of MODULE_SYSTEM.md §2.7.2.
|
||||
//
|
||||
// This is the part of the screen worth testing, and it is plain JS so this
|
||||
// runner can reach it (there is no DOM here). What it encodes is §2.4's rule
|
||||
// that the row, the loader and the volume are three sources of truth which are
|
||||
// ALLOWED to disagree — so most of these cases are combinations that a screen
|
||||
// picking one source would render as a lie.
|
||||
|
||||
/** A module as GET /admin/modules returns it, with the running case as default. */
|
||||
const mod = (over = {}) => ({
|
||||
id: 'uo',
|
||||
name: 'Ultima Online',
|
||||
version: '1.0.0',
|
||||
state: 'started',
|
||||
failureStage: null,
|
||||
failureReason: null,
|
||||
source: 'https://gitea.example.com/x/uo.json',
|
||||
sha256: 'a'.repeat(64),
|
||||
installedAt: null,
|
||||
startedAt: null,
|
||||
liveState: 'started',
|
||||
liveVersion: '1.0.0',
|
||||
capabilities: [],
|
||||
onVolume: true,
|
||||
canPurge: true,
|
||||
declared: false,
|
||||
declaredVersion: null,
|
||||
declaredError: null,
|
||||
...over,
|
||||
})
|
||||
|
||||
// ── statusOf ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('a mounted, started module is Running and needs nothing', () => {
|
||||
const s = statusOf(mod())
|
||||
assert.equal(s.label, 'Running')
|
||||
assert.equal(s.tone, 'ok')
|
||||
assert.equal(s.pending, false)
|
||||
})
|
||||
|
||||
test('enabled in the row but disabled in the loader is "Restart to start"', () => {
|
||||
// THE case decision 3 creates on purpose: disable ran the module's onShutdown,
|
||||
// then the operator enabled it again. The row says enabled; nothing can start
|
||||
// it before a restart. Showing either "Running" or "Disabled" would be false.
|
||||
const s = statusOf(mod({ state: 'enabled', liveState: 'disabled' }))
|
||||
assert.equal(s.label, 'Restart to start')
|
||||
assert.equal(s.tone, 'warn')
|
||||
assert.equal(s.pending, true)
|
||||
assert.match(s.detail, /cannot be restarted in place/)
|
||||
})
|
||||
|
||||
test('freshly installed and never booted into is also "Restart to start"', () => {
|
||||
const s = statusOf(mod({ state: 'installed', liveState: null }))
|
||||
assert.equal(s.label, 'Restart to start')
|
||||
assert.equal(s.pending, true)
|
||||
assert.match(s.detail, /mounts when the server next starts/)
|
||||
})
|
||||
|
||||
test('a disabled module is Disabled, and that is not pending anything', () => {
|
||||
// Disable takes effect immediately — it is the one action that does — so there
|
||||
// is nothing for a restart banner to be about.
|
||||
const s = statusOf(mod({ state: 'disabled', liveState: 'disabled' }))
|
||||
assert.equal(s.label, 'Disabled')
|
||||
assert.equal(s.pending, false)
|
||||
})
|
||||
|
||||
test('a fresh install over a failed row is pending, not failed', () => {
|
||||
// THE defect the §7.7 browser smoke found, and one no test here had modelled.
|
||||
// Installing over a row the previous boot left `startup_failed` rendered
|
||||
// "Failed at the require stage: module directory not present on the volume" a
|
||||
// second after the files had been written — and suppressed the restart banner
|
||||
// the install had just told the operator to use.
|
||||
//
|
||||
// `liveState === null` with the module on the volume means the loader's scan
|
||||
// never saw it, so it arrived after boot and everything the row says predates
|
||||
// it.
|
||||
const s = statusOf(mod({
|
||||
state: 'startup_failed',
|
||||
liveState: null,
|
||||
failureStage: 'require',
|
||||
failureReason: 'module directory not present on the volume',
|
||||
}))
|
||||
assert.equal(s.label, 'Restart to start')
|
||||
assert.equal(s.pending, true)
|
||||
assert.doesNotMatch(s.detail, /not present on the volume/, 'the stale reason must not survive the install')
|
||||
})
|
||||
|
||||
test('the restart banner appears for that install', () => {
|
||||
// The second half of the same defect: the banner is driven by `pending`, so a
|
||||
// row wrongly classified as failed silently removed the only way to act on it.
|
||||
assert.equal(needsRestart([mod({ state: 'startup_failed', liveState: null })]), true)
|
||||
})
|
||||
|
||||
test('an upgrade that has not been restarted into says so', () => {
|
||||
// Same class as the stale-failure defect: the row is a promise about the next
|
||||
// boot, not a description of this one. Reporting "Running v2.0.0" while the
|
||||
// process is serving v1.0.0 would hide the only action that fixes it.
|
||||
const s = statusOf(mod({ version: '2.0.0', liveVersion: '1.0.0' }))
|
||||
assert.equal(s.label, 'Restart to finish upgrading')
|
||||
assert.equal(s.pending, true)
|
||||
assert.match(s.detail, /v2\.0\.0 is installed; v1\.0\.0 is still running/)
|
||||
})
|
||||
|
||||
test('reinstalling the SAME version is not an upgrade in progress', () => {
|
||||
assert.equal(statusOf(mod({ version: '1.0.0', liveVersion: '1.0.0' })).label, 'Running')
|
||||
})
|
||||
|
||||
test('a failed module reports the stage and the reason it recorded', () => {
|
||||
const s = statusOf(mod({
|
||||
state: 'startup_failed',
|
||||
liveState: 'startup_failed',
|
||||
failureStage: 'schema',
|
||||
failureReason: "Unknown column 'x' in 'field list'",
|
||||
}))
|
||||
assert.equal(s.label, 'Failed to start')
|
||||
assert.equal(s.tone, 'bad')
|
||||
assert.match(s.detail, /schema stage/)
|
||||
assert.match(s.detail, /Unknown column/)
|
||||
})
|
||||
|
||||
test('a failure with no recorded reason says so rather than showing a blank', () => {
|
||||
const s = statusOf(mod({ state: 'startup_failed', liveState: 'startup_failed' }))
|
||||
assert.match(s.detail, /recorded no reason/)
|
||||
})
|
||||
|
||||
test('a row whose directory is gone by hand is bad, not merely disabled', () => {
|
||||
// The boot reconcile marks this `startup_failed` because a row claiming to be
|
||||
// enabled for a module that is not on the volume is simply untrue.
|
||||
const s = statusOf(mod({ state: 'startup_failed', liveState: null, onVolume: false, failureStage: 'require', failureReason: 'module directory not present on the volume' }))
|
||||
assert.equal(s.label, 'Missing from the volume')
|
||||
assert.equal(s.tone, 'bad')
|
||||
})
|
||||
|
||||
test('an uninstalled module reads as uninstalled, and says the data was kept', () => {
|
||||
// Uninstall leaves the row `disabled` and the data alone — which is the whole
|
||||
// point of keeping the row, so the screen has to say it.
|
||||
const s = statusOf(mod({ state: 'disabled', liveState: 'disabled', onVolume: false }))
|
||||
assert.equal(s.label, 'Uninstalled')
|
||||
assert.equal(s.tone, 'idle')
|
||||
assert.match(s.detail, /data was kept/i)
|
||||
})
|
||||
|
||||
test('missing-from-the-volume beats every other status', () => {
|
||||
// Ordering: a module with no files is described that way whatever its row
|
||||
// still claims, because there is nothing there to be running.
|
||||
for (const state of ['started', 'enabled', 'installed', 'startup_failed']) {
|
||||
assert.match(statusOf(mod({ state, onVolume: false })).label, /Missing from the volume/)
|
||||
}
|
||||
})
|
||||
|
||||
// ── actionsFor ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('a running module offers disable, uninstall and a blocked purge', () => {
|
||||
const a = actionsFor(mod())
|
||||
assert.equal(a.disable.shown, true)
|
||||
assert.equal(a.enable.shown, false)
|
||||
assert.equal(a.uninstall.shown, true)
|
||||
assert.equal(a.purge.shown, true)
|
||||
// Shown but not clickable: the server refuses a standalone purge on anything
|
||||
// that is not disabled, so offering the click would only produce a 409.
|
||||
assert.equal(a.purge.enabled, false)
|
||||
assert.match(a.purge.reason, /Disable it first/)
|
||||
})
|
||||
|
||||
test('a disabled module offers enable, and purge is now live', () => {
|
||||
const a = actionsFor(mod({ state: 'disabled', liveState: 'disabled' }))
|
||||
assert.equal(a.enable.shown, true)
|
||||
assert.equal(a.disable.shown, false)
|
||||
assert.equal(a.purge.enabled, true)
|
||||
})
|
||||
|
||||
test('a module with no purge.sql never offers purge, and says why', () => {
|
||||
const a = actionsFor(mod({ state: 'disabled', liveState: 'disabled', canPurge: false }))
|
||||
assert.equal(a.purge.shown, false)
|
||||
assert.match(a.purge.reason, /ships no purge.sql/)
|
||||
})
|
||||
|
||||
test('a module with no files offers only clearing the row', () => {
|
||||
const a = actionsFor(mod({ state: 'disabled', liveState: null, onVolume: false }))
|
||||
assert.equal(a.uninstall.shown, false)
|
||||
assert.equal(a.disable.shown, false)
|
||||
assert.equal(a.enable.shown, false)
|
||||
assert.equal(a.purge.shown, false, 'there is no purge.sql left to run')
|
||||
assert.equal(a.forget.shown, true)
|
||||
})
|
||||
|
||||
test('a directory with no row yet is actionable, and offers nothing to forget', () => {
|
||||
// A hand-placed install before its first boot: it has no row, so `state` is
|
||||
// null. Its routes are already being served, so it must be disableable.
|
||||
const a = actionsFor(mod({ state: null, liveState: 'started' }))
|
||||
assert.equal(a.disable.shown, true)
|
||||
assert.equal(a.uninstall.shown, true)
|
||||
assert.equal(a.forget.shown, false)
|
||||
})
|
||||
|
||||
// ── needsRestart ───────────────────────────────────────────────────────────
|
||||
|
||||
test('the restart banner is driven by the list, not by any one module', () => {
|
||||
// A restart is a property of the SERVER. One pending module is enough, and
|
||||
// three do not mean three restarts.
|
||||
assert.equal(needsRestart([mod(), mod({ id: 'b' })]), false)
|
||||
assert.equal(needsRestart([mod(), mod({ id: 'b', state: 'installed', liveState: null })]), true)
|
||||
assert.equal(needsRestart([]), false)
|
||||
})
|
||||
|
||||
test('a disabled module does not ask for a restart', () => {
|
||||
// Disable is immediate; a banner here would be asking for a restart that
|
||||
// would change nothing.
|
||||
assert.equal(needsRestart([mod({ state: 'disabled', liveState: 'disabled' })]), false)
|
||||
})
|
||||
|
||||
test('a failed module does not ask for a restart either', () => {
|
||||
// It is retried on every boot anyway, and the operator has to fix the cause
|
||||
// first — a banner would suggest restarting is the remedy.
|
||||
assert.equal(needsRestart([mod({ state: 'startup_failed', liveState: 'startup_failed' })]), false)
|
||||
})
|
||||
|
||||
// ── parseHosts ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('parseHosts previews exactly what the server will store', () => {
|
||||
assert.deepEqual(parseHosts('A.com, b.com\n c.com'), ['a.com', 'b.com', 'c.com'])
|
||||
assert.deepEqual(parseHosts(' '), [])
|
||||
assert.deepEqual(parseHosts(undefined), [])
|
||||
})
|
||||
|
||||
// ── the declaration (slice 3) ──────────────────────────────────────────────
|
||||
|
||||
test('a declared module that has never installed says so, with the reason', () => {
|
||||
// No row, no directory, nothing mounted — invisible to the other three
|
||||
// sources, so without this branch the screen would describe a module it has
|
||||
// never had as a row gone stale.
|
||||
const s = statusOf(mod({
|
||||
state: null,
|
||||
liveState: null,
|
||||
liveVersion: null,
|
||||
version: null,
|
||||
onVolume: false,
|
||||
declared: true,
|
||||
declaredVersion: '0.3.0',
|
||||
declaredError: 'could not reach releases.example.com',
|
||||
}))
|
||||
assert.equal(s.label, 'Declared, not installed')
|
||||
assert.equal(s.tone, 'bad')
|
||||
assert.equal(s.pending, false, 'a restart will not fix an unreachable host')
|
||||
assert.match(s.detail, /could not reach releases.example.com/)
|
||||
})
|
||||
|
||||
test('a declared module waiting for its first resolution is not reported as failed', () => {
|
||||
const s = statusOf(mod({ state: null, liveState: null, version: null, onVolume: false, declared: true, declaredVersion: '0.3.0' }))
|
||||
assert.match(s.detail, /installed when the server next starts/)
|
||||
})
|
||||
|
||||
test('a running module whose declared upgrade is failing is still Running', () => {
|
||||
// Both facts are true at once. The status is one label, so the declaration
|
||||
// gets its own line rather than overwriting it.
|
||||
const m = mod({ declared: true, declaredVersion: '2.0.0', declaredError: 'sha256 did not match' })
|
||||
assert.equal(statusOf(m).label, 'Running')
|
||||
const note = declarationNoteFor(m)
|
||||
assert.equal(note.tone, 'warn')
|
||||
assert.match(note.text, /sha256 did not match/)
|
||||
})
|
||||
|
||||
test('uninstalling a declared module is told that its files come back', () => {
|
||||
// The sentence that saves an afternoon: MODULES owns what is on the volume,
|
||||
// the row owns whether it runs.
|
||||
const note = declarationNoteFor(mod({ state: 'disabled', liveState: null, onVolume: false, declared: true, declaredVersion: '1.0.0' }))
|
||||
assert.match(note.text, /come back when the server next starts/)
|
||||
assert.match(note.text, /switched off/)
|
||||
})
|
||||
|
||||
test('an ordinary declared module gets a quiet note, and an undeclared one none', () => {
|
||||
assert.equal(declarationNoteFor(mod()), null)
|
||||
const note = declarationNoteFor(mod({ declared: true, declaredVersion: '1.0.0' }))
|
||||
assert.equal(note.tone, 'idle')
|
||||
assert.match(note.text, /MODULES/)
|
||||
})
|
||||
@@ -132,6 +132,36 @@ test('a module row carries its moduleId through, which is how the gate finds it'
|
||||
assert.equal(row.feature, 'atlas')
|
||||
})
|
||||
|
||||
test('a module row carries its icon through, and an override cannot touch it', () => {
|
||||
// 1.3.0. Without an `icon` the six extracted UO rows would have been the only
|
||||
// text-only entries in a sidebar where every other row has a glyph. Core
|
||||
// renders whatever component the row carries and supplies no fallback — an
|
||||
// invented one would be core making a presentation choice for content it knows
|
||||
// nothing about.
|
||||
const Glyph = () => null
|
||||
registerNav('uo', {
|
||||
area: 'admin',
|
||||
items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System', icon: Glyph }],
|
||||
})
|
||||
const merged = withModuleNav(ADMIN, 'admin')
|
||||
const row = merged.find((g) => g.title === 'System').items.find((i) => i.to === '/admin/uo/link')
|
||||
assert.equal(row.icon, Glyph)
|
||||
|
||||
// `icon` was already on navOverrides' list of fields an override may not
|
||||
// touch, from long before a module could supply one. It still is.
|
||||
const overridden = applyNavOverrides(merged, { '/admin/uo/link': { label: 'Renamed', icon: 'nope' } })
|
||||
const after = overridden.find((g) => g.title === 'System').items.find((i) => i.to === '/admin/uo/link')
|
||||
assert.equal(after.label, 'Renamed')
|
||||
assert.equal(after.icon, Glyph)
|
||||
})
|
||||
|
||||
test('a row with no icon simply has none, the same as a core row with none', () => {
|
||||
registerNav('uo', { area: 'player', items: [{ label: 'Characters', to: '/player/uo/characters', order: 0 }] })
|
||||
const row = withModuleNav([{ to: '/account', label: 'Account' }], 'player')[0]
|
||||
assert.equal(row.to, '/player/uo/characters')
|
||||
assert.equal(row.icon, undefined)
|
||||
})
|
||||
|
||||
test('areas do not leak into one another', () => {
|
||||
registerNav('uo', { area: 'admin', items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System' }] })
|
||||
assert.equal(withModuleNav(PUBLIC, 'public'), PUBLIC)
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { bucketize, BUCKETS } from '../src/data/regionBuckets.js'
|
||||
|
||||
// Unit-test the presence.online region roll-up for the "Players Online" widget.
|
||||
// The load-bearing invariant: the bucket counts ALWAYS reconcile to the true
|
||||
// total — anything unmatched lands in Wilderness — so the widget can never show
|
||||
// a sum that disagrees with the headline online count.
|
||||
|
||||
test('bucketize groups named regions into their buckets', () => {
|
||||
const { rows, total } = bucketize({
|
||||
'Britain': 4,
|
||||
'Moonglow': 2,
|
||||
'Despise': 3,
|
||||
'Green Acres House 12': 1, // not a town/dungeon name → Housing
|
||||
})
|
||||
const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
|
||||
assert.equal(byId.britain, 4)
|
||||
assert.equal(byId.towns, 2)
|
||||
assert.equal(byId.dungeons, 3)
|
||||
assert.equal(byId.housing, 1)
|
||||
assert.equal(total, 10)
|
||||
})
|
||||
|
||||
test('first match wins by BUCKETS order: a town-named house region counts as Towns, not Housing', () => {
|
||||
// The towns regex is ^-anchored and towns is checked BEFORE housing, so a house
|
||||
// region whose name starts with a town name is bucketed as Towns. Pinning this
|
||||
// documents the ordering dependency for anyone retuning BUCKETS.
|
||||
const { rows } = bucketize({ 'Trinsic House 12': 1 })
|
||||
const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
|
||||
assert.equal(byId.towns, 1)
|
||||
assert.equal(byId.housing, undefined) // empty bucket dropped
|
||||
})
|
||||
|
||||
test('an unmatched region falls through to Wilderness so counts always reconcile', () => {
|
||||
const { rows, total } = bucketize({ 'Some Unnamed Field': 5, 'Wilderness': 2 })
|
||||
const wilderness = rows.find((r) => r.id === 'wilderness')
|
||||
assert.equal(wilderness.count, 7)
|
||||
assert.equal(total, 7)
|
||||
// The reconciliation guarantee: the buckets sum to the total, exactly.
|
||||
assert.equal(rows.reduce((s, r) => s + r.count, 0), total)
|
||||
})
|
||||
|
||||
test('bucketize returns rows in BUCKETS order and drops empty buckets', () => {
|
||||
const { rows } = bucketize({ 'Despise': 1, 'Britain': 1 })
|
||||
assert.deepEqual(rows.map((r) => r.id), ['britain', 'dungeons']) // BUCKETS order, no empty towns/housing/wilderness
|
||||
})
|
||||
|
||||
test('bucketize coerces non-numeric counts and tolerates empty/nullish input', () => {
|
||||
assert.deepEqual(bucketize({}), { rows: [], total: 0 })
|
||||
assert.deepEqual(bucketize(), { rows: [], total: 0 })
|
||||
const { total } = bucketize({ 'Britain': '3', 'Minoc': 'oops' })
|
||||
assert.equal(total, 3) // '3' → 3, 'oops' → 0
|
||||
})
|
||||
|
||||
test('the last bucket is the catch-all (its match accepts anything)', () => {
|
||||
const last = BUCKETS[BUCKETS.length - 1]
|
||||
assert.equal(last.id, 'wilderness')
|
||||
assert.equal(last.match('literally anything'), true)
|
||||
})
|
||||
@@ -1,74 +0,0 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, categoryOf, kindLabel, CATEGORIES } from '../src/lib/shardEvents.js'
|
||||
|
||||
// Unit-test the shared shard-event formatter — the single place that decides how
|
||||
// each event kind reads and which filter category it belongs to. These strings
|
||||
// are user-facing on the public Shard page, the Activity feed, and the admin
|
||||
// live feed, so a regression here is visible everywhere at once.
|
||||
|
||||
// ── describe(): works on both stored (.payload) and live (top-level) frames ──
|
||||
test('describe reads fields from .payload when present, else the top level', () => {
|
||||
const stored = { kind: 'quest.complete', payload: { who: { name: 'Ada' }, quest: 'The Cavern' } }
|
||||
const live = { kind: 'quest.complete', who: { name: 'Ada' }, quest: 'The Cavern' }
|
||||
assert.equal(describe(stored), 'Ada completed “The Cavern”')
|
||||
assert.equal(describe(live), 'Ada completed “The Cavern”')
|
||||
})
|
||||
|
||||
test('describe resolves an actor from name → acct → "Someone"', () => {
|
||||
assert.equal(describe({ kind: 'mob.login', who: { name: 'Bob' } }), 'Bob entered the world')
|
||||
assert.equal(describe({ kind: 'mob.login', who: { acct: 'acct7' } }), 'acct7 entered the world')
|
||||
assert.equal(describe({ kind: 'mob.login', who: null }), 'Someone entered the world')
|
||||
assert.equal(describe({ kind: 'mob.login', who: 'RawString' }), 'RawString entered the world')
|
||||
})
|
||||
|
||||
test('describe pluralizes a vendor sale only when amount > 1 and formats the price', () => {
|
||||
assert.equal(describe({ kind: 'vendor.sale', itemType: 'Katana', amount: 1, price: 1200 }), 'Katana sold for 1,200gp')
|
||||
assert.equal(describe({ kind: 'vendor.sale', itemType: 'Arrow', amount: 40, price: 80 }), 'Arrow ×40 sold for 80gp')
|
||||
})
|
||||
|
||||
test('describe includes the killer only when present (optional clause)', () => {
|
||||
assert.equal(describe({ kind: 'player.death', who: { name: 'Ada' } }), 'Ada was slain')
|
||||
assert.equal(
|
||||
describe({ kind: 'player.death', who: { name: 'Ada' }, killer: { name: 'Orc' } }),
|
||||
'Ada was slain by Orc',
|
||||
)
|
||||
})
|
||||
|
||||
test('describe champ.update branches on status and boss state', () => {
|
||||
assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', bossUp: true }), 'Rikktor: boss is up')
|
||||
assert.equal(
|
||||
describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', level: 3 }),
|
||||
'Rikktor is active — level 3',
|
||||
)
|
||||
assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'cooldown' }), 'Rikktor is on cooldown')
|
||||
})
|
||||
|
||||
test('describe falls back to the raw kind for an unknown event', () => {
|
||||
assert.equal(describe({ kind: 'some.future.kind' }), 'some.future.kind')
|
||||
})
|
||||
|
||||
// ── categoryOf(): membership + catch-all ────────────────────────────────
|
||||
test('categoryOf groups kinds per the CATEGORIES table, and unknowns are "other"', () => {
|
||||
assert.equal(categoryOf('player.death'), 'pvp')
|
||||
assert.equal(categoryOf('skill.gain'), 'progress')
|
||||
assert.equal(categoryOf('house.decay'), 'world')
|
||||
assert.equal(categoryOf('vendor.sale'), 'other') // deliberately not a public category
|
||||
assert.equal(categoryOf('totally.unknown'), 'other')
|
||||
})
|
||||
|
||||
test('every kind listed in CATEGORIES maps back to that category (table stays consistent)', () => {
|
||||
for (const cat of CATEGORIES) {
|
||||
if (!cat.kinds) continue
|
||||
for (const kind of cat.kinds) {
|
||||
assert.equal(categoryOf(kind), cat.id, `${kind} should be in ${cat.id}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── kindLabel(): badge text ─────────────────────────────────────────────
|
||||
test('kindLabel turns dots/underscores into spaces and tolerates empty input', () => {
|
||||
assert.equal(kindLabel('player.death'), 'player death')
|
||||
assert.equal(kindLabel('account.login.attempt'), 'account login attempt')
|
||||
assert.equal(kindLabel(null), '')
|
||||
})
|
||||
@@ -39,6 +39,24 @@ services:
|
||||
# defaults to (<repo>/modules, and the repo is /app in the image), set
|
||||
# explicitly because the bind mount below is what makes it meaningful.
|
||||
MODULES_DIR: /app/modules
|
||||
# WHICH modules this deployment runs (MODULE_SYSTEM.md §2.7.2 decision 4).
|
||||
# One entry per module, `<id>@<version>=<install manifest URL>`, whitespace-
|
||||
# or comma-separated. The container resolves this set for itself at every
|
||||
# start: a module already unpacked at the declared version is left alone
|
||||
# without a single network call — so a restart with the internet down comes
|
||||
# up unchanged — and only a missing or different version is fetched,
|
||||
# verified against the sha256 its release manifest declares, and unpacked.
|
||||
# A failure is logged and surfaced in Admin → Modules; it never stops the
|
||||
# site from starting.
|
||||
#
|
||||
# Uncomment to declare a set here, in the file this host version-controls,
|
||||
# or leave it out and set MODULES in .env (env_file above) — or leave it
|
||||
# unset entirely and install from the admin panel. What it declares is what
|
||||
# is ON the volume, never whether a module runs: a module disabled from the
|
||||
# admin panel gets its files back and stays disabled.
|
||||
#
|
||||
# MODULES: >-
|
||||
# uo@0.3.0=https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -56,10 +74,12 @@ services:
|
||||
# pull-only deployment without building anything. A bind mount rather than
|
||||
# a named volume because placing a module directory by hand is a supported
|
||||
# install — `tar -xf uo-1.0.0.tgz -C ./modules` then restart — and that has
|
||||
# to be doable from the host, not through `docker cp`.
|
||||
# to be doable from the host, not through `docker cp`. It is no longer the
|
||||
# usual way in: declare MODULES above, or install from Admin → Modules.
|
||||
#
|
||||
# Read-WRITE: the admin panel's install/uninstall unpacks and removes
|
||||
# directories here from inside the container.
|
||||
# Read-WRITE: MODULES resolution at start, and the admin panel's
|
||||
# install/uninstall, both unpack and remove directories here from inside
|
||||
# the container.
|
||||
#
|
||||
# `modules/` is tracked (it ships a README) so the directory exists in the
|
||||
# checkout with the operator's own ownership. Do not delete it — Docker
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "runic-gateway-website",
|
||||
"version": "1.0.0",
|
||||
"description": "Runic Gateway — public site, wiki, and admin panel for a private Ultima Online shard",
|
||||
"description": "Runic Gateway — public site, wiki, and admin panel for a private game server",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"install-server": "npm install --prefix server",
|
||||
@@ -13,7 +13,8 @@
|
||||
"bot": "npm run dev --prefix bot",
|
||||
"seed": "npm run seed --prefix server",
|
||||
"build": "npm run build --prefix client",
|
||||
"start": "npm start --prefix server"
|
||||
"start": "npm start --prefix server",
|
||||
"check:modules": "node scripts/checkModuleIdentifiers.js"
|
||||
},
|
||||
"keywords": ["express", "mariadb", "react", "vite", "jwt"],
|
||||
"author": "whitlocktech",
|
||||
|
||||
328
scripts/checkModuleIdentifiers.js
Normal file
328
scripts/checkModuleIdentifiers.js
Normal file
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/env node
|
||||
// ── §5.2 — zero module identifiers in core ─────────────────────────────────
|
||||
//
|
||||
// Phase 3's acceptance criterion 1, as a check rather than a review promise: no
|
||||
// `shard`, `uoLink`, `cliloc`, `atlas` or `towncrier` anywhere in core's source
|
||||
// (MODULE_API.md §5.2, MODULE_SYSTEM.md §2.7.1 slice 4). The extraction is only
|
||||
// worth what this is worth — a boundary nothing enforces grows a hole the first
|
||||
// time someone is in a hurry, and the hole looks exactly like the code that was
|
||||
// there before.
|
||||
//
|
||||
// **It reads code, not prose, and that is the whole design.** Four things are
|
||||
// checked, and each is a thing a module owns:
|
||||
//
|
||||
// 1. file and directory names
|
||||
// 2. import and require SPECIFIERS — the path, not the file's contents
|
||||
// 3. route path literals — the string handed to .get/.post/.put/.patch/
|
||||
// .delete/.use
|
||||
// 4. declared identifiers — function, const, class, and object property names
|
||||
//
|
||||
// Comments and string content in general are NOT read. Core's own English may
|
||||
// legitimately say "shard": `About.jsx` did until slice 4 rewrote it, and a
|
||||
// comment explaining *what moved and why* — `AcceptInvite.jsx` has one — is
|
||||
// worth more than the word costs. A literal word grep would fail on both, prove
|
||||
// nothing about the boundary, and teach people to phrase around it. The
|
||||
// boundary this defends is structural: core must not NAME a module's files,
|
||||
// import them, route to them, or declare their symbols. It may talk about them.
|
||||
//
|
||||
// Two things learned the hard way, both of which this file would have got wrong:
|
||||
//
|
||||
// • **Match on word boundaries, not substrings.** `defaultImage` contains
|
||||
// "ultIma"; `atlas` is inside "atlasSomething" legitimately only when it is
|
||||
// the same word. The tokeniser below splits identifiers on camelCase and
|
||||
// separators and compares WHOLE words, so `shardStatus` is a hit and
|
||||
// `defaultImage` is not. A substring pass flagged four innocent lines in
|
||||
// this repo on its first run.
|
||||
// • **Strip comments and strings with a character walk, not a regexp.** The
|
||||
// module's own `checkImports.js` flagged the comments that explain what it
|
||||
// catches. A comment contains quotes (`-- '' when randomised`), a string
|
||||
// contains `//` (any URL), and a regexp literal contains both. Doing it in
|
||||
// one pass, in order, is the only way that comes out right — and this file
|
||||
// has its own test suite (`server/test/checkModuleIdentifiers.test.js`)
|
||||
// because a check that silently stops checking is worse than no check.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { execFileSync } = require('child_process')
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
|
||||
// The trees core owns. `modules/` is deliberately absent — that is where a
|
||||
// module's own code lives, and it is the one place these words belong.
|
||||
const TREES = [
|
||||
path.join(ROOT, 'server', 'src'),
|
||||
path.join(ROOT, 'server', 'scripts'),
|
||||
path.join(ROOT, 'server', 'db'),
|
||||
path.join(ROOT, 'client', 'src'),
|
||||
]
|
||||
|
||||
const SKIP_DIRS = new Set(['node_modules', 'coverage', 'dist', '.git'])
|
||||
const CODE = new Set(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx'])
|
||||
|
||||
// The words a module owns. Lower-cased whole words, compared against the
|
||||
// tokeniser's output — so `uoLink`, `uo_link` and `uo-link` all reduce to the
|
||||
// two tokens `uo` and `link`, and the pair is what is matched.
|
||||
const RESERVED = new Set(['shard', 'shards', 'cliloc', 'clilocs', 'atlas', 'towncrier'])
|
||||
// Sequences of tokens that are reserved together but innocent apart: "uo" and
|
||||
// "link" each appear in ordinary core code ("link" especially), and only the
|
||||
// pair names the sidecar.
|
||||
const RESERVED_PAIRS = [['uo', 'link'], ['town', 'crier'], ['spawn', 'atlas'], ['serv', 'uo']]
|
||||
// Standalone `uo` is reserved too: it is the module id, and a core file called
|
||||
// `uo.js` or a route `/uo` is the boundary being crossed in the plainest way.
|
||||
const RESERVED_ALONE = new Set(['uo', 'uolink', 'servuo', 'ultima'])
|
||||
|
||||
// ── The grandfathering exemptions ───────────────────────────────────────────
|
||||
//
|
||||
// Exactly three, and all three are the SAME mechanism: core's per-module legacy
|
||||
// allowlists (MODULE_API.md §6.5). A table prefix, a set of stream ids and an
|
||||
// announce leg all predate the module system, are stored in live rows, and are
|
||||
// read by a shipped Android client — so `uo` keeps them, and keeping them means
|
||||
// core holds a map whose KEY is the module id. There is no way to write that
|
||||
// down without naming the module; that is what grandfathering is.
|
||||
//
|
||||
// Nothing else may be added here without the same kind of reason. In particular
|
||||
// this is not an escape hatch for "core still needs this for now" — that is the
|
||||
// state slice 4 exists to end.
|
||||
//
|
||||
// Each entry must MATCH something. An exemption that no longer fires is deleted
|
||||
// by the check itself (`unused exemption` below), because a stale one is how an
|
||||
// allowlist quietly becomes permission for whatever drifts into it later.
|
||||
const EXEMPT = [
|
||||
{
|
||||
file: 'server/src/modules/loader.js',
|
||||
name: 'uo',
|
||||
kind: 'property name',
|
||||
why: 'LEGACY_TABLE_PREFIXES — the grandfathered shard_/uo_link_ table prefixes (API §6.5)',
|
||||
},
|
||||
{
|
||||
file: 'server/src/modules/registries.js',
|
||||
name: 'uo',
|
||||
kind: 'property name',
|
||||
why: 'LEGACY_STREAM_IDS and LEGACY_LEGS — grandfathered stream ids and the towncrier leg (API §6.5)',
|
||||
},
|
||||
]
|
||||
|
||||
const isExempt = (hit) =>
|
||||
EXEMPT.some((e) => e.file === hit.file && e.name === hit.name && e.kind === hit.kind)
|
||||
|
||||
/**
|
||||
* Split a name into lower-case words: camelCase humps, and runs separated by
|
||||
* `-`, `_`, `.`, `/` or digits.
|
||||
*
|
||||
* `shardStatus` → [shard, status]; `uo_link_config` → [uo, link, config];
|
||||
* `defaultImage` → [default, image] — which is the point: the substring
|
||||
* "ultIma" inside it is not a word and never appears here.
|
||||
*/
|
||||
function tokenize(name) {
|
||||
return String(name)
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||
.split(/[^A-Za-z]+/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w.toLowerCase())
|
||||
}
|
||||
|
||||
/** Does this name contain a reserved word, as a word? */
|
||||
function reservedWordIn(name) {
|
||||
const words = tokenize(name)
|
||||
for (const w of words) {
|
||||
if (RESERVED.has(w) || RESERVED_ALONE.has(w)) return w
|
||||
}
|
||||
for (const [a, b] of RESERVED_PAIRS) {
|
||||
for (let i = 0; i < words.length - 1; i++) {
|
||||
if (words[i] === a && words[i + 1] === b) return `${a}-${b}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Blank out comments, and MASK string/template/regexp contents, in one
|
||||
* left-to-right pass.
|
||||
*
|
||||
* Masking rather than deleting: the checks that run afterwards need to know
|
||||
* WHERE a string was (a route path literal is a string) while not reading what
|
||||
* is in an arbitrary one. So a string's delimiters and length survive and its
|
||||
* body becomes spaces, except that the string-literal check below re-reads the
|
||||
* original text at the same offsets. Comments are replaced by spaces so every
|
||||
* offset in the returned text still lines up with the input — line numbers stay
|
||||
* honest without a second pass.
|
||||
*/
|
||||
function maskCode(src) {
|
||||
const out = Array.from(src)
|
||||
const blank = (from, to) => {
|
||||
for (let i = from; i < to && i < out.length; i++) if (out[i] !== '\n') out[i] = ' '
|
||||
}
|
||||
let i = 0
|
||||
while (i < src.length) {
|
||||
const c = src[i]
|
||||
const next = src[i + 1]
|
||||
if (c === '/' && next === '/') {
|
||||
let j = i
|
||||
while (j < src.length && src[j] !== '\n') j++
|
||||
blank(i, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if (c === '/' && next === '*') {
|
||||
const end = src.indexOf('*/', i + 2)
|
||||
const j = end === -1 ? src.length : end + 2
|
||||
blank(i, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if (c === '-' && next === '-' && src[i + 2] === ' ') {
|
||||
// SQL line comment; harmless in JS, where `-- ` cannot start an expression.
|
||||
let j = i
|
||||
while (j < src.length && src[j] !== '\n') j++
|
||||
blank(i, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if (c === '"' || c === "'" || c === '`') {
|
||||
let j = i + 1
|
||||
while (j < src.length) {
|
||||
if (src[j] === '\\') { j += 2; continue }
|
||||
if (src[j] === c) break
|
||||
j++
|
||||
}
|
||||
blank(i + 1, j) // keep the quotes, blank the body
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
return out.join('')
|
||||
}
|
||||
|
||||
// ── the four checks ─────────────────────────────────────────────────────────
|
||||
|
||||
const SPECIFIER = /(?:require\(\s*|from\s+|import\(\s*)(['"])([^'"]+)\1/g
|
||||
const ROUTE = /\.(?:get|post|put|patch|delete|use|all)\(\s*(['"`])([^'"`]*)\1/g
|
||||
const DECLARED = /\b(?:function|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g
|
||||
const PROPERTY = /(?:^|[{,]\s*)([A-Za-z_$][\w$]*)\s*:/gm
|
||||
|
||||
function lineOf(src, index) {
|
||||
return src.slice(0, index).split('\n').length
|
||||
}
|
||||
|
||||
/**
|
||||
* Check one file. `src` is the raw text; `masked` has comments blanked and
|
||||
* string bodies blanked at the same offsets, so a regexp run over `masked`
|
||||
* finds only real code — and the captured offsets index back into `src` when a
|
||||
* check legitimately needs the string's content (specifiers and route paths).
|
||||
*/
|
||||
function checkFile(rel, src) {
|
||||
const hits = []
|
||||
const masked = maskCode(src)
|
||||
const add = (kind, name, index) => {
|
||||
const word = reservedWordIn(name)
|
||||
if (word) hits.push({ file: rel, line: lineOf(src, index), kind, name, word })
|
||||
}
|
||||
|
||||
for (const m of masked.matchAll(SPECIFIER)) {
|
||||
// Read the specifier out of the ORIGINAL text: its body was masked, and a
|
||||
// path is the one string whose content is structural.
|
||||
const start = m.index + m[0].indexOf(m[1]) + 1
|
||||
add('import specifier', src.slice(start, start + m[2].length), m.index)
|
||||
}
|
||||
for (const m of masked.matchAll(ROUTE)) {
|
||||
const start = m.index + m[0].indexOf(m[1]) + 1
|
||||
add('route path', src.slice(start, start + m[2].length), m.index)
|
||||
}
|
||||
for (const m of masked.matchAll(DECLARED)) add('declared identifier', m[1], m.index)
|
||||
for (const m of masked.matchAll(PROPERTY)) add('property name', m[1], m.index)
|
||||
|
||||
return hits
|
||||
}
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) walk(full, out)
|
||||
else out.push(full)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The files core SHIPS, which is what the boundary is about — not whatever
|
||||
* happens to be in a working tree.
|
||||
*
|
||||
* `git ls-files` rather than a walk, because an untracked local artifact is not
|
||||
* core's source and must not fail anyone's build. This is not hypothetical: an
|
||||
* operator-supplied `server/db/data/spawnAtlas.art.json` is gitignored, sits in
|
||||
* the tree of anyone who has run the atlas import, and would otherwise report a
|
||||
* file-name violation that no commit could fix. The walk stays as the fallback
|
||||
* for an export with no git in it, where over-reporting is the safer failure.
|
||||
*/
|
||||
function sourceFiles() {
|
||||
try {
|
||||
const out = execFileSync('git', ['ls-files', '-z', '--cached', '--', ...TREES.map((t) => path.relative(ROOT, t))], {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
})
|
||||
const files = out.split('\0').filter(Boolean).map((f) => path.join(ROOT, f))
|
||||
if (files.length) return files
|
||||
} catch {
|
||||
// no git, or not a checkout — fall through
|
||||
}
|
||||
return TREES.filter((t) => fs.existsSync(t)).flatMap((t) => walk(t))
|
||||
}
|
||||
|
||||
function run() {
|
||||
const hits = []
|
||||
for (const file of sourceFiles()) {
|
||||
if (!fs.existsSync(file)) continue
|
||||
const rel = path.relative(ROOT, file).split(path.sep).join('/')
|
||||
|
||||
// 1. the name itself
|
||||
const word = reservedWordIn(path.basename(file))
|
||||
if (word) hits.push({ file: rel, line: 0, kind: 'file name', name: path.basename(file), word })
|
||||
|
||||
// 2-4. the contents, for code files only
|
||||
if (!CODE.has(path.extname(file))) continue
|
||||
hits.push(...checkFile(rel, fs.readFileSync(file, 'utf8')))
|
||||
}
|
||||
|
||||
const live = hits.filter((h) => !isExempt(h))
|
||||
// A grandfathering entry that matches nothing is deleted, loudly. Reported as
|
||||
// a failure rather than a warning: the exemption list is the one part of this
|
||||
// check that can only get weaker, so it is the part that needs the noise.
|
||||
const unused = EXEMPT.filter((e) => !hits.some((h) => h.file === e.file && h.name === e.name && h.kind === e.kind))
|
||||
return { hits: live, unused }
|
||||
}
|
||||
|
||||
module.exports = { run, checkFile, maskCode, tokenize, reservedWordIn, EXEMPT }
|
||||
|
||||
if (require.main === module) {
|
||||
const { hits, unused } = run()
|
||||
if (hits.length === 0 && unused.length === 0) {
|
||||
console.log('OK — core names no module identifier (MODULE_API.md §5.2).')
|
||||
process.exit(0)
|
||||
}
|
||||
for (const e of unused) {
|
||||
console.error(
|
||||
`\nUnused exemption: ${e.file} "${e.name}" (${e.kind}) matches nothing any more.\n` +
|
||||
` ${e.why}\n` +
|
||||
' Delete it from EXEMPT in this file. A grandfathering entry that has outlived what it ' +
|
||||
'grandfathered is permission with nothing attached to it.',
|
||||
)
|
||||
}
|
||||
if (hits.length === 0) process.exit(1)
|
||||
console.error(
|
||||
`\nCore names ${hits.length} module identifier${hits.length === 1 ? '' : 's'} ` +
|
||||
'(MODULE_API.md §5.2). Each of these belongs to an installed module:\n',
|
||||
)
|
||||
for (const h of hits) {
|
||||
console.error(` ${h.file}:${h.line} ${h.kind} "${h.name}" — reserved word "${h.word}"`)
|
||||
}
|
||||
console.error(
|
||||
'\nCore may TALK about a module in English; it may not name its files, import them, ' +
|
||||
'route to them, or declare its symbols. If one of these is core\'s own and the word is ' +
|
||||
'a coincidence, the fix is to rename it — the reserved list is short and deliberate.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -99,13 +99,15 @@ CLIENT_ORIGIN=http://localhost:5173
|
||||
BOT_INTERNAL_URL=http://localhost:4100
|
||||
BOT_INTERNAL_KEY=dev-only-change-me-bot-key
|
||||
|
||||
# News announcement pipeline (published news post -> in-game town crier + Discord
|
||||
# #news). The dispatcher is an in-process poller; these tune it. Links in the
|
||||
# News announcement pipeline (published news post -> every registered delivery
|
||||
# leg). The dispatcher is an in-process poller; this tunes it. Links in the
|
||||
# announcements use APP_BASE_URL (set above), so set that in production too.
|
||||
# ANNOUNCE_POLL_MS how often the dispatcher sweeps for due/retry legs
|
||||
# TOWNCRIER_DURATION_SEC how long the in-game town-crier message stays up (<= 86400)
|
||||
#
|
||||
# Which legs exist depends on what has registered one: Discord (#news) is core's,
|
||||
# and an installed module may add its own. A module's leg brings its own settings
|
||||
# with it -- module-uo's in-game town crier reads TOWNCRIER_DURATION_SEC, which is
|
||||
# documented in that module rather than here, because core has no town crier.
|
||||
ANNOUNCE_POLL_MS=15000
|
||||
TOWNCRIER_DURATION_SEC=3600
|
||||
|
||||
# Push notifications (M7) — opt-in fan-out to the Android app via a self-hosted
|
||||
# ntfy UnifiedPush relay (docs/android/PLAN.md §11). The publisher POSTs
|
||||
@@ -122,8 +124,43 @@ TOWNCRIER_DURATION_SEC=3600
|
||||
# NTFY_PUBLISH_TOKEN Optional bearer token for backend->ntfy publishes (off by default).
|
||||
# Leave NTFY_BASE_URL unset in local dev to allow any public HTTPS endpoint
|
||||
# (private/loopback hosts are always rejected). Without NTFY_PUBLIC_URL /
|
||||
# NTFY_ALLOWED_ORIGINS the app shows push as unavailable for the shard.
|
||||
# NTFY_ALLOWED_ORIGINS the app shows push as unavailable for this instance.
|
||||
# NTFY_BASE_URL=https://ntfy.example.com
|
||||
# NTFY_PUBLIC_URL=https://ntfy.example.com
|
||||
# NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
|
||||
# NTFY_PUBLISH_TOKEN=
|
||||
|
||||
# Modules (MODULE_SYSTEM.md §2.5) — where installable modules live, and where
|
||||
# they may be installed from.
|
||||
# MODULES_DIR Directory the loader scans at require time. Defaults to
|
||||
# <repo>/modules; docker-compose.yml sets it to /app/modules,
|
||||
# which is the bind mount that makes it meaningful.
|
||||
# MODULE_SOURCE_HOSTS BOOTSTRAP ONLY. Comma-separated hostnames the admin panel
|
||||
# may install a module from, seeded into the `module_source_hosts`
|
||||
# setting the first time the site boots without one. From then
|
||||
# on the SETTING is authoritative and is edited in
|
||||
# Admin → Modules — changing this variable on an existing
|
||||
# deployment does nothing, deliberately, so a redeploy cannot
|
||||
# silently undo an operator's choice. Installs are https-only
|
||||
# and an empty list forbids all of them.
|
||||
# MODULES The module set this deployment RUNS, resolved at every
|
||||
# start (§2.7.2 decision 4). One entry per module, separated
|
||||
# by whitespace or commas:
|
||||
#
|
||||
# <id>@<version>=<install manifest URL>
|
||||
#
|
||||
# A module already unpacked at the declared version is left
|
||||
# alone WITHOUT touching the network, so a restart with no
|
||||
# route to the internet comes up unchanged; only a missing or
|
||||
# different version is fetched, through the same verify-and-
|
||||
# unpack path (and the same host allowlist) the admin panel
|
||||
# uses. A version that cannot be fetched is logged and shown
|
||||
# in Admin → Modules — it never stops the site from starting.
|
||||
#
|
||||
# This variable owns what is ON the volume, not what runs: a
|
||||
# module disabled from the admin panel stays disabled even
|
||||
# though its files are put back. Leave it unset to manage
|
||||
# modules entirely from the admin panel.
|
||||
# MODULES_DIR=/app/modules
|
||||
# MODULE_SOURCE_HOSTS=gitea.whitlocktech.com
|
||||
# MODULES=uo@0.3.0=https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json
|
||||
|
||||
@@ -802,20 +802,6 @@ ALTER TABLE announce_jobs
|
||||
DROP INDEX IF EXISTS idx_announce_due,
|
||||
DROP INDEX IF EXISTS idx_announce_due_discord;
|
||||
|
||||
-- ── Spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
|
||||
-- Static shard CONTENT, not live shard state: what spawns where, which regions
|
||||
-- and landmarks exist, and which champion altars are configured. Nothing here
|
||||
-- comes from the sidecar — it is imported from a committed artifact built off a
|
||||
-- ServUO tree by `npm run atlas:build` (see docs/website/SPAWN_ATLAS.md), so
|
||||
-- these tables stay populated whether the shard is up or not.
|
||||
--
|
||||
-- Every table is import-owned: `npm run atlas:import` TRUNCATEs and reloads them
|
||||
-- in one transaction. Nothing else may write here, and nothing else may hold a
|
||||
-- foreign key to them. No FKs at all, consistent with every other shard_* table.
|
||||
|
||||
-- One row per spawnable type, aggregated across the world. `total` is the sum of
|
||||
-- each type's own MX across every point that spawns it (how many exist at once);
|
||||
|
||||
-- Installed modules (module system, docs/website/MODULE_SYSTEM.md §2.4). One row
|
||||
-- per module the operator has installed onto the modules volume, keyed by the
|
||||
-- module id from its module.json — the same id that names the directory, the URL
|
||||
@@ -888,10 +874,6 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL;
|
||||
-- Player self-registration mode: disabled | password | sso | both. Default off,
|
||||
-- so the system behaves exactly as today until an admin opts in.
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled');
|
||||
-- Game-account signup (Protocol 2.0 hybrid mode): whether a signed-in website user
|
||||
-- may provision a linked game account from the site. Default off; the shard's own
|
||||
-- signup mode still has the final say (a 'game'-mode shard refuses regardless).
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled');
|
||||
|
||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
|
||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
|
||||
@@ -921,4 +903,3 @@ ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME
|
||||
-- trust token. A boolean only — the token is returned over that app→server call
|
||||
-- and never persisted here (only its sha256 lands in trusted_devices).
|
||||
ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0;
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
|
||||
@@ -23,15 +23,30 @@ const DEFAULT_SETTINGS = {
|
||||
site_title: brand.name,
|
||||
// Android App Links opt-in — off until an admin enables it (docs/android/APP_LINKS.md).
|
||||
mobile_app_links_enabled: 'false',
|
||||
// Hosts a module may be installed from (MODULE_SYSTEM.md §2.7.2 decision 6).
|
||||
//
|
||||
// The environment BOOTSTRAPS this and does not own it: seedDefault is an
|
||||
// INSERT IGNORE, so the variable supplies a sane default on a fresh install
|
||||
// and never reaches back in to overwrite what an admin later chose in
|
||||
// Admin → Modules. Changing MODULE_SOURCE_HOSTS on an existing deployment is
|
||||
// therefore a no-op, which is the intended behaviour and not an oversight.
|
||||
//
|
||||
// An empty stored value forbids every install rather than allowing every host
|
||||
// — the safe direction for a setting someone might blank by accident.
|
||||
module_source_hosts: process.env.MODULE_SOURCE_HOSTS || 'gitea.whitlocktech.com',
|
||||
}
|
||||
|
||||
// Starter wiki sections (editable later via the admin panel).
|
||||
//
|
||||
// The SLUGS are deliberately untouched by the de-UO pass: `seedDefault*` only
|
||||
// inserts a row that is not already there, so renaming one adds a duplicate page
|
||||
// to every existing install rather than renaming anything.
|
||||
// [slug, title, description, sort_order]
|
||||
const WIKI_CATEGORIES = [
|
||||
['guides', 'Guides', 'Getting started and how-to guides.', 10],
|
||||
['world', 'World & Lore', `Regions, maps, and the story of ${brand.shortName}.`, 20],
|
||||
['gameplay', 'Systems & Gameplay', 'Mechanics, items, monsters, and crafting.', 30],
|
||||
['community', 'Community & Rules', 'Player conduct and shard policies.', 40],
|
||||
['community', 'Community & Rules', 'Player conduct and server policies.', 40],
|
||||
]
|
||||
|
||||
// The 8 starter pages, each mapped to a section. [slug, title, body, categorySlug]
|
||||
@@ -39,11 +54,11 @@ const WIKI_PAGES = [
|
||||
['new-player-guide', 'New Player Guide', 'First steps, basic survival, and early goals.', 'guides'],
|
||||
['maps-atlas', 'Maps & Atlas', 'Regions, towns, routes, and travel notes.', 'world'],
|
||||
['lore', 'Lore', 'Stories, places, factions, and mysteries.', 'world'],
|
||||
['systems', 'Server Systems', 'Shard mechanics and custom features.', 'gameplay'],
|
||||
['systems', 'Server Systems', 'Server mechanics and custom features.', 'gameplay'],
|
||||
['items', 'Items & Rewards', 'Equipment, treasures, rewards, and curiosities.', 'gameplay'],
|
||||
['monsters', 'Monsters & Encounters', 'Creatures, bosses, spawns, and dangers.', 'gameplay'],
|
||||
['crafting', 'Crafting', 'Professions, materials, recipes, and tools.', 'gameplay'],
|
||||
['rules', 'Rules', 'Player conduct, shard expectations, and policies.', 'community'],
|
||||
['rules', 'Rules', 'Player conduct, server expectations, and policies.', 'community'],
|
||||
]
|
||||
|
||||
async function seedDefaults() {
|
||||
|
||||
68
server/package-lock.json
generated
68
server/package-lock.json
generated
@@ -27,6 +27,7 @@
|
||||
"sanitize-html": "^2.17.5",
|
||||
"speakeasy": "^2.0.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tar": "^7.5.22",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -34,6 +35,18 @@
|
||||
"swagger-autogen": "^2.23.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^7.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@scarf/scarf": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
||||
@@ -330,6 +343,15 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
@@ -1488,6 +1510,27 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/morgan": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz",
|
||||
@@ -2254,6 +2297,22 @@
|
||||
"express": ">=4.0.0 || >=5.0.0-beta"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.22",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
|
||||
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -2414,6 +2473,15 @@
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"sanitize-html": "^2.17.5",
|
||||
"speakeasy": "^2.0.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tar": "^7.5.22",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -427,6 +427,90 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/modules",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/modules",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/modules/:id",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/modules/:id/disable",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/modules/:id/enable",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/modules/:id/purge",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/modules/restart",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/modules/sources",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/pages",
|
||||
|
||||
@@ -177,6 +177,38 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/moderation/user/:discordId/notes"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/modules"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/modules"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/modules/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/modules/:id/disable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/modules/:id/enable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/modules/:id/purge"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/modules/restart"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/modules/sources"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/pages"
|
||||
|
||||
@@ -111,15 +111,22 @@ app.use(
|
||||
)
|
||||
|
||||
// ── API docs (Swagger UI) ─────────────────────────────────────────────
|
||||
// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. The spec
|
||||
// is generated from route annotations by `npm run swagger` (server/swagger/).
|
||||
// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. Core's own
|
||||
// routes are generated from their annotations by `npm run swagger`
|
||||
// (server/swagger/) and committed; an installed module's routes cannot be —
|
||||
// swagger-autogen is static analysis and a module arrives on the volume after the
|
||||
// image was built — so each module ships its own fragment and they are merged
|
||||
// HERE, per request, over core's committed spec (docs/website/MODULE_API.md §6.1a).
|
||||
// Loaded lazily and guarded so a missing spec never crashes the server.
|
||||
try {
|
||||
// eslint-disable-next-line global-require
|
||||
/* eslint-disable global-require */
|
||||
const swaggerSpec = require('../swagger/swagger-output.json')
|
||||
const { docsSpec } = require('../swagger/docsSpec')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
app.get('/api/docs.json', (req, res) => {
|
||||
// #swagger.ignore = true
|
||||
res.json(swaggerSpec)
|
||||
res.json(docsSpec(swaggerSpec))
|
||||
})
|
||||
// swagger-ui-express injects an inline bootstrap script and inline styles, which
|
||||
// the global 'self'-only script-src would block — relax CSP for this route only.
|
||||
@@ -132,10 +139,18 @@ try {
|
||||
'upgrade-insecure-requests': null,
|
||||
},
|
||||
})
|
||||
app.use('/api/docs', swaggerCsp, swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
|
||||
// `setup()` is called PER REQUEST rather than once here, because the document it
|
||||
// renders is not fixed at boot: a module reaching `started` (or failing to) adds
|
||||
// or removes paths, and a UI bound to the spec as it looked while app.js was
|
||||
// still being required would show core's routes for the life of the process
|
||||
// while /api/docs.json showed the merged set. `docsSpec` is cached on the
|
||||
// loader's state version, so the repeated call costs a comparison.
|
||||
const swaggerOpts = {
|
||||
customSiteTitle: `${brand.name} API docs`,
|
||||
swaggerOptions: { persistAuthorization: true },
|
||||
}))
|
||||
}
|
||||
app.use('/api/docs', swaggerCsp, swaggerUi.serve, (req, res, next) =>
|
||||
swaggerUi.setup(docsSpec(swaggerSpec), swaggerOpts)(req, res, next))
|
||||
} catch (err) {
|
||||
errLog.error('Swagger spec not found — run `npm run swagger` to generate it. API docs disabled.', {
|
||||
message: err.message,
|
||||
|
||||
@@ -22,10 +22,15 @@ const name = process.env.BRAND_NAME || 'Runic Gateway'
|
||||
const brand = {
|
||||
name,
|
||||
shortName: process.env.BRAND_SHORT_NAME || name,
|
||||
tagline: process.env.BRAND_TAGLINE || 'an independent private Ultima Online shard',
|
||||
// Game-neutral defaults. Core is the platform, not one game's site: which game
|
||||
// this instance is for is the operator's to say, through these two vars or an
|
||||
// installed module (MODULE_SYSTEM.md §2.7.1, slice 4). Every real instance
|
||||
// overrides both — `.env.uomysticmoon.example` sets its own wording — so these
|
||||
// are what an unconfigured instance shows, not what anyone ships.
|
||||
tagline: process.env.BRAND_TAGLINE || 'an independent, privately-run game server',
|
||||
description:
|
||||
process.env.BRAND_DESCRIPTION ||
|
||||
`${name} — an independent private Ultima Online shard. News, screenshots, guides, and community notes.`,
|
||||
`${name} — an independent, privately-run game server. News, screenshots, guides, and community notes.`,
|
||||
contactEmail: process.env.BRAND_CONTACT_EMAIL || process.env.CONTACT_TO || '',
|
||||
url: process.env.BRAND_URL || '',
|
||||
// Visual
|
||||
|
||||
@@ -17,6 +17,23 @@ const getOne = (id) => query(`SELECT ${COLS} FROM installed_modules WHERE id = ?
|
||||
// module must not silently disable it, and re-installing a disabled one must not
|
||||
// silently switch it back on. A brand-new row lands in `installed`, the transient
|
||||
// state the next restart resolves.
|
||||
// Provenance is COALESCEd, and that is the whole difference between this
|
||||
// working and not.
|
||||
//
|
||||
// `lifecycle.boot()` re-records every module it scanned with no source and no
|
||||
// sha256 — a directory placed on the volume by hand genuinely has neither, and
|
||||
// the boot has no way to know where one came from. With a plain
|
||||
// `source = VALUES(source)` that refresh overwrote both columns with NULL on
|
||||
// EVERY boot, so an admin-panel install's provenance survived exactly until the
|
||||
// restart that install asked for. Nothing could catch it before Phase 4: until
|
||||
// then no caller ever passed a non-null value, and lifecycle.js's comment
|
||||
// asserting that "recordInstalled leaves what it is not given" was a description
|
||||
// of an intention rather than of this statement.
|
||||
//
|
||||
// COALESCE makes that comment true: a value overwrites, a NULL leaves what is
|
||||
// there. The cost is that re-placing a DIFFERENT bundle by hand over a row that
|
||||
// was once installed from a URL keeps the old provenance — which is wrong but
|
||||
// stale, and strictly better than the alternative, which was wrong and blank.
|
||||
const upsert = ({ id, name, version, source, sha256 }) =>
|
||||
query(
|
||||
`INSERT INTO installed_modules (id, name, version, source, sha256, state)
|
||||
@@ -24,8 +41,8 @@ const upsert = ({ id, name, version, source, sha256 }) =>
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
version = VALUES(version),
|
||||
source = VALUES(source),
|
||||
sha256 = VALUES(sha256)`,
|
||||
source = COALESCE(VALUES(source), source),
|
||||
sha256 = COALESCE(VALUES(sha256), sha256)`,
|
||||
[id, name, version, source ?? null, sha256 ?? null],
|
||||
)
|
||||
|
||||
|
||||
@@ -55,27 +55,6 @@ function registrationFlags(mode) {
|
||||
}
|
||||
}
|
||||
|
||||
// Game-account signup (Protocol 2.0). The admin picks who mints game accounts:
|
||||
// disabled — the site never offers game-account creation (link-only).
|
||||
// website — the site is the authority (offer creation; pair with the shard in
|
||||
// website mode + AutoCreateAccounts=false).
|
||||
// hybrid — either side may create (the site offers creation).
|
||||
// game — the game server is the authority; the site does NOT offer creation.
|
||||
// The site OFFERS creation only for 'website'/'hybrid'; the shard's own SignupMode
|
||||
// (Bridge.cfg) still has the final say and may 403 a call regardless.
|
||||
const GAME_SIGNUP_KEY = 'game_account_signup'
|
||||
const GAME_SIGNUP_MODES = ['disabled', 'website', 'hybrid', 'game']
|
||||
const GAME_SIGNUP_OFFER = ['website', 'hybrid']
|
||||
|
||||
async function getGameSignupMode() {
|
||||
const v = await settingsDb.get(GAME_SIGNUP_KEY)
|
||||
return GAME_SIGNUP_MODES.includes(v) ? v : 'disabled'
|
||||
}
|
||||
|
||||
async function isGameAccountSignupEnabled() {
|
||||
return GAME_SIGNUP_OFFER.includes(await getGameSignupMode())
|
||||
}
|
||||
|
||||
// Android App Links opt-in (M9 follow-up). When on, the shard auto-serves
|
||||
// /.well-known/assetlinks.json and the mobile SSO bridge additionally accepts the
|
||||
// self-origin https://<host>/mobile/callback redirect. Stored as the string
|
||||
@@ -146,10 +125,12 @@ async function getPublic() {
|
||||
// page show/hide the password form and SSO buttons.
|
||||
const mode = REGISTRATION_MODES.includes(all[REGISTRATION_KEY]) ? all[REGISTRATION_KEY] : 'disabled'
|
||||
out.registration = registrationFlags(mode)
|
||||
// Whether the site offers game-account creation (the shard's own mode still has
|
||||
// the final say when the call is made). Lets the portal show/hide the form.
|
||||
const gsMode = GAME_SIGNUP_MODES.includes(all[GAME_SIGNUP_KEY]) ? all[GAME_SIGNUP_KEY] : 'disabled'
|
||||
out.gameAccountSignup = GAME_SIGNUP_OFFER.includes(gsMode)
|
||||
// `gameAccountSignup` was derived here until Phase 3 slice 3. It said whether
|
||||
// the site offers GAME-account creation, which is a question about a shard —
|
||||
// its own SignupMode has to agree — so it left with the rest of core's UO
|
||||
// prose. The `game_account_signup` row is unchanged and module-uo reads it
|
||||
// through ctx.settings; the derived flag is on its `/public/shard/features`.
|
||||
//
|
||||
// The effective CSS custom properties for the admin's theme, or absent when
|
||||
// no theme_visual row exists (or nothing in it was usable). The SPA writes
|
||||
// these onto <html>; absence means it writes nothing and theme.css's :root
|
||||
@@ -264,10 +245,6 @@ module.exports = {
|
||||
REGISTRATION_MODES,
|
||||
getRegistrationMode,
|
||||
registrationFlags,
|
||||
GAME_SIGNUP_KEY,
|
||||
GAME_SIGNUP_MODES,
|
||||
getGameSignupMode,
|
||||
isGameAccountSignupEnabled,
|
||||
MOBILE_APP_LINKS_KEY,
|
||||
isMobileAppLinksEnabled,
|
||||
}
|
||||
|
||||
221
server/src/modules/archive.js
Normal file
221
server/src/modules/archive.js
Normal file
@@ -0,0 +1,221 @@
|
||||
// ── The hardened bundle extractor ──────────────────────────────────────────
|
||||
//
|
||||
// Phase 4, slice 1 of docs/website/MODULE_SYSTEM.md §2.7.2. This is the part of
|
||||
// the install path that handles input an attacker chose, and it is separated
|
||||
// from install.js so that it can be tested against crafted archives without a
|
||||
// network, a database or a filesystem layout.
|
||||
//
|
||||
// **The download is not the dangerous part.** An allowlisted host, a declared
|
||||
// sha256 and a size cap between them settle where the bytes came from and that
|
||||
// they are the bytes that were published. Unpacking is different: the ARCHIVE
|
||||
// chooses the filenames, and core writes into a directory bind-mounted from the
|
||||
// host (MODULE_SYSTEM.md §2.5), so an escape is not confined to the container.
|
||||
//
|
||||
// The rule this file follows is **reject, never sanitise.** node-tar will
|
||||
// happily strip a leading `/` and drop a `..` for you, which turns a hostile
|
||||
// archive into a slightly different archive that then gets installed. An archive
|
||||
// that needs correcting is an archive that should not be trusted, so anything on
|
||||
// the list below fails the whole install and nothing is written.
|
||||
//
|
||||
// - an absolute path, POSIX (`/etc/…`) or Windows (`C:\…`, `\\server\…`)
|
||||
// - any `..` segment, anywhere
|
||||
// - anything that is not a regular file or a directory — so no symlinks, no
|
||||
// hardlinks, no devices, no FIFOs. A module bundle has no legitimate use for
|
||||
// any of them, and every one is a documented escape primitive
|
||||
// - more than one top-level entry
|
||||
// - more entries, or more unpacked bytes, than the caps below
|
||||
//
|
||||
// That third rule deserves its own note, because it is why the `tar` dependency
|
||||
// is pinned forward rather than merely present: **the majority of node-tar's
|
||||
// published advisories are hardlink or symlink path traversal**, several of them
|
||||
// through interpretation differences in PAX and GNU long-name headers rather
|
||||
// than through anything the calling code did wrong. Refusing those entry types
|
||||
// outright means this extractor is not relying on the library to get their
|
||||
// containment right. The remaining advisories are parser denial-of-service, and
|
||||
// those are what the caps and the two-pass shape address.
|
||||
//
|
||||
// Two passes, deliberately: `inspect()` reads the archive and decides, and only
|
||||
// an archive that survived that is handed to `extract()`. A `filter` callback
|
||||
// during extraction cannot reject — by the time it is asked about entry 400, the
|
||||
// first 399 are already on disk.
|
||||
|
||||
const fs = require('fs')
|
||||
const fsp = require('fs/promises')
|
||||
const path = require('path')
|
||||
const tar = require('tar')
|
||||
|
||||
// Caps. Generous against a real bundle (module-uo's is a few megabytes, a few
|
||||
// hundred files) and small enough that a decompression bomb is refused rather
|
||||
// than paged in. Both are checked DURING the inspect pass, not after it, so a
|
||||
// hostile archive stops being read at the limit instead of at its end.
|
||||
const MAX_BYTES = 128 * 1024 * 1024
|
||||
const MAX_ENTRIES = 20000
|
||||
|
||||
// tar entry types worth naming. node-tar reports these as strings; anything not
|
||||
// in this set is refused, including the ones no one has thought of, which is the
|
||||
// point of an allowlist here rather than a list of the types known to be bad.
|
||||
const ALLOWED_TYPES = new Set(['File', 'Directory'])
|
||||
|
||||
class ArchiveError extends Error {
|
||||
constructor(message) {
|
||||
super(message)
|
||||
this.name = 'ArchiveError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this entry path safe to unpack anywhere?
|
||||
*
|
||||
* Returns a reason string, or null when the path is fine. Written as a reason
|
||||
* rather than a boolean because the operator pasting a URL needs to be told what
|
||||
* was wrong with what they were served, and "the archive is invalid" is not that.
|
||||
*/
|
||||
function pathProblem(entryPath) {
|
||||
const p = String(entryPath)
|
||||
|
||||
// NUL is not a path character. node-tar has had uncaught-exception advisories
|
||||
// for NUL bytes inside PAX records, so this is checked here rather than left
|
||||
// to the parser.
|
||||
if (p.includes('\0')) return 'an entry path contains a NUL byte'
|
||||
|
||||
// A backslash is a path separator on the platform this may be unpacked on, so
|
||||
// an entry that contains one is not the single path component it looks like.
|
||||
if (p.includes('\\')) return `an entry path contains a backslash: "${p}"`
|
||||
|
||||
if (p.startsWith('/')) return `an entry path is absolute: "${p}"`
|
||||
// Drive-relative and UNC. Both are the subject of their own node-tar
|
||||
// advisories, and neither is refused by a leading-slash check.
|
||||
if (/^[a-zA-Z]:/.test(p)) return `an entry path is drive-absolute: "${p}"`
|
||||
|
||||
const segments = p.split('/')
|
||||
if (segments.includes('..')) return `an entry path escapes upward: "${p}"`
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an archive without unpacking it, and decide whether it may be unpacked.
|
||||
*
|
||||
* @param {string} file absolute path to the .tar.gz
|
||||
* @returns {Promise<{root: string, entries: number, bytes: number}>} the single
|
||||
* top-level directory name, and what is inside it.
|
||||
* @throws {ArchiveError} on anything in this file's header list.
|
||||
*/
|
||||
async function inspect(file) {
|
||||
const roots = new Set()
|
||||
let entries = 0
|
||||
let bytes = 0
|
||||
// The first problem found, kept rather than thrown from inside the callback:
|
||||
// throwing out of `onentry` escapes through the parser's stream machinery and
|
||||
// arrives as an unhelpful wrapped error, when it arrives at all.
|
||||
let problem = null
|
||||
|
||||
const note = (message) => {
|
||||
if (!problem) problem = message
|
||||
}
|
||||
|
||||
await tar.t({
|
||||
file,
|
||||
onentry(entry) {
|
||||
if (problem) return
|
||||
|
||||
entries += 1
|
||||
if (entries > MAX_ENTRIES) {
|
||||
note(`the archive has more than ${MAX_ENTRIES} entries`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.has(entry.type)) {
|
||||
// The message names the type because "symbolic link" is a much more
|
||||
// useful thing for an operator to read than "invalid entry".
|
||||
note(`the archive contains a ${entry.type} ("${entry.path}") — a module bundle may only contain files and directories`)
|
||||
return
|
||||
}
|
||||
|
||||
const bad = pathProblem(entry.path)
|
||||
if (bad) {
|
||||
note(bad)
|
||||
return
|
||||
}
|
||||
|
||||
// A negative or absurd size is a parser-confusion primitive in its own
|
||||
// right; clamping at zero keeps the running total honest.
|
||||
bytes += Math.max(0, Number(entry.size) || 0)
|
||||
if (bytes > MAX_BYTES) {
|
||||
note(`the archive unpacks to more than ${Math.round(MAX_BYTES / 1024 / 1024)} MB`)
|
||||
return
|
||||
}
|
||||
|
||||
const [root] = String(entry.path).split('/')
|
||||
if (root) roots.add(root)
|
||||
},
|
||||
})
|
||||
|
||||
if (problem) throw new ArchiveError(problem)
|
||||
if (entries === 0) throw new ArchiveError('the archive is empty')
|
||||
if (roots.size !== 1) {
|
||||
// A bundle is one directory. More than one top-level entry means `strip: 1`
|
||||
// below would silently merge or discard things, and a bundle that needs
|
||||
// interpreting is not a bundle.
|
||||
throw new ArchiveError(
|
||||
`the archive must contain exactly one top-level directory, found ${roots.size}` +
|
||||
(roots.size > 1 ? ` (${[...roots].slice(0, 4).join(', ')})` : ''),
|
||||
)
|
||||
}
|
||||
|
||||
return { root: [...roots][0], entries, bytes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpack an inspected archive into `dest`, stripping its single top level.
|
||||
*
|
||||
* The top-level directory is stripped rather than kept, because its name belongs
|
||||
* to whoever published the bundle — module-uo's release workflow packs
|
||||
* `module-uo-<version>/`, not `uo/` — while the directory it lands in is core's
|
||||
* decision and has to be the module id the loader scans for. What is inside the
|
||||
* bundle is the module; what the wrapper is called is packaging.
|
||||
*
|
||||
* `dest` must not exist. Callers unpack to a temporary directory and move it
|
||||
* into place, so a failure halfway through leaves nothing for the next boot's
|
||||
* scan to find.
|
||||
*/
|
||||
async function extract(file, dest) {
|
||||
if (fs.existsSync(dest)) throw new ArchiveError(`extraction target already exists: ${dest}`)
|
||||
await fsp.mkdir(dest, { recursive: true })
|
||||
|
||||
await tar.x({
|
||||
file,
|
||||
cwd: dest,
|
||||
strip: 1,
|
||||
// Belt and braces on top of inspect(): the library's own refusal to write
|
||||
// outside cwd, and its refusal to overwrite through a link. Neither is
|
||||
// load-bearing here — inspect() has already rejected every entry that could
|
||||
// exercise them — but a second lock on a door that is already locked costs
|
||||
// nothing, and this is the door.
|
||||
preservePaths: false,
|
||||
// Reject rather than warn on anything the parser itself objects to.
|
||||
strict: true,
|
||||
})
|
||||
|
||||
return dest
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect, then extract. The only entry point install.js uses.
|
||||
*/
|
||||
async function unpack(file, dest) {
|
||||
const stats = await inspect(file)
|
||||
await extract(file, dest)
|
||||
return stats
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ArchiveError,
|
||||
inspect,
|
||||
extract,
|
||||
unpack,
|
||||
pathProblem,
|
||||
MAX_BYTES,
|
||||
MAX_ENTRIES,
|
||||
ALLOWED_TYPES,
|
||||
}
|
||||
220
server/src/modules/declared.js
Normal file
220
server/src/modules/declared.js
Normal file
@@ -0,0 +1,220 @@
|
||||
// ── The declared module set ────────────────────────────────────────────────
|
||||
//
|
||||
// Phase 4, slice 3 of docs/website/MODULE_SYSTEM.md §2.7.2 — decision 4. A
|
||||
// compose-managed host is not driven by clicking: it declares which modules it
|
||||
// runs, in the file it already edits and version-controls, and the container
|
||||
// arrives at that set by itself.
|
||||
//
|
||||
// MODULES: uo@0.3.0=https://<host>/…/module-uo-0.3.0.json
|
||||
//
|
||||
// One entry per module, `<id>@<version>=<install manifest URL>`, separated by
|
||||
// whitespace or commas. The id and the version are written out rather than left
|
||||
// to be discovered inside the manifest for one reason: **the no-op case must not
|
||||
// need the network.** A module already unpacked at the declared version is
|
||||
// answered by reading its own `module.json` off the volume, so a restart with
|
||||
// the network down brings the site up exactly as it was. Only a module that is
|
||||
// missing, or unpacked at some other version, reaches out — and it reaches out
|
||||
// through modules/install.js, the same fetch-verify-unpack path the admin panel
|
||||
// uses, under the same host allowlist.
|
||||
//
|
||||
// Three things this file deliberately does not do:
|
||||
//
|
||||
// - **It does not decide whether a module RUNS.** Resolution owns what is on
|
||||
// the volume; `installed_modules` owns whether a mounted module answers. An
|
||||
// admin who uninstalls a declared module gets its directory back at the next
|
||||
// boot with the row still `disabled`, so it stays off until they enable it.
|
||||
// The two never fight because they are not answering the same question.
|
||||
// - **It does not fail a boot.** A module publisher's host being unreachable
|
||||
// must not take a shard's website down with it; core is built to serve with
|
||||
// a module absent (§1.6). Every failure is logged loudly and kept for the
|
||||
// admin screen, and the site comes up.
|
||||
// - **It does not mount anything.** §1.12 makes the volume the mounting source
|
||||
// of truth, read once at require time — which is why this runs before
|
||||
// `require('./app')` in server.js and not from inside it.
|
||||
//
|
||||
// It runs on every boot, not only in Docker: a bare `npm start` with MODULES set
|
||||
// resolves the same way. The Docker path is the reason it exists, not a special
|
||||
// case in it.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const install = require('./install')
|
||||
|
||||
const log = require('../utils/logger')('modules')
|
||||
|
||||
// The variable an operator sets. Named next to MODULES_DIR, which is the other
|
||||
// half of the same story: one says where modules live, the other says which.
|
||||
const VAR = 'MODULES'
|
||||
|
||||
// Same id rule the loader enforces when scanning and install.js enforces when
|
||||
// placing, restated here so a declaration cannot name something neither would
|
||||
// accept.
|
||||
const ID = /^[a-z][a-z0-9-]{1,31}$/
|
||||
|
||||
// The outcome of the last resolution, in memory, for the admin screen. Not a
|
||||
// database row: a declaration is a fact about this process's environment, and
|
||||
// writing it down would put it in front of the boot reconcile, which resets
|
||||
// every non-disabled row (§2.4). The screen merges it as a fourth source
|
||||
// alongside the row, the loader and the volume.
|
||||
let results = []
|
||||
|
||||
/**
|
||||
* Parse the declaration into entries.
|
||||
*
|
||||
* Malformed entries are collected rather than thrown: one operator typo should
|
||||
* cost that module, not every module on the host. A duplicate id keeps the
|
||||
* first — there is no sensible way to run two versions of one module, and
|
||||
* silently preferring the last would make the outcome depend on the order of a
|
||||
* list nobody reads as ordered.
|
||||
*
|
||||
* @param {string} value the raw variable
|
||||
* @returns {{entries: Array<{id,version,url}>, errors: string[]}}
|
||||
*/
|
||||
function parse(value) {
|
||||
const entries = []
|
||||
const errors = []
|
||||
const seen = new Set()
|
||||
|
||||
for (const token of String(value || '').split(/[,\s]+/).filter(Boolean)) {
|
||||
const at = token.indexOf('@')
|
||||
const eq = token.indexOf('=')
|
||||
if (at < 1 || eq < at + 2) {
|
||||
errors.push(`"${token}" is not <id>@<version>=<url>`)
|
||||
continue
|
||||
}
|
||||
const id = token.slice(0, at)
|
||||
const version = token.slice(at + 1, eq)
|
||||
const url = token.slice(eq + 1)
|
||||
|
||||
if (!ID.test(id)) {
|
||||
errors.push(`"${token}" names an invalid module id "${id}"`)
|
||||
continue
|
||||
}
|
||||
if (!url) {
|
||||
errors.push(`"${token}" has no install manifest URL`)
|
||||
continue
|
||||
}
|
||||
if (seen.has(id)) {
|
||||
errors.push(`"${id}" is declared more than once — keeping the first`)
|
||||
continue
|
||||
}
|
||||
seen.add(id)
|
||||
entries.push({ id, version, url })
|
||||
}
|
||||
|
||||
return { entries, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* The version currently unpacked on the volume, or null.
|
||||
*
|
||||
* Read straight out of the module's own `module.json`, which is the same file
|
||||
* the loader trusts for the same fact — and never from `installed_modules`,
|
||||
* because the row records what was installed and this has to answer what is
|
||||
* actually there. An unreadable manifest counts as absent: whatever is in that
|
||||
* directory, it is not a module at the declared version.
|
||||
*/
|
||||
function installedVersion(id) {
|
||||
try {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(install.moduleDir(id), 'module.json'), 'utf8'),
|
||||
)
|
||||
return manifest && manifest.version ? String(manifest.version) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the volume in line with the declaration.
|
||||
*
|
||||
* Never throws and never rejects. Returns one outcome per declared entry, and
|
||||
* remembers them for `state()`.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {string} [args.value] the raw variable (defaults to the environment)
|
||||
* @param {string[]} args.hosts the install allowlist, already parsed
|
||||
* @param {object} args.model modules.model, for recording provenance
|
||||
* @param {object} [args.installImpl] injection seam, as everywhere else here
|
||||
* @returns {Promise<Array<{id,version,url,action,message}>>}
|
||||
*/
|
||||
async function resolve({ value = process.env[VAR], hosts = [], model, installImpl = install } = {}) {
|
||||
const { entries, errors } = parse(value)
|
||||
results = []
|
||||
|
||||
for (const message of errors) log.error(`${VAR}: ${message}`)
|
||||
|
||||
if (!entries.length) return results
|
||||
|
||||
log.info(`${VAR} declares ${entries.length} module(s)`, {
|
||||
modules: entries.map((e) => `${e.id}@${e.version}`).join(' '),
|
||||
})
|
||||
|
||||
for (const entry of entries) {
|
||||
const present = installedVersion(entry.id)
|
||||
if (present === entry.version) {
|
||||
// The offline path, and the common one: nothing is fetched, nothing is
|
||||
// written, and a host with no route to the internet boots unchanged.
|
||||
log.info(`module "${entry.id}" is already at the declared version ${entry.version}`)
|
||||
results.push({ ...entry, action: 'noop', message: null })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// `expect` is the declaration itself, handed down so install.js can refuse
|
||||
// a URL that resolves to another module or another version while it is
|
||||
// still only a manifest — a check made after the unpack would be made with
|
||||
// the undeclared module already on the volume.
|
||||
const result = await installImpl.install({
|
||||
url: entry.url,
|
||||
hosts,
|
||||
expect: { id: entry.id, version: entry.version },
|
||||
})
|
||||
|
||||
// Provenance, written exactly as the admin route writes it — the whole
|
||||
// point of resolving in-process rather than from a script that cannot
|
||||
// reach the database. A module installed by the compose file and one
|
||||
// installed by an admin are then indistinguishable on the screen, which
|
||||
// is what makes this one feature and not two.
|
||||
if (model) {
|
||||
await model.recordInstalled({
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
version: result.version,
|
||||
source: entry.url,
|
||||
sha256: result.sha256,
|
||||
})
|
||||
}
|
||||
|
||||
log.warn(`installed declared module "${entry.id}" v${entry.version}`, {
|
||||
from: present || 'nothing',
|
||||
source: entry.url,
|
||||
sha256: result.sha256,
|
||||
})
|
||||
results.push({ ...entry, action: 'installed', message: null })
|
||||
} catch (err) {
|
||||
// Loud, and then onward. The site serves; this module does not, or serves
|
||||
// the version that was already there.
|
||||
log.error(
|
||||
`could not resolve declared module "${entry.id}@${entry.version}": ${err.message}` +
|
||||
(present ? ` — leaving version ${present} in place` : ''),
|
||||
)
|
||||
results.push({ ...entry, action: 'failed', message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/** What the last resolution decided, for the admin screen. */
|
||||
function state() {
|
||||
return results.map((r) => ({ ...r }))
|
||||
}
|
||||
|
||||
/** Test seam: forget the last resolution. */
|
||||
function reset() {
|
||||
results = []
|
||||
}
|
||||
|
||||
module.exports = { VAR, parse, installedVersion, resolve, state, reset }
|
||||
450
server/src/modules/install.js
Normal file
450
server/src/modules/install.js
Normal file
@@ -0,0 +1,450 @@
|
||||
// ── Installing and removing a module bundle ────────────────────────────────
|
||||
//
|
||||
// Phase 4, slice 1 of docs/website/MODULE_SYSTEM.md §2.7.2 — the consumer half
|
||||
// of a release the module's own CI already publishes. Nothing here touches the
|
||||
// database or the loader: this file moves bytes onto the volume and off it, and
|
||||
// the caller (router/v1/admin/modules.controller.js) writes down what happened.
|
||||
//
|
||||
// The shape of an install, and why it is this shape:
|
||||
//
|
||||
// 1. The operator pastes the URL of a release's install manifest — a small
|
||||
// JSON document naming the artifact, its sha256 and its size (decision 2).
|
||||
// There is no catalog, because a catalog would make core's release cadence
|
||||
// decide which modules can exist.
|
||||
// 2. Every URL fetched — the manifest, the artifact, and every redirect hop —
|
||||
// is checked against the admin-managed host allowlist (decision 6). That is
|
||||
// what keeps a pasted URL from also being an SSRF primitive.
|
||||
// 3. The artifact is streamed to a temporary file under a byte cap, hashed as
|
||||
// it arrives, and compared against the manifest's `sha256`. The hash is the
|
||||
// trust anchor; releases are unsigned and say so.
|
||||
// 4. modules/archive.js inspects the archive in full before a single byte is
|
||||
// unpacked, and only then unpacks it — into a TEMPORARY directory.
|
||||
// 5. The unpacked tree's own `module.json` must agree with the manifest about
|
||||
// what it is. A manifest that promises `uo` and delivers something else is
|
||||
// refused rather than installed under the name it was promised.
|
||||
// 6. Only then is anything moved into `modules/<id>/`, and the directory it
|
||||
// replaces is kept aside until the move has succeeded.
|
||||
//
|
||||
// **Nothing is written into the modules directory until every check has passed**,
|
||||
// and that is not belt-and-braces. Verified against node-tar 7.5.22 while writing
|
||||
// this: extracting an archive whose fourth member escapes upward throws — and
|
||||
// leaves the first three members on disk. A loader scans that directory at
|
||||
// require time on the next boot; a half-unpacked module is a module.
|
||||
//
|
||||
// One thing this file deliberately does NOT do: mount anything. Installing puts
|
||||
// a directory on the volume, and §1.12 means the volume is read at require time,
|
||||
// so the module appears when the process restarts. The admin screen offers that
|
||||
// restart (decision 1); it is not implied here.
|
||||
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const fsp = require('fs/promises')
|
||||
const path = require('path')
|
||||
const { Readable, Transform } = require('stream')
|
||||
const { pipeline } = require('stream/promises')
|
||||
|
||||
const archive = require('./archive')
|
||||
const loader = require('./loader')
|
||||
|
||||
const log = require('../utils/logger')('modules')
|
||||
|
||||
// An install manifest is a small JSON document. A megabyte of it is not a
|
||||
// manifest, and reading it into memory unbounded is the one place this file
|
||||
// would otherwise trust a remote length.
|
||||
const MAX_MANIFEST_BYTES = 256 * 1024
|
||||
// The artifact cap is the archive's own unpacked cap — a compressed bundle
|
||||
// larger than what it is allowed to unpack to has nothing to offer.
|
||||
const MAX_ARTIFACT_BYTES = archive.MAX_BYTES
|
||||
const FETCH_TIMEOUT_MS = 30000
|
||||
// Gitea serves a release asset through at least one redirect. Following them is
|
||||
// necessary; following them blindly is how an allowlist gets bypassed, so each
|
||||
// hop is re-checked and the chain is bounded.
|
||||
const MAX_REDIRECTS = 5
|
||||
|
||||
// Same id rule the loader enforces when scanning, restated rather than imported
|
||||
// so an install cannot put a directory on the volume that the loader would then
|
||||
// refuse to look at.
|
||||
const ID = /^[a-z][a-z0-9-]{1,31}$/
|
||||
const SHA256 = /^[0-9a-f]{64}$/
|
||||
|
||||
class InstallError extends Error {
|
||||
constructor(message, { status = 400 } = {}) {
|
||||
super(message)
|
||||
this.name = 'InstallError'
|
||||
// Carried so the controller can answer 400 for "your URL is wrong" and 502
|
||||
// for "the host you named misbehaved" without re-deriving it from the text.
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
// ── The allowlist ──────────────────────────────────────────────────────────
|
||||
|
||||
// The settings row the allowlist lives in (decision 6): seeded from
|
||||
// MODULE_SOURCE_HOSTS on a fresh install and admin-managed from then on. The KEY
|
||||
// lives here rather than in the admin controller because it is now read from two
|
||||
// places — the controller, and the boot-time resolution of the declared module
|
||||
// set (modules/declared.js), which has no route and no request.
|
||||
const HOSTS_SETTING = 'module_source_hosts'
|
||||
|
||||
/**
|
||||
* Parse the stored allowlist setting into hostnames.
|
||||
*
|
||||
* Comma or whitespace separated, case-insensitive, empty entries dropped. An
|
||||
* empty list means nothing may be installed from anywhere — a refusal, never a
|
||||
* wildcard. That direction matters: a setting an admin accidentally blanks
|
||||
* should stop installs, not permit every host on the internet.
|
||||
*/
|
||||
function parseHosts(value) {
|
||||
return String(value || '')
|
||||
.split(/[,\s]+/)
|
||||
.map((h) => h.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check one URL against the allowlist, and return it parsed.
|
||||
*
|
||||
* `https` only. A plaintext fetch of code this process is going to execute is
|
||||
* not something an operator should be able to opt into by typing a URL, and the
|
||||
* sha256 does not help — whoever can rewrite the artifact in flight can rewrite
|
||||
* the manifest that declares its hash.
|
||||
*/
|
||||
function checkUrl(raw, hosts) {
|
||||
let url
|
||||
try {
|
||||
url = new URL(String(raw))
|
||||
} catch {
|
||||
throw new InstallError(`"${raw}" is not a valid URL`)
|
||||
}
|
||||
if (url.protocol !== 'https:') {
|
||||
throw new InstallError(`only https URLs may be installed from (got "${url.protocol}")`)
|
||||
}
|
||||
if (!hosts.length) {
|
||||
throw new InstallError(
|
||||
'no module source hosts are allowed — set one in Admin → Modules before installing',
|
||||
)
|
||||
}
|
||||
if (!hosts.includes(url.hostname.toLowerCase())) {
|
||||
throw new InstallError(
|
||||
`"${url.hostname}" is not an allowed module source host (allowed: ${hosts.join(', ')})`,
|
||||
)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// ── Fetching ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET a URL, following redirects by hand so every hop is re-checked.
|
||||
*
|
||||
* `fetch`'s own redirect following would take the first hop off the allowlist
|
||||
* and the rest wherever it was pointed, which is exactly the hole the allowlist
|
||||
* exists to close.
|
||||
*
|
||||
* `fetchImpl` is the same injection seam `replayFragments({query})` and
|
||||
* `lifecycle.boot({model})` use, and it exists for the same reason: the rules
|
||||
* this file enforces — https only, allowlisted host, allowlisted REDIRECT host,
|
||||
* bounded body, matching hash — are all decisions about a response, and testing
|
||||
* them against a real TLS server would mean testing Node's certificate handling
|
||||
* instead. Production never passes it.
|
||||
*
|
||||
* @returns {Promise<Response>} a response whose body has not been read.
|
||||
*/
|
||||
async function get(rawUrl, hosts, fetchImpl = fetch) {
|
||||
let url = checkUrl(rawUrl, hosts)
|
||||
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
||||
let res
|
||||
try {
|
||||
res = await fetchImpl(url, {
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
headers: { accept: '*/*' },
|
||||
})
|
||||
} catch (err) {
|
||||
throw new InstallError(`could not reach ${url.hostname}: ${err.message}`, { status: 502 })
|
||||
}
|
||||
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const location = res.headers.get('location')
|
||||
if (!location) throw new InstallError(`${url.hostname} redirected without a location`, { status: 502 })
|
||||
// Resolved against the current URL, then re-checked — a relative redirect
|
||||
// is normal and a cross-host one is the thing being guarded against.
|
||||
url = checkUrl(new URL(location, url).toString(), hosts)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new InstallError(`${url.href} returned ${res.status}`, { status: 502 })
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
throw new InstallError(`too many redirects (more than ${MAX_REDIRECTS})`, { status: 502 })
|
||||
}
|
||||
|
||||
/** Read a bounded response body as text. */
|
||||
async function readText(res, cap, what) {
|
||||
const declared = Number(res.headers.get('content-length'))
|
||||
if (Number.isFinite(declared) && declared > cap) {
|
||||
throw new InstallError(`${what} is larger than ${cap} bytes`)
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer())
|
||||
// Checked again after reading: content-length is the server's claim, not a
|
||||
// limit it is obliged to honour.
|
||||
if (buf.length > cap) throw new InstallError(`${what} is larger than ${cap} bytes`)
|
||||
return buf.toString('utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a response body to a file, hashing as it goes and stopping at the cap.
|
||||
*
|
||||
* The hash is computed from the bytes that were written rather than by re-reading
|
||||
* the file, so there is no window in which the file could differ from what was
|
||||
* verified.
|
||||
*/
|
||||
async function download(res, dest, cap) {
|
||||
const hash = crypto.createHash('sha256')
|
||||
let bytes = 0
|
||||
|
||||
const body = Readable.fromWeb(res.body)
|
||||
const counter = new Transform({
|
||||
transform(chunk, _enc, cb) {
|
||||
bytes += chunk.length
|
||||
if (bytes > cap) {
|
||||
cb(new InstallError(`the artifact is larger than ${Math.round(cap / 1024 / 1024)} MB`))
|
||||
return
|
||||
}
|
||||
hash.update(chunk)
|
||||
cb(null, chunk)
|
||||
},
|
||||
})
|
||||
|
||||
await pipeline(body, counter, fs.createWriteStream(dest))
|
||||
return { sha256: hash.digest('hex'), bytes }
|
||||
}
|
||||
|
||||
// ── The install manifest ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch and validate a release's install manifest.
|
||||
*
|
||||
* The shape is the one module-uo's release workflow already writes:
|
||||
* `{schema, id, name, version, coreApi, artifact, url, sha256, size}`. Only the
|
||||
* fields this side needs are required — a module publisher may carry more.
|
||||
*/
|
||||
async function fetchManifest(manifestUrl, hosts, fetchImpl = fetch) {
|
||||
const res = await get(manifestUrl, hosts, fetchImpl)
|
||||
const text = await readText(res, MAX_MANIFEST_BYTES, 'the install manifest')
|
||||
|
||||
let manifest
|
||||
try {
|
||||
manifest = JSON.parse(text)
|
||||
} catch (err) {
|
||||
throw new InstallError(`the install manifest is not valid JSON: ${err.message}`)
|
||||
}
|
||||
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
||||
throw new InstallError('the install manifest is not a JSON object')
|
||||
}
|
||||
|
||||
const { id, name, version, sha256 } = manifest
|
||||
if (!ID.test(String(id || ''))) {
|
||||
throw new InstallError(`the install manifest has an invalid module id: ${JSON.stringify(id)}`)
|
||||
}
|
||||
if (!name || !version) throw new InstallError('the install manifest is missing name or version')
|
||||
if (!SHA256.test(String(sha256 || '').toLowerCase())) {
|
||||
throw new InstallError('the install manifest has no valid sha256 for its artifact')
|
||||
}
|
||||
|
||||
// `url` is the absolute artifact URL the publisher wrote; `artifact` is its
|
||||
// filename. Prefer the URL, fall back to resolving the filename beside the
|
||||
// manifest — which is where a release's assets sit — so a manifest that
|
||||
// travelled without its absolute URL still installs.
|
||||
const artifactUrl = new URL(manifest.url || manifest.artifact || '', manifestUrl).toString()
|
||||
|
||||
return {
|
||||
id: String(id),
|
||||
name: String(name),
|
||||
version: String(version),
|
||||
sha256: String(sha256).toLowerCase(),
|
||||
size: Number(manifest.size) || null,
|
||||
artifactUrl,
|
||||
coreApi: manifest.coreApi ? String(manifest.coreApi) : null,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Paths on the volume ────────────────────────────────────────────────────
|
||||
|
||||
/** Where a module with this id lives. Rejects an id that is not one. */
|
||||
function moduleDir(id) {
|
||||
if (!ID.test(String(id || ''))) throw new InstallError(`invalid module id: ${JSON.stringify(id)}`)
|
||||
return path.join(loader.dir(), String(id))
|
||||
}
|
||||
|
||||
/** Is there a directory for this module on the volume right now? */
|
||||
function isInstalled(id) {
|
||||
try {
|
||||
return fs.statSync(moduleDir(id)).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The absolute path of a module's `purge.sql`, or null.
|
||||
*
|
||||
* Read from the module's own `module.json` on disk rather than from the loader,
|
||||
* because purge has to work for a module that never loaded — a `startup_failed`
|
||||
* one is exactly when an operator wants its tables gone.
|
||||
*/
|
||||
function purgeFile(id) {
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(moduleDir(id), 'module.json'), 'utf8'))
|
||||
if (!manifest.purge) return null
|
||||
const file = path.resolve(moduleDir(id), manifest.purge)
|
||||
// The same containment check the loader applies to `client.entry`: a
|
||||
// manifest may not point core at a file outside the module.
|
||||
if (!file.startsWith(moduleDir(id) + path.sep)) return null
|
||||
return fs.existsSync(file) ? file : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Install ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Download, verify, unpack and install a module from an install-manifest URL.
|
||||
*
|
||||
* Never leaves a partial module on the volume: everything happens under a
|
||||
* scratch directory that is removed on any failure, and the move into place is
|
||||
* the last step.
|
||||
*
|
||||
* `expect` is what the CALLER was promised, as opposed to what the manifest
|
||||
* promises about itself — the declared module set (modules/declared.js) pins an
|
||||
* id and a version in the environment, and a URL that resolves to something else
|
||||
* has to be refused rather than installed. Checked against the manifest, before
|
||||
* a byte is downloaded: catching it after the unpack would mean the undeclared
|
||||
* module is already on the volume when the objection is raised. The admin panel
|
||||
* passes nothing, because there a URL is the whole of what was asked for.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {string} args.url the install manifest URL the admin pasted
|
||||
* @param {string[]} args.hosts the allowlist, already parsed
|
||||
* @param {{id?: string, version?: string}} [args.expect] what the caller pinned
|
||||
* @returns {Promise<{id,name,version,sha256,source,bytes,replaced}>}
|
||||
*/
|
||||
async function install({ url, hosts, expect = null, fetchImpl = fetch }) {
|
||||
const manifest = await fetchManifest(url, hosts, fetchImpl)
|
||||
|
||||
if (expect && expect.id && manifest.id !== expect.id) {
|
||||
throw new InstallError(
|
||||
`that URL installs the module "${manifest.id}", but "${expect.id}" was asked for`,
|
||||
)
|
||||
}
|
||||
if (expect && expect.version && manifest.version !== expect.version) {
|
||||
throw new InstallError(
|
||||
`that URL installs ${manifest.id} v${manifest.version}, but v${expect.version} was asked for`,
|
||||
)
|
||||
}
|
||||
|
||||
const target = moduleDir(manifest.id)
|
||||
const scratch = await fsp.mkdtemp(path.join(loader.dir(), `.install-${manifest.id}-`))
|
||||
const tarball = path.join(scratch, 'bundle.tar.gz')
|
||||
const unpacked = path.join(scratch, 'unpacked')
|
||||
|
||||
try {
|
||||
const res = await get(manifest.artifactUrl, hosts, fetchImpl)
|
||||
const { sha256, bytes } = await download(res, tarball, MAX_ARTIFACT_BYTES)
|
||||
|
||||
if (sha256 !== manifest.sha256) {
|
||||
// The whole trust model in one comparison. Deliberately does not name the
|
||||
// computed hash in a way that reads like a value to copy into the
|
||||
// manifest — a mismatch means stop, not reconcile.
|
||||
throw new InstallError(
|
||||
`the downloaded artifact does not match the sha256 in the install manifest — refusing to install`,
|
||||
)
|
||||
}
|
||||
if (manifest.size && bytes !== manifest.size) {
|
||||
throw new InstallError(`the artifact is ${bytes} bytes but the manifest declares ${manifest.size}`)
|
||||
}
|
||||
|
||||
await archive.unpack(tarball, unpacked)
|
||||
|
||||
// What the bundle says it is, checked against what the manifest promised.
|
||||
let inner
|
||||
try {
|
||||
inner = JSON.parse(await fsp.readFile(path.join(unpacked, 'module.json'), 'utf8'))
|
||||
} catch {
|
||||
throw new InstallError('the bundle has no readable module.json at its root')
|
||||
}
|
||||
if (inner.id !== manifest.id) {
|
||||
throw new InstallError(
|
||||
`the bundle declares module id "${inner.id}" but the install manifest promised "${manifest.id}"`,
|
||||
)
|
||||
}
|
||||
if (inner.version !== manifest.version) {
|
||||
throw new InstallError(
|
||||
`the bundle declares version "${inner.version}" but the install manifest promised "${manifest.version}"`,
|
||||
)
|
||||
}
|
||||
|
||||
// The swap. The outgoing directory is moved aside rather than deleted first,
|
||||
// so a failed rename leaves the previous version recoverable instead of
|
||||
// leaving no module at all — the same reasoning the installer repo applies
|
||||
// to a ServUO tree.
|
||||
const replaced = fs.existsSync(target)
|
||||
const aside = `${target}.replaced-${Date.now()}`
|
||||
if (replaced) await fsp.rename(target, aside)
|
||||
try {
|
||||
await fsp.rename(unpacked, target)
|
||||
} catch (err) {
|
||||
if (replaced) await fsp.rename(aside, target).catch(() => {})
|
||||
throw err
|
||||
}
|
||||
if (replaced) await fsp.rm(aside, { recursive: true, force: true })
|
||||
|
||||
log.info(`installed module "${manifest.id}" v${manifest.version}`, {
|
||||
source: url,
|
||||
sha256: manifest.sha256,
|
||||
bytes,
|
||||
replaced,
|
||||
})
|
||||
|
||||
return { ...manifest, source: url, bytes, replaced }
|
||||
} finally {
|
||||
await fsp.rm(scratch, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a module's directory from the volume.
|
||||
*
|
||||
* Returns whether there was one. Does not touch the database, does not run
|
||||
* purge.sql, and does not stop the running module — the controller sequences
|
||||
* all three, because the order matters and only it knows what the operator
|
||||
* asked for.
|
||||
*/
|
||||
async function removeDir(id) {
|
||||
const dir = moduleDir(id)
|
||||
if (!fs.existsSync(dir)) return false
|
||||
await fsp.rm(dir, { recursive: true, force: true })
|
||||
log.info(`removed module directory for "${id}"`)
|
||||
return true
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
InstallError,
|
||||
HOSTS_SETTING,
|
||||
parseHosts,
|
||||
checkUrl,
|
||||
get,
|
||||
fetchManifest,
|
||||
install,
|
||||
removeDir,
|
||||
moduleDir,
|
||||
isInstalled,
|
||||
purgeFile,
|
||||
MAX_MANIFEST_BYTES,
|
||||
MAX_ARTIFACT_BYTES,
|
||||
}
|
||||
@@ -101,10 +101,16 @@ async function boot({ modules, model } = {}) {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
version: m.version,
|
||||
// Null provenance is what a hand-placed directory looks like. An install
|
||||
// performed through the admin panel (§2.5, a later phase) writes the row
|
||||
// with its source and hash first; this refresh deliberately does not
|
||||
// overwrite either, because recordInstalled leaves what it is not given.
|
||||
// Null provenance is what a hand-placed directory looks like, and it is
|
||||
// all this step can honestly say: a scan finds a directory, never where it
|
||||
// came from. An install through the admin panel writes source and sha256
|
||||
// first, and this refresh must not undo that.
|
||||
//
|
||||
// It used to. `upsert` assigned both columns unconditionally, so every
|
||||
// boot nulled them and an install's provenance survived only until the
|
||||
// restart it asked for. The statement now COALESCEs — see the note on
|
||||
// modules.db.js's upsert. Nothing could have caught it before Phase 4:
|
||||
// this was the only caller, and it has never had a value to pass.
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -205,4 +211,67 @@ async function shutdown({ modules, budgetMs = SHUTDOWN_BUDGET_MS } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { boot, shutdown, SHUTDOWN_BUDGET_MS }
|
||||
/**
|
||||
* Stop ONE module and mark it disabled — the admin panel's Disable (§2.7.2
|
||||
* decision 3).
|
||||
*
|
||||
* Phase 2 built disable as a pure state flip: the record moved to `disabled` and
|
||||
* the dispatch guard started answering 404. That makes a module invisible, not
|
||||
* stopped. Everything a module does that is not a response to a request — the
|
||||
* sockets and timers its `onBoot` armed — carried on running, so an operator
|
||||
* disabling a module *because* it was misbehaving got nothing until the next
|
||||
* restart, which is the one thing the button was supposed to save them.
|
||||
*
|
||||
* So the hook runs first, and the state moves after it: while `onShutdown` is
|
||||
* running the module is still `started`, which is the only state in which its
|
||||
* own routes and the things it is tearing down are consistent with each other.
|
||||
* The hook gets the same budget the exit path gives it.
|
||||
*
|
||||
* A hook that throws does NOT stop the disable. The operator asked for this
|
||||
* module to stop answering; a module that could not close cleanly is a reason to
|
||||
* log loudly, not a reason to leave it serving. That is the opposite of the boot
|
||||
* path's rule, and deliberately: there, a failure means the module never became
|
||||
* safe to use.
|
||||
*
|
||||
* **Enable is not the mirror of this, and there is no `start(id)` beside it.**
|
||||
* There is no `onBoot` re-dispatch, and MODULE_API.md has never promised the
|
||||
* hooks are re-entrant — no module author has written `onBoot` to be safe to run
|
||||
* twice in one process. Re-enabling therefore moves the row and waits for a
|
||||
* restart, which the admin screen offers.
|
||||
*
|
||||
* @returns {Promise<{stopped: boolean, error: string|null}>} whether a hook ran.
|
||||
*/
|
||||
async function stop(id, { modules, model, budgetMs = SHUTDOWN_BUDGET_MS } = {}) {
|
||||
/* eslint-disable global-require */
|
||||
const loader = modules || require('./loader')
|
||||
const rows = model || require('../model/modules/modules.model')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
let error = null
|
||||
let stopped = false
|
||||
|
||||
if (loader.isLoaded()) {
|
||||
const target = loader.stopHook(id)
|
||||
if (target) {
|
||||
try {
|
||||
await withBudget(target.hook, budgetMs)
|
||||
stopped = true
|
||||
log.info(`module "${id}" stopped by an operator`)
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
log.warn(`module "${id}" onShutdown failed or timed out while being disabled — disabling anyway`, {
|
||||
error: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Unconditional, and after the hook: this is what makes its routes and its
|
||||
// client chunk answer 404 (§4.5). A module that is not loaded in this
|
||||
// process has no record to move, and setState ignores an unknown id.
|
||||
loader.setState(id, 'disabled')
|
||||
}
|
||||
|
||||
await safe(`disabling module "${id}"`, () => rows.disable(id))
|
||||
return { stopped, error }
|
||||
}
|
||||
|
||||
module.exports = { boot, shutdown, stop, SHUTDOWN_BUDGET_MS }
|
||||
|
||||
@@ -45,7 +45,14 @@ const { splitStatements } = require('../utils/sqlStatements')
|
||||
const log = require('../utils/logger')('modules')
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..', '..')
|
||||
const MODULES_DIR = process.env.MODULES_DIR || path.join(REPO_ROOT, 'modules')
|
||||
// Resolved absolute, and the `path.resolve` is load-bearing rather than tidy.
|
||||
// `resolveClient` compares an absolute `path.resolve(dir, entry)` against this
|
||||
// directory to check containment, so a RELATIVE `MODULES_DIR` — which is what
|
||||
// anyone following §7.7's smoke recipe from `server/` naturally types — makes
|
||||
// that comparison fail for every module, with the thoroughly misleading
|
||||
// "client.entry escapes the module directory". Found the first time the smoke
|
||||
// was run against a module with a real client half.
|
||||
const MODULES_DIR = path.resolve(process.env.MODULES_DIR || path.join(REPO_ROOT, 'modules'))
|
||||
|
||||
// One segment, lowercase, no parameters. A module prefix that could contain a
|
||||
// `/` or a `:` would let a module reach outside the slot it was given.
|
||||
@@ -67,6 +74,13 @@ const MANIFEST_KEYS = new Set([
|
||||
const modules = new Map()
|
||||
let loaded = false
|
||||
|
||||
// Bumped by every state CHANGE. One consumer today: the merged OpenAPI document
|
||||
// at /api/docs.json, which is built from the fragments of `started` modules and
|
||||
// so has to be rebuilt when that set moves (§6.1a). A counter rather than an
|
||||
// event, because the question a cache asks is "is what I have still current",
|
||||
// and a number answers it without anyone having to remember to subscribe.
|
||||
let stateVersion = 0
|
||||
|
||||
// ── ctx ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Everything a module may reach in core, and nothing else (§2.3). Required
|
||||
@@ -702,6 +716,7 @@ function setState(id, state, { stage = null, reason = null } = {}) {
|
||||
if (!RECORD_STATES.has(state)) throw new Error(`unknown module state "${state}"`)
|
||||
const record = modules.get(id)
|
||||
if (!record) return
|
||||
if (record.state !== state) stateVersion += 1
|
||||
record.state = state
|
||||
record.stage = state === 'startup_failed' ? stage : null
|
||||
record.reason = state === 'startup_failed' ? reason : null
|
||||
@@ -781,6 +796,31 @@ function shutdownHooks() {
|
||||
.reverse()
|
||||
}
|
||||
|
||||
/**
|
||||
* One module's `onShutdown`, for stopping it on its own rather than at exit.
|
||||
*
|
||||
* Phase 4 (§2.7.2 decision 3) gave the admin panel's Disable a real meaning.
|
||||
* Until then, disabling flipped this record's state and the dispatch guard began
|
||||
* answering 404 — which made the module invisible without making it stop. A
|
||||
* module's `onBoot` is where it opens its sockets and arms its timers, and none
|
||||
* of that is reachable through a URL, so an operator disabling a misbehaving
|
||||
* module got no relief from it at all until the next restart.
|
||||
*
|
||||
* `started` only, the same rule shutdownHooks() applies and for the same reason:
|
||||
* a module whose `onBoot` threw has a half-built world its `onShutdown` was
|
||||
* never written to tear down. Returns null when there is nothing to run — which
|
||||
* covers "not started", "no hook", and "no such module", none of which is an
|
||||
* error the caller can act on differently.
|
||||
*
|
||||
* @returns {{id: string, hook: Function}|null}
|
||||
*/
|
||||
function stopHook(id) {
|
||||
assertLoaded('stopHook')
|
||||
const record = modules.get(id)
|
||||
if (!record || record.state !== 'started' || !record.hooks.onShutdown) return null
|
||||
return { id: record.id, hook: record.hooks.onShutdown }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every schema fragment waiting to be replayed, in scan order.
|
||||
*
|
||||
@@ -838,6 +878,39 @@ function clientEntryUrls() {
|
||||
.map((r) => r.client.entryUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every started module's OpenAPI fragment, in scan order.
|
||||
*
|
||||
* `started` only, matching clientEntryUrls() rather than clientChunks(): the
|
||||
* merged document is built when it is asked for, at which point the state is
|
||||
* known, and documenting a module that is 503ing every one of those paths would
|
||||
* send a client somewhere it cannot go.
|
||||
*
|
||||
* The filename is fixed by §2.8 — `swagger-fragment.json` in the bundle root —
|
||||
* rather than declared in `module.json`, so a module cannot point core at
|
||||
* something else. A module that ships none is simply absent: registering routes
|
||||
* without documenting them is checked in the module's OWN CI (§2.8), where the
|
||||
* routes are known; core has no way to tell the difference here between a module
|
||||
* with no routes and one that forgot.
|
||||
*
|
||||
* @returns {{id: string, file: string}[]}
|
||||
*/
|
||||
function specFragments() {
|
||||
assertLoaded('specFragments')
|
||||
return [...modules.values()]
|
||||
.filter((r) => r.state === 'started')
|
||||
.map((r) => ({ id: r.id, file: path.join(r.dir, 'swagger-fragment.json') }))
|
||||
.filter((f) => fs.existsSync(f.file))
|
||||
}
|
||||
|
||||
/**
|
||||
* How many times a module's state has CHANGED in this process.
|
||||
*
|
||||
* A cache key, and nothing more: hold the value you built with, compare, rebuild
|
||||
* when it differs. It says nothing about which module moved or where to.
|
||||
*/
|
||||
const version = () => stateVersion
|
||||
|
||||
/** Absolute path of the modules directory. */
|
||||
const dir = () => MODULES_DIR
|
||||
|
||||
@@ -848,8 +921,11 @@ module.exports = {
|
||||
fragments,
|
||||
bootable,
|
||||
shutdownHooks,
|
||||
stopHook,
|
||||
clientChunks,
|
||||
clientEntryUrls,
|
||||
specFragments,
|
||||
version,
|
||||
isLoaded,
|
||||
dir,
|
||||
}
|
||||
|
||||
@@ -81,4 +81,48 @@ async function replayFragments({ query, modules } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { replayFragments }
|
||||
/**
|
||||
* Run one module's `purge.sql` — the destructive twin of the replay above
|
||||
* (MODULE_SYSTEM.md §2.5, and §2.7.2 decision 5 for when it is offered).
|
||||
*
|
||||
* It lives beside replayFragments because they are the same operation pointed in
|
||||
* opposite directions: a file of statements the module ships, split by the same
|
||||
* splitter, run serially on the same pool. Keeping them together is what makes
|
||||
* it obvious that the fragment's leading-verb allowlist does NOT apply here —
|
||||
* `purge.sql` is the one file a module may put a DROP in, precisely because it
|
||||
* is the one file that never runs on a boot.
|
||||
*
|
||||
* Unlike the replay, this **throws**. A replay failure is one module failing to
|
||||
* start, which the site survives by 503ing that module; a purge failure is an
|
||||
* operator's explicit destructive request not having happened, and reporting
|
||||
* success for that would leave them believing data is gone when it is not.
|
||||
*
|
||||
* Statements run serially and are not wrapped in a transaction, for the reason
|
||||
* the replay's header already gives: MariaDB commits DDL implicitly, so there is
|
||||
* no rollback to have. A purge that fails halfway has dropped some tables — the
|
||||
* error names the statement that stopped it, and running it again is safe
|
||||
* because a purge script is required to be idempotent in the same way a fragment
|
||||
* is (`DROP TABLE IF EXISTS`).
|
||||
*
|
||||
* @param {string} file absolute path to the module's purge.sql
|
||||
* @param {object} [deps] injection seam for tests
|
||||
* @returns {Promise<number>} how many statements ran
|
||||
*/
|
||||
async function runPurge(file, { query } = {}) {
|
||||
// eslint-disable-next-line global-require
|
||||
const run = query || require('../utils/db').query
|
||||
|
||||
const statements = splitStatements(fs.readFileSync(file, 'utf8'))
|
||||
let ran = 0
|
||||
for (const statement of statements) {
|
||||
try {
|
||||
await run(statement)
|
||||
ran += 1
|
||||
} catch (err) {
|
||||
throw new Error(`purge failed at statement ${ran + 1} of ${statements.length}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
return ran
|
||||
}
|
||||
|
||||
module.exports = { replayFragments, runPurge }
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
||||
// has nothing to say about a website module) and from any module's own version.
|
||||
|
||||
// 1.3.0 — three CLIENT additions from Phase 3 slice 3: a nav item may carry an
|
||||
// `icon`, core declares a `player.invite.accepted` slot, and `window.__rg.api`
|
||||
// gained `BASE` (which §3.5 always specified and shared.js never published).
|
||||
// Nothing on the server changed; this file bumps for the reason below.
|
||||
//
|
||||
// 1.2.0 — the CLIENT registry gained `registerExtension` and core gained client
|
||||
// extension slots (MODULE_API.md §3.7): the twin of this half's declareSlot /
|
||||
// registerExtension, for module content inside a core *page* rather than under a
|
||||
@@ -21,6 +26,6 @@
|
||||
// an admin action a module performs belongs in core's one audit log, the
|
||||
// extension slot needs the user its prefix names, and §2.7 forbids a module
|
||||
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
|
||||
const MODULE_API_VERSION = '1.2.0'
|
||||
const MODULE_API_VERSION = '1.3.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
|
||||
@@ -520,12 +520,12 @@ async function updateSettings(req, res) {
|
||||
) {
|
||||
return res.status(400).json({ message: 'Invalid player_registration value' })
|
||||
}
|
||||
if (
|
||||
settings.GAME_SIGNUP_KEY in updates &&
|
||||
!settings.GAME_SIGNUP_MODES.includes(updates[settings.GAME_SIGNUP_KEY])
|
||||
) {
|
||||
return res.status(400).json({ message: 'Invalid game_account_signup value' })
|
||||
}
|
||||
// `game_account_signup` was validated here until Phase 3 slice 3. The key and
|
||||
// its stored value are unchanged, but the four legal modes are a fact about a
|
||||
// ServUO shard, so module-uo owns them and validates on its own route. This
|
||||
// endpoint takes arbitrary keys either way, and an unrecognised value resolves
|
||||
// to `disabled` on read — the module's gate fails closed, which is the right
|
||||
// direction for "may this player mint a game account".
|
||||
// App Links toggle is a boolean stored as a 'true'/'false' string; accept a real
|
||||
// boolean or those two strings and normalize, reject anything else.
|
||||
if (settings.MOBILE_APP_LINKS_KEY in updates) {
|
||||
|
||||
@@ -30,6 +30,7 @@ const pagesRouter = require('./pages.router')
|
||||
const emailRouter = require('./email.router')
|
||||
const discordBotRouter = require('./discordBot.router')
|
||||
const settingsRouter = require('./settings.router')
|
||||
const modulesRouter = require('./modules.router')
|
||||
const dashboardRouter = require('./dashboard.router')
|
||||
|
||||
const adminRouter = express.Router()
|
||||
@@ -73,6 +74,11 @@ adminRouter.use('/pages', pagesRouter)
|
||||
adminRouter.use('/email', emailRouter)
|
||||
adminRouter.use('/discord-bot', discordBotRouter)
|
||||
adminRouter.use('/settings', settingsRouter)
|
||||
// The Modules screen (Phase 4). Core's, not a module's — and it has to be
|
||||
// core's: it is how a module gets onto the volume in the first place. Mounted
|
||||
// here alongside the other configuration capabilities, and admin-only per route
|
||||
// rather than at this line, so the gate sits next to what it is guarding.
|
||||
adminRouter.use('/modules', modulesRouter)
|
||||
|
||||
// The two singletons that own no path segment of their own: GET /dashboard and
|
||||
// PUT /site-mode. Mounted at the group root, last, exactly where the residual
|
||||
|
||||
@@ -19,7 +19,7 @@ invitesRouter.post(
|
||||
// #swagger.tags = ['Admin · Invites']
|
||||
// #swagger.summary = 'Create and email an account invite at a chosen access level'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } */
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Invite created', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
adminOnly,
|
||||
|
||||
426
server/src/router/v1/admin/modules.controller.js
Normal file
426
server/src/router/v1/admin/modules.controller.js
Normal file
@@ -0,0 +1,426 @@
|
||||
// ── Admin: installed modules ───────────────────────────────────────────────
|
||||
//
|
||||
// Phase 4, slice 1 of docs/website/MODULE_SYSTEM.md §2.7.2. Admin-only, and more
|
||||
// so than anything else in this directory: installing a module puts JavaScript on
|
||||
// the volume that core will `require` into its own process on the next boot. That
|
||||
// is the feature — it is what "an operator never builds anything" means (§1.14) —
|
||||
// but it is worth being plain that this controller is remote code execution with
|
||||
// an audit trail, not a settings screen.
|
||||
//
|
||||
// What guards it, in the order an attacker would meet them:
|
||||
//
|
||||
// 1. `requireRole('admin')` on every route, on top of the group's staff gate.
|
||||
// 2. An `https`-only host allowlist, re-checked on every redirect hop, so a
|
||||
// pasted URL cannot be pointed at the compose network or a metadata service
|
||||
// (install.js).
|
||||
// 3. The sha256 the release published, compared against the bytes that arrived.
|
||||
// 4. A full inspection of the archive before a byte of it is unpacked, and an
|
||||
// unpack into a scratch directory that is only moved into place once the
|
||||
// bundle has agreed with the manifest about what it is (archive.js).
|
||||
// 5. Every action here writes to core's one audit log.
|
||||
//
|
||||
// The one thing this file cannot do is mount anything. §1.12 makes the volume the
|
||||
// mounting source of truth, read once at require time, so install and uninstall
|
||||
// take effect on the next boot — which is why `restart` is a route here rather
|
||||
// than a sentence in a tooltip (decision 1).
|
||||
|
||||
const modules = require('../../../model/modules/modules.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
const loader = require('../../../modules/loader')
|
||||
const lifecycle = require('../../../modules/lifecycle')
|
||||
const install = require('../../../modules/install')
|
||||
const declared = require('../../../modules/declared')
|
||||
// A namespace import, like every other require in this file, and not
|
||||
// `const { runPurge } = …`: destructuring at require time captures the function
|
||||
// rather than the module, which makes it the one dependency here that cannot be
|
||||
// substituted. That matters because the two tests worth having about purge are
|
||||
// about the ORDER it runs in relative to the directory being removed.
|
||||
const schema = require('../../../modules/schema')
|
||||
|
||||
const log = require('../../../utils/logger')('admin-modules')
|
||||
|
||||
// The allowlist setting. Seeded from MODULE_SOURCE_HOSTS on first boot and
|
||||
// admin-managed from then on (decision 6) — db/seed.js writes it once and never
|
||||
// overwrites it, so changing the variable later does not silently reach in and
|
||||
// undo an operator's choice. The key itself lives in install.js, which is now
|
||||
// read by boot-time declared-set resolution as well as by this controller.
|
||||
const HOSTS_KEY = install.HOSTS_SETTING
|
||||
|
||||
// A hostname, not a URL: no scheme, no path, no port, no wildcard. Deliberately
|
||||
// strict — every character allowed here is a character that can appear in the
|
||||
// host of a URL this server will fetch and execute the contents of.
|
||||
const HOSTNAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/
|
||||
|
||||
async function allowedHosts() {
|
||||
return install.parseHosts(await settings.get(HOSTS_KEY))
|
||||
}
|
||||
|
||||
/**
|
||||
* One module, as the admin screen needs it.
|
||||
*
|
||||
* Four sources have to be reconciled, and which one answers which question is
|
||||
* the whole of §2.4:
|
||||
*
|
||||
* - the ROW says what the operator decided and what the last boot recorded;
|
||||
* - the LOADER says what is mounted and answering right now;
|
||||
* - the VOLUME says whether there is still a directory there at all;
|
||||
* - the DECLARATION (slice 3) says what this container's environment asks for,
|
||||
* which is the only one of the four an admin cannot change from this screen.
|
||||
*
|
||||
* They can legitimately disagree, and the screen has to show that rather than
|
||||
* pick a winner. A row `enabled` with a loader state of `disabled` is a module
|
||||
* the operator has just switched back on and which is waiting for a restart —
|
||||
* exactly the case decision 3 creates, and it would be a lie to render it as
|
||||
* either "running" or "off". A declared module with no row and no directory is
|
||||
* the newest of those disagreements: MODULES asked for it and resolution could
|
||||
* not get it, so the screen carries the reason rather than showing nothing.
|
||||
*/
|
||||
function present(row, live, onVolume, declaration = null) {
|
||||
const id = row ? row.id : live ? live.id : declaration.id
|
||||
return {
|
||||
id,
|
||||
name: row ? row.name : live ? live.name : id,
|
||||
version: row ? row.version : live ? live.version : null,
|
||||
// What the database records.
|
||||
state: row ? row.state : null,
|
||||
failureStage: row ? row.failureStage : (live && live.stage) || null,
|
||||
failureReason: row ? row.failureReason : (live && live.reason) || null,
|
||||
source: row ? row.source : null,
|
||||
sha256: row ? row.sha256 : null,
|
||||
installedAt: row ? row.installedAt : null,
|
||||
startedAt: row ? row.startedAt : null,
|
||||
// What is actually mounted in this process, and what it is answering.
|
||||
liveState: live ? live.state : null,
|
||||
// The version RUNNING, which is not always the version installed: an upgrade
|
||||
// writes new files and a new row while the old code stays loaded until the
|
||||
// restart. Without this the screen would report the new version as
|
||||
// "Running", which is the same lie in a different place.
|
||||
liveVersion: live ? live.version : null,
|
||||
capabilities: live ? live.capabilities : [],
|
||||
// What is on the volume.
|
||||
onVolume,
|
||||
canPurge: onVolume && Boolean(install.purgeFile(id)),
|
||||
// What the environment declares. `declaredVersion` is what MODULES pins, not
|
||||
// what is installed — they differ exactly while a resolution is failing, and
|
||||
// `declaredError` says why. Uninstalling a declared module from this screen
|
||||
// removes its directory and disables its row; the next boot puts the
|
||||
// directory back and leaves the row disabled, so the screen says so rather
|
||||
// than letting the files reappear unexplained.
|
||||
declared: Boolean(declaration),
|
||||
declaredVersion: declaration ? declaration.version : null,
|
||||
declaredError: declaration && declaration.action === 'failed' ? declaration.message : null,
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/modules — every module core knows about, from all three sources,
|
||||
// plus the source allowlist the install form needs.
|
||||
async function list(req, res) {
|
||||
try {
|
||||
const rows = await modules.list()
|
||||
// The loader throws rather than returning [] before load() has run (§7.6),
|
||||
// and this controller is reachable from a process where that is true —
|
||||
// `npm run seed` never gets here, but a test harness might.
|
||||
const live = loader.isLoaded() ? loader.list() : []
|
||||
const byId = new Map(live.map((m) => [m.id, m]))
|
||||
const declaredById = new Map(declared.state().map((d) => [d.id, d]))
|
||||
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const row of rows) {
|
||||
seen.add(row.id)
|
||||
out.push(
|
||||
present(row, byId.get(row.id) || null, install.isInstalled(row.id), declaredById.get(row.id)),
|
||||
)
|
||||
}
|
||||
// A directory on the volume that has no row yet — a hand-placed install
|
||||
// before its first boot. It has to be listed, or the screen would show
|
||||
// nothing for a module whose routes are already being served.
|
||||
for (const m of live) {
|
||||
if (!seen.has(m.id)) {
|
||||
seen.add(m.id)
|
||||
out.push(present(null, m, true, declaredById.get(m.id)))
|
||||
}
|
||||
}
|
||||
// A module MODULES declares that has neither. Resolution failed and left
|
||||
// nothing behind — the case an operator most needs told, because from the
|
||||
// screen's other three sources it is indistinguishable from never having
|
||||
// asked for it.
|
||||
for (const d of declaredById.values()) {
|
||||
if (!seen.has(d.id)) out.push(present(null, null, install.isInstalled(d.id), d))
|
||||
}
|
||||
|
||||
return res.json({ modules: out, sourceHosts: await allowedHosts() })
|
||||
} catch (err) {
|
||||
log.error('list modules', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/modules — install (or upgrade) from an install-manifest URL.
|
||||
async function create(req, res) {
|
||||
const url = String(req.body.url || '').trim()
|
||||
try {
|
||||
const hosts = await allowedHosts()
|
||||
const result = await install.install({ url, hosts })
|
||||
|
||||
// Provenance is written here and nowhere else: the boot reconcile records a
|
||||
// module with NULL source/sha256 and leaves what it is not given, precisely
|
||||
// so that a refresh cannot overwrite what an install knew (lifecycle.js).
|
||||
const row = await modules.recordInstalled({
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
version: result.version,
|
||||
source: result.source,
|
||||
sha256: result.sha256,
|
||||
})
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
userId: req.user.id,
|
||||
action: 'module.install',
|
||||
detail: { id: result.id, version: result.version, source: url, sha256: result.sha256, replaced: result.replaced },
|
||||
})
|
||||
log.warn('module installed — it will mount on the next restart', {
|
||||
id: result.id,
|
||||
version: result.version,
|
||||
by: req.user.username,
|
||||
})
|
||||
|
||||
return res.status(201).json({ module: row, restartRequired: true, replaced: result.replaced })
|
||||
} catch (err) {
|
||||
if (err.name === 'InstallError' || err.name === 'ArchiveError') {
|
||||
// The operator pasted a URL and something about what came back was wrong.
|
||||
// The message is the useful part and is written to be read by them.
|
||||
log.warn('module install refused', { url, reason: err.message })
|
||||
return res.status(err.status || 400).json({ message: err.message })
|
||||
}
|
||||
log.error('install module', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/modules/:id/enable — switch a module back on, for the next boot.
|
||||
//
|
||||
// Deliberately does NOT touch the loader's record. Disable ran the module's
|
||||
// onShutdown (decision 3), and there is no onBoot re-dispatch to undo that: a
|
||||
// module whose sockets were closed and timers cleared cannot be made to serve
|
||||
// again by flipping a flag, and pretending otherwise would put it back on the
|
||||
// nav with a torn-down world behind it. The row moves; the restart starts it.
|
||||
async function enable(req, res) {
|
||||
const { id } = req.params
|
||||
try {
|
||||
const row = await modules.enable(id)
|
||||
if (!row) return res.status(404).json({ message: 'No such module.' })
|
||||
|
||||
await activity.log({ req, userId: req.user.id, action: 'module.enable', detail: { id } })
|
||||
return res.json({ module: row, restartRequired: true })
|
||||
} catch (err) {
|
||||
if (err.name === 'ModuleStateError') return res.status(409).json({ message: err.message })
|
||||
log.error('enable module', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/modules/:id/disable — stop it now.
|
||||
//
|
||||
// The one action on this screen that takes effect without a restart, and the
|
||||
// reason it does is that it is the one an operator reaches for when something is
|
||||
// going wrong. Its routes answer 404 from the moment this returns, and its
|
||||
// onShutdown has already run.
|
||||
async function disable(req, res) {
|
||||
const { id } = req.params
|
||||
try {
|
||||
const current = await modules.get(id)
|
||||
if (!current) return res.status(404).json({ message: 'No such module.' })
|
||||
|
||||
const { stopped, error } = await lifecycle.stop(id)
|
||||
const row = await modules.get(id)
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
userId: req.user.id,
|
||||
action: 'module.disable',
|
||||
detail: { id, hookRan: stopped, hookError: error },
|
||||
})
|
||||
log.warn('module disabled by an operator', { id, hookRan: stopped, by: req.user.username })
|
||||
|
||||
// `shutdownError` is reported rather than swallowed: the module IS disabled
|
||||
// either way, and an operator whose module could not close cleanly should be
|
||||
// told so while they still have the logs to look at.
|
||||
return res.json({ module: row, stopped, shutdownError: error })
|
||||
} catch (err) {
|
||||
log.error('disable module', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /admin/modules/:id[?purge=true] — uninstall.
|
||||
//
|
||||
// Non-destructive by default (§2.5): the directory goes, the row stays
|
||||
// `disabled`, and the module's tables and data are left alone.
|
||||
//
|
||||
// The purge option is here rather than as a follow-up action because it cannot
|
||||
// be a follow-up action (decision 5): `purge.sql` is a file inside the directory
|
||||
// this is about to delete, so after an uninstall there is nothing left to purge
|
||||
// with. Ticking the box is the last moment the file exists.
|
||||
//
|
||||
// The order below is the whole of it, and each step depends on the one above:
|
||||
// purge while the SQL is still readable, stop while the code is still loaded,
|
||||
// then delete.
|
||||
async function remove(req, res) {
|
||||
const { id } = req.params
|
||||
const purge = req.query.purge === 'true' || req.query.purge === '1'
|
||||
try {
|
||||
const current = await modules.get(id)
|
||||
const onVolume = install.isInstalled(id)
|
||||
if (!current && !onVolume) return res.status(404).json({ message: 'No such module.' })
|
||||
|
||||
let purged = null
|
||||
if (purge) {
|
||||
const file = install.purgeFile(id)
|
||||
if (!file) {
|
||||
return res.status(400).json({
|
||||
message: 'This module ships no purge.sql, so its data cannot be deleted. Uninstall without purging instead.',
|
||||
})
|
||||
}
|
||||
purged = await schema.runPurge(file)
|
||||
}
|
||||
|
||||
// Stop it before its files vanish. A module whose directory is deleted out
|
||||
// from under a running onShutdown is being asked to tear down a world whose
|
||||
// code may already be half-unreadable — and its sockets would otherwise stay
|
||||
// open until the restart, holding a connection on behalf of a module that no
|
||||
// longer exists on disk.
|
||||
await lifecycle.stop(id)
|
||||
const removed = await install.removeDir(id)
|
||||
|
||||
// A purge leaves nothing: no directory, no tables, no data. Keeping a
|
||||
// `disabled` row for that is a tombstone with nothing to offer and a Purge
|
||||
// button that would fail. A plain uninstall keeps its row, which is what
|
||||
// makes the retained data visible and reinstallable.
|
||||
if (purge) await modules.remove(id)
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
userId: req.user.id,
|
||||
action: purge ? 'module.purge' : 'module.uninstall',
|
||||
detail: { id, purged, removed },
|
||||
})
|
||||
log.warn(`module ${purge ? 'uninstalled and purged' : 'uninstalled'}`, {
|
||||
id,
|
||||
statements: purged,
|
||||
by: req.user.username,
|
||||
})
|
||||
|
||||
return res.json({ id, removed, purged, restartRequired: true })
|
||||
} catch (err) {
|
||||
log.error('uninstall module', err)
|
||||
return res.status(500).json({ message: err.message || 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/modules/:id/purge — drop a still-installed module's data.
|
||||
//
|
||||
// Refuses unless the module is already disabled, and that guard is the point:
|
||||
// dropping the tables under a module that is still serving requests leaves it
|
||||
// answering out of a world that no longer exists. Disabling first is one click
|
||||
// and makes the destructive step happen against something that has stopped.
|
||||
async function purge(req, res) {
|
||||
const { id } = req.params
|
||||
try {
|
||||
const current = await modules.get(id)
|
||||
if (!current) return res.status(404).json({ message: 'No such module.' })
|
||||
if (current.state !== 'disabled') {
|
||||
return res.status(409).json({
|
||||
message: 'Disable this module before purging its data, so nothing is serving out of the tables being dropped.',
|
||||
})
|
||||
}
|
||||
|
||||
const file = install.purgeFile(id)
|
||||
if (!file) {
|
||||
return res.status(400).json({ message: 'This module ships no purge.sql, so its data cannot be deleted.' })
|
||||
}
|
||||
|
||||
const statements = await schema.runPurge(file)
|
||||
await activity.log({ req, userId: req.user.id, action: 'module.purge', detail: { id, statements } })
|
||||
log.warn('module data purged', { id, statements, by: req.user.username })
|
||||
|
||||
return res.json({ id, purged: statements })
|
||||
} catch (err) {
|
||||
log.error('purge module', err)
|
||||
return res.status(500).json({ message: err.message || 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /admin/modules/sources — the host allowlist.
|
||||
async function setSources(req, res) {
|
||||
const hosts = install.parseHosts(req.body.hosts)
|
||||
const bad = hosts.find((h) => !HOSTNAME.test(h))
|
||||
if (bad) return res.status(400).json({ message: `"${bad}" is not a valid hostname.` })
|
||||
|
||||
try {
|
||||
const before = await allowedHosts()
|
||||
await settings.set(HOSTS_KEY, hosts.join(','), req.user.id)
|
||||
await activity.log({
|
||||
req,
|
||||
userId: req.user.id,
|
||||
action: 'module.sources',
|
||||
detail: { before, after: hosts },
|
||||
})
|
||||
log.warn('module source allowlist changed', { before, after: hosts, by: req.user.username })
|
||||
return res.json({ sourceHosts: hosts })
|
||||
} catch (err) {
|
||||
log.error('set module sources', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/modules/restart — restart the server process.
|
||||
//
|
||||
// Decision 1. Install, uninstall and re-enable all only take effect at boot
|
||||
// because §1.12 reads the volume at require time, and §2.4 promises recovery
|
||||
// "with no shell access to the box" — which a banner saying "please restart your
|
||||
// container" does not deliver.
|
||||
//
|
||||
// It reaches server.js's existing SIGTERM handler rather than doing the work
|
||||
// itself: that handler stops the modules, the workers and the listeners in the
|
||||
// right order and closes the pool and the log file before exiting 0, and going
|
||||
// through it means there is exactly one graceful-shutdown path that this route
|
||||
// cannot drift from.
|
||||
//
|
||||
// It gets there by EMITTING the event, not by signalling the process, and that
|
||||
// is not a detail. `process.kill(process.pid, 'SIGTERM')` is what this did
|
||||
// first, and it works on Linux — but **Windows has no POSIX signals, and Node
|
||||
// documents SIGTERM there as unconditional termination of the target process**.
|
||||
// So on a Windows host the restart killed the server outright: no module
|
||||
// `onShutdown`, no pool close, no log flush. Verified by running it — the
|
||||
// process was gone and the shutdown handler had logged nothing.
|
||||
//
|
||||
// `process.on('SIGTERM', …)` is an ordinary EventEmitter listener, so
|
||||
// `process.emit('SIGTERM')` invokes exactly the same handler on every platform
|
||||
// without involving the OS at all. Deployment is Linux containers and would
|
||||
// never have shown this; development is not.
|
||||
//
|
||||
// What brings the process BACK is the supervisor, not this. The shipped
|
||||
// docker-compose.yml declares `restart: unless-stopped` on `app`, which restarts
|
||||
// on a clean exit as well as a crash. A bare `npm start` does not come back, and
|
||||
// the screen says so before it asks.
|
||||
function restart(req, res) {
|
||||
log.warn('restart requested from the admin panel', { by: req.user.username })
|
||||
|
||||
// Logged and answered first. Once the signal is raised the response has no
|
||||
// listener left to flush through, so the operator would be told nothing.
|
||||
res.status(202).json({ restarting: true })
|
||||
|
||||
activity
|
||||
.log({ req, userId: req.user.id, action: 'module.restart', detail: {} })
|
||||
.catch((err) => log.error('failed to record the restart in the audit log', err))
|
||||
.finally(() => {
|
||||
// A beat, so the 202 is on the wire. `unref` so this timer is not itself
|
||||
// something keeping the process alive.
|
||||
setTimeout(() => process.emit('SIGTERM'), 250).unref()
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { list, create, enable, disable, remove, purge, setSources, restart, HOSTS_KEY }
|
||||
143
server/src/router/v1/admin/modules.router.js
Normal file
143
server/src/router/v1/admin/modules.router.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// Admin · Modules — install, enable, disable, uninstall, purge and restart.
|
||||
//
|
||||
// Mounted at /api/v1/admin/modules by admin/index.js, which has already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. Every route here re-gates to `admin`: an
|
||||
// editor or moderator has no business installing code into the server process,
|
||||
// and the group gate alone would let them.
|
||||
//
|
||||
// Route order matters in one place. `/restart` and `/sources` are declared
|
||||
// BEFORE the `/:id/...` routes, because express matches in declaration order and
|
||||
// a module whose id was `restart` would otherwise shadow — or be shadowed by —
|
||||
// the literal path. The id pattern below makes that unreachable in practice; the
|
||||
// ordering makes it unreachable by construction.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param, query } = require('express-validator')
|
||||
|
||||
const controller = require('./modules.controller')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const modulesRouter = express.Router()
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
// The loader's own id rule (MODULE_API.md §2.1). Applied at the edge so a
|
||||
// traversal-shaped id never reaches a path join, even though install.js checks
|
||||
// it again — this one produces a 400 with a readable message, that one is the
|
||||
// guarantee.
|
||||
const ID = /^[a-z][a-z0-9-]{1,31}$/
|
||||
|
||||
modulesRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Modules']
|
||||
// #swagger.summary = 'List installed modules, their live state, and the source allowlist'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Modules and the install source allowlist', content: { "application/json": { schema: { type: "object", properties: { modules: { type: "array", items: { type: "object", additionalProperties: true } }, sourceHosts: { type: "array", items: { type: "string" } } } } } } } */
|
||||
adminOnly,
|
||||
controller.list,
|
||||
)
|
||||
|
||||
modulesRouter.post(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Modules']
|
||||
// #swagger.summary = 'Install or upgrade a module from a release install-manifest URL'
|
||||
// #swagger.description = 'Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["url"], properties: { url: { type: "string", description: "https URL of the release install manifest, on an allowed host" } } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Installed — restart to mount it', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'The URL, the manifest, the hash or the archive was refused', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[502] = { description: 'The source host could not be reached or answered badly', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('url').isString().trim().isLength({ min: 1, max: 2048 }),
|
||||
validate,
|
||||
controller.create,
|
||||
)
|
||||
|
||||
modulesRouter.put(
|
||||
'/sources',
|
||||
// #swagger.tags = ['Admin · Modules']
|
||||
// #swagger.summary = 'Replace the allowlist of hosts modules may be installed from'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["hosts"], properties: { hosts: { type: "string", description: "Comma- or space-separated hostnames. An empty list forbids all installs." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The new allowlist', content: { "application/json": { schema: { type: "object", properties: { sourceHosts: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'One of the entries is not a hostname', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('hosts').isString().isLength({ max: 2048 }),
|
||||
validate,
|
||||
controller.setSources,
|
||||
)
|
||||
|
||||
modulesRouter.post(
|
||||
'/restart',
|
||||
// #swagger.tags = ['Admin · Modules']
|
||||
// #swagger.summary = 'Restart the server process so module changes take effect'
|
||||
// #swagger.description = 'Runs the same graceful shutdown a SIGTERM does. The process is brought back by the supervisor, which the shipped docker-compose.yml provides; a bare `npm start` will not come back.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[202] = { description: 'Shutting down', content: { "application/json": { schema: { type: "object", properties: { restarting: { type: "boolean" } } } } } } */
|
||||
adminOnly,
|
||||
controller.restart,
|
||||
)
|
||||
|
||||
modulesRouter.post(
|
||||
'/:id/enable',
|
||||
// #swagger.tags = ['Admin · Modules']
|
||||
// #swagger.summary = 'Enable a module (takes effect on the next restart)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Module id.' }
|
||||
/* #swagger.responses[200] = { description: 'Enabled — restart to start it', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such module', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').matches(ID),
|
||||
validate,
|
||||
controller.enable,
|
||||
)
|
||||
|
||||
modulesRouter.post(
|
||||
'/:id/disable',
|
||||
// #swagger.tags = ['Admin · Modules']
|
||||
// #swagger.summary = 'Stop a module now — runs its onShutdown, then its routes answer 404'
|
||||
// #swagger.description = 'The only module action that takes effect without a restart. Re-enabling needs one, because there is no onBoot re-dispatch.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Module id.' }
|
||||
/* #swagger.responses[200] = { description: 'Disabled', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such module', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').matches(ID),
|
||||
validate,
|
||||
controller.disable,
|
||||
)
|
||||
|
||||
modulesRouter.post(
|
||||
'/:id/purge',
|
||||
// #swagger.tags = ['Admin · Modules']
|
||||
// #swagger.summary = "Run a disabled module’s purge.sql, dropping its tables and data"
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Module id.' }
|
||||
/* #swagger.responses[200] = { description: 'Purged', content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" }, purged: { type: "integer" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'The module ships no purge.sql', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'The module must be disabled first', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').matches(ID),
|
||||
validate,
|
||||
controller.purge,
|
||||
)
|
||||
|
||||
modulesRouter.delete(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Modules']
|
||||
// #swagger.summary = 'Uninstall a module, optionally deleting its data too'
|
||||
// #swagger.description = "Removes the module directory and leaves its row disabled. With purge=true it also runs purge.sql first — which is the only moment it can, since purge.sql lives inside the directory being deleted."
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Module id.' }
|
||||
// #swagger.parameters['purge'] = { in: 'query', required: false, schema: { type: 'boolean' }, description: "Also run the module’s purge.sql and drop its row. Destructive and irreversible." }
|
||||
/* #swagger.responses[200] = { description: 'Uninstalled — restart to unmount it', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Purge was asked for and the module ships no purge.sql', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such module', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').matches(ID),
|
||||
query('purge').optional().isIn(['true', 'false', '1', '0']),
|
||||
validate,
|
||||
controller.remove,
|
||||
)
|
||||
|
||||
module.exports = modulesRouter
|
||||
@@ -33,7 +33,7 @@ inviteRouter.post(
|
||||
// #swagger.tags = ['Auth']
|
||||
// #swagger.summary = 'Accept an email invite (creates the account at the invited role)'
|
||||
// #swagger.description = 'Creates the website user at the invite’s pre-assigned role and logs them in (sets the session cookie). Bypasses the player_registration gate — the invite is its own authority. Rate limited + honeypot-guarded like registration.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["username","password"], properties: { username: { type: "string" }, password: { type: "string" } } } } } */
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["username","password"], properties: { username: { type: "string" }, password: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Account created and session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
require('dotenv').config()
|
||||
const http = require('http')
|
||||
|
||||
const app = require('./app')
|
||||
const internalApp = require('./internalApp')
|
||||
// NOTE: `./app` and `./internalApp` are deliberately NOT required here. Requiring
|
||||
// app.js runs `modules.load()`, which scans the volume and mounts whatever is on
|
||||
// it (MODULE_API.md §4.1) — so the declared module set has to be resolved before
|
||||
// that require, not before the listener. They are required inside start(), after
|
||||
// resolveDeclaredModules(); everything else this file needs is safe to pull in
|
||||
// now because none of it reaches the loader's scan.
|
||||
const botScore = require('./middleware/botScore')
|
||||
const announceWorker = require('./utils/announceWorker')
|
||||
const { ensureSchema, close } = require('./utils/db')
|
||||
@@ -11,6 +15,9 @@ const settings = require('./model/settings/settings.model')
|
||||
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
|
||||
const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model')
|
||||
const moduleLifecycle = require('./modules/lifecycle')
|
||||
const declaredModules = require('./modules/declared')
|
||||
const moduleInstall = require('./modules/install')
|
||||
const moduleModel = require('./model/modules/modules.model')
|
||||
const createLogger = require('./utils/logger')
|
||||
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
|
||||
const brand = require('./config/brand')
|
||||
@@ -51,7 +58,9 @@ async function start() {
|
||||
}
|
||||
|
||||
log.info('ensuring database schema...')
|
||||
await ensureSchema()
|
||||
// Core's schema only. Each installed module's fragment is replayed further
|
||||
// down, after the volume has been scanned — see the require of ./app below.
|
||||
await ensureSchema({ replayModules: false })
|
||||
log.info('seeding defaults...')
|
||||
await seedDefaults()
|
||||
await createInitialAdminFromEnv()
|
||||
@@ -77,6 +86,36 @@ async function start() {
|
||||
const mode = await settings.get('site_mode')
|
||||
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
|
||||
|
||||
// Bring the modules volume in line with what MODULES declares (§2.7.2
|
||||
// decision 4), and only then require the app — the loader scans and mounts at
|
||||
// require time, so this is the last moment at which a module can be put on the
|
||||
// volume and still be part of this process.
|
||||
//
|
||||
// After the schema and the seed, because the host allowlist it installs under
|
||||
// is a settings row that the seed creates on a fresh instance. Never throws:
|
||||
// an unreachable release host leaves the site serving without that module
|
||||
// rather than taking the site down with it.
|
||||
await declaredModules.resolve({
|
||||
hosts: moduleInstall.parseHosts(await settings.get(moduleInstall.HOSTS_SETTING)),
|
||||
model: moduleModel,
|
||||
})
|
||||
|
||||
// Requiring app.js is what scans the volume and mounts what is on it. Every
|
||||
// line above this one runs against a core that has no modules in it yet.
|
||||
// eslint-disable-next-line global-require
|
||||
const app = require('./app')
|
||||
// eslint-disable-next-line global-require
|
||||
const internalApp = require('./internalApp')
|
||||
|
||||
// Now that the scan has happened, replay each module's schema fragment
|
||||
// (MODULE_API.md §2.6). This used to ride inside ensureSchema() and could,
|
||||
// because app.js was required at the top of this file; resolving the declared
|
||||
// set first moved the scan after it, and a booting server quietly getting no
|
||||
// module tables is precisely what §7.6 warns about. Caught by the browser
|
||||
// smoke rather than by a test: every suite here stubs one side or the other.
|
||||
// eslint-disable-next-line global-require
|
||||
await require('./modules/schema').replayFragments()
|
||||
|
||||
// Reconcile installed_modules with what the loader found on the volume at
|
||||
// require time, then run each module's onBoot (MODULE_API.md §2.5).
|
||||
//
|
||||
|
||||
@@ -55,11 +55,18 @@ const SCHEMA_PATH = path.join(__dirname, '..', '..', 'db', 'schema.sql')
|
||||
* below — the discovery, splitting and per-module failure handling all live in
|
||||
* modules/schema.js, required lazily so that requiring the pool never drags the
|
||||
* loader in with it.
|
||||
*
|
||||
* `replayModules: false` is for a caller that has not scanned the volume YET and
|
||||
* intends to. server.js is the one: since slice 3 it resolves the declared
|
||||
* module set before requiring app.js, which puts core's schema *before* the scan
|
||||
* — so it replays the fragments itself, in the one place that knows the scan has
|
||||
* happened. Left true everywhere else, so the ordinary caller cannot get module
|
||||
* tables by accident and lose them by refactor.
|
||||
*/
|
||||
async function ensureSchema({ retries = 10, delayMs = 2000 } = {}) {
|
||||
async function ensureSchema({ retries = 10, delayMs = 2000, replayModules = true } = {}) {
|
||||
await ensureCoreSchema({ retries, delayMs })
|
||||
// eslint-disable-next-line global-require
|
||||
await require('../modules/schema').replayFragments()
|
||||
if (replayModules) await require('../modules/schema').replayFragments()
|
||||
}
|
||||
|
||||
/** Core's own schema.sql, with the wait-for-the-database retry. */
|
||||
|
||||
100
server/swagger/docsSpec.js
Normal file
100
server/swagger/docsSpec.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// ── The OpenAPI document core actually serves ──────────────────────────────
|
||||
//
|
||||
// `swagger-output.json` is core's own routes and only core's own routes: it is
|
||||
// generated by `npm run swagger` on a developer's machine and committed, so it
|
||||
// must come out the same regardless of which modules that developer happened to
|
||||
// have checked out. A module's routes cannot be in it, and not merely because
|
||||
// nobody put them there — a module arrives on a volume long after the image was
|
||||
// built, and core never has its sources to analyse.
|
||||
//
|
||||
// So the document served at `/api/docs.json` is assembled at REQUEST time: core's
|
||||
// committed spec, plus the `swagger-fragment.json` of every started module
|
||||
// (docs/website/MODULE_API.md §2.8 and §6.1a). This file is that assembly.
|
||||
//
|
||||
// **Core always wins a key collision.** `mergeFragment` enforces it and reports
|
||||
// what it dropped. A module cannot redefine a core path, tag or schema by shipping
|
||||
// one with the same name — which is why §6.1a tells modules to namespace the
|
||||
// schemas they define (`UoShardStatus`) while referencing core's shared ones
|
||||
// (`Error`) by core's name: the first would collide and lose, the second resolves
|
||||
// here, in the merged document, which is the only place both exist.
|
||||
//
|
||||
// **Cached, keyed on the loader's state version.** Building the document reads a
|
||||
// file per module and deep-copies a 5,000-line spec; `/api/docs` is a page an
|
||||
// operator opens occasionally and a crawler may hit repeatedly. The cache is
|
||||
// invalidated by any module state CHANGE — which is what "started modules only"
|
||||
// depends on, and the only input here that can move without a restart.
|
||||
|
||||
const fs = require('fs')
|
||||
|
||||
const modules = require('../src/modules/loader')
|
||||
const createLogger = require('../src/utils/logger')
|
||||
const { mergeFragment } = require('./mergeSpec')
|
||||
|
||||
const log = createLogger('swagger')
|
||||
|
||||
let cached = null
|
||||
let cachedVersion = -1
|
||||
|
||||
/**
|
||||
* Core's spec with every started module's fragment merged over it.
|
||||
*
|
||||
* Never throws: `/api/docs.json` answering with core's routes alone is a worse
|
||||
* document than the full one, but it is a document. A fragment that is missing,
|
||||
* unreadable or not JSON costs that module its paths and nothing else — the same
|
||||
* bargain §4.4 makes everywhere else, where one module's failure is never the
|
||||
* site's.
|
||||
*
|
||||
* @param {object} coreSpec the committed swagger-output.json — never mutated
|
||||
* @returns {object}
|
||||
*/
|
||||
function docsSpec(coreSpec) {
|
||||
// Before app.js has called modules.load(), asking is a mis-ordered boot rather
|
||||
// than a core with nothing installed (§7.6) — but this is a request handler, and
|
||||
// 500ing the docs page over it would be the wrong trade. Core's own spec is the
|
||||
// honest answer to "what is documented" at that point anyway.
|
||||
if (!modules.isLoaded()) return coreSpec
|
||||
|
||||
const version = modules.version()
|
||||
if (cached && cachedVersion === version) return cached
|
||||
|
||||
// A structural copy, because mergeFragment writes into what it is given and
|
||||
// `coreSpec` is a require()d JSON module: mutating it would make the merge
|
||||
// cumulative across rebuilds and permanent for the life of the process.
|
||||
const spec = JSON.parse(JSON.stringify(coreSpec))
|
||||
spec.paths = spec.paths || {}
|
||||
spec.tags = spec.tags || []
|
||||
spec.components = spec.components || {}
|
||||
spec.components.schemas = spec.components.schemas || {}
|
||||
|
||||
for (const { id, file } of modules.specFragments()) {
|
||||
let fragment
|
||||
try {
|
||||
fragment = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
} catch (err) {
|
||||
log.warn('module OpenAPI fragment could not be read — its routes will be undocumented', {
|
||||
module: id,
|
||||
file,
|
||||
error: err.message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
const before = Object.keys(spec.paths).length
|
||||
mergeFragment(spec, fragment, `module ${id}`)
|
||||
log.debug('merged module OpenAPI fragment', {
|
||||
module: id,
|
||||
paths: Object.keys(spec.paths).length - before,
|
||||
})
|
||||
}
|
||||
|
||||
cached = spec
|
||||
cachedVersion = version
|
||||
return spec
|
||||
}
|
||||
|
||||
/** Test seam: forget the cached document. */
|
||||
function reset() {
|
||||
cached = null
|
||||
cachedVersion = -1
|
||||
}
|
||||
|
||||
module.exports = { docsSpec, reset }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user