Compare commits
53 Commits
spike/modu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e0c961c690 | |||
| 3669696532 | |||
| 953d0c25f6 | |||
| 4ad8b2bb0e | |||
| 1433b60d6c | |||
| 1b692bf624 | |||
| 5410e7e0b3 | |||
| c3120ea3da | |||
| a4da1cc438 | |||
| 12df79430f | |||
| ec2b530be7 | |||
| 8bc09d8b53 | |||
| 9b16f39a52 | |||
| 75f4d29e93 | |||
| 9083e4135a | |||
| 732927a6bb | |||
| 50d719cf46 | |||
| b30e82cde2 | |||
| 2cb549e9e5 | |||
| adff20be7b | |||
| 87230c879a | |||
| 0c4eacfa4a | |||
| a99ead4ee4 | |||
| 5bdb6a7e10 | |||
| f7d27f7a06 | |||
| 5b5006c365 | |||
| 91964c8898 | |||
| d667565ae7 | |||
| 1d1350558b | |||
| 7a236cad8b | |||
| f5e6025dcc | |||
| 39d731d87a | |||
| f50541f374 | |||
| b649345484 | |||
| a103e0ce10 | |||
| 0a3f1eb9fa | |||
| a45a3d120a | |||
| e3c999b704 | |||
| e0927bc255 | |||
| fe83c91ba9 | |||
| 291c30f6ff | |||
| 85f563fc16 | |||
| 32ed8e4411 | |||
| 21196466ed | |||
| 39eaae90a8 | |||
| 97f19b4221 | |||
| 6195c76d61 | |||
| bd749d4f1f | |||
| 2892d01b24 | |||
| 7780fb033b | |||
| ec1ca7e794 | |||
| dcf6ef1886 | |||
| 3add0063bf |
@@ -10,5 +10,8 @@ uploads
|
|||||||
server/logs
|
server/logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
# Installed modules are mounted at runtime, never baked into the image. Without
|
||||||
|
# this a module in the builder's working tree would ship inside every image.
|
||||||
|
modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
44
.env.example
44
.env.example
@@ -33,8 +33,8 @@ LOG_FILE=app.log
|
|||||||
# BRAND_NAME / BRAND_CONTACT_EMAIL.
|
# BRAND_NAME / BRAND_CONTACT_EMAIL.
|
||||||
BRAND_NAME=Runic Gateway
|
BRAND_NAME=Runic Gateway
|
||||||
BRAND_SHORT_NAME=Runic Gateway
|
BRAND_SHORT_NAME=Runic Gateway
|
||||||
BRAND_TAGLINE=an independent private Ultima Online shard
|
BRAND_TAGLINE=an independent game community
|
||||||
BRAND_DESCRIPTION=Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes.
|
BRAND_DESCRIPTION=Runic Gateway — an independent game community. News, screenshots, guides, and community notes.
|
||||||
BRAND_CONTACT_EMAIL=
|
BRAND_CONTACT_EMAIL=
|
||||||
BRAND_URL=
|
BRAND_URL=
|
||||||
# Accent color — drives the web theme's --accent and the Discord embed color.
|
# 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_URL=http://bot:4100
|
||||||
BOT_INTERNAL_KEY=change-me-to-a-long-random-string
|
BOT_INTERNAL_KEY=change-me-to-a-long-random-string
|
||||||
|
|
||||||
# uo-link sidecar — the HTTP + WebSocket bridge to the ServUO game server. The
|
# ─── Installed modules ───
|
||||||
# website ingests its live event feed and proxies its read queries/commands
|
# A module is a directory on the modules volume (see MODULES_DIR in
|
||||||
# (shard status, online players, player-vendor sales, IDOC houses, character
|
# server/.env.example); everything about a specific game lives in one, and core
|
||||||
# sheets, account linking, town-crier). In production the sidecar + shard run on
|
# knows nothing about any of them. A module may read its own env vars, and they
|
||||||
# a DIFFERENT host from the website, so both URLs are configurable. The
|
# belong here because Compose passes this file to the container.
|
||||||
# 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
|
# MODULES declares the set this deployment runs, and the container arrives at it
|
||||||
# token). These URLs are just defaults; the admin can override them at runtime.
|
# on its own — no admin panel, no `tar -xf` on the host. One entry per module,
|
||||||
UOLINK_BASE_URL=http://127.0.0.1:8080
|
# `<id>@<version>=<install manifest URL>`, whitespace- or comma-separated:
|
||||||
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
|
# MODULES=uo@0.3.0=https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json
|
||||||
# with nothing saved yet — the admin panel's pinned value wins — but set it lower
|
#
|
||||||
# if you deliberately run an older sidecar.
|
# A module already unpacked at the declared version is a no-op that never touches
|
||||||
UOLINK_PROTOCOL=3
|
# 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 ───
|
# ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ───
|
||||||
# The `ntfy` compose service and the backend's push fan-out (opt-in notifications
|
# The `ntfy` compose service and the backend's push fan-out (opt-in notifications
|
||||||
|
|||||||
@@ -28,3 +28,36 @@ TOTP_ISSUER=UOMysticmoon
|
|||||||
DB_NAME=uomysticmoon
|
DB_NAME=uomysticmoon
|
||||||
DB_USER=uomm
|
DB_USER=uomm
|
||||||
COOKIE_NAME=uomm_token
|
COOKIE_NAME=uomm_token
|
||||||
|
|
||||||
|
# ── The UO module — REQUIRED for this instance, not optional like the vars above.
|
||||||
|
#
|
||||||
|
# Core is game-agnostic (docs/website/MODULE_SYSTEM.md): every shard-facing
|
||||||
|
# surface this instance runs — the shard pages, the player's characters, vendors
|
||||||
|
# and houses, Admin → Shard, and the uo-link connection itself — lives in
|
||||||
|
# RunicGateway/Module-uo and reaches the deployment through this line. Without
|
||||||
|
# it, the same image is a perfectly working site with no game on it.
|
||||||
|
#
|
||||||
|
# It is declared here rather than left to Admin → Modules because a compose host
|
||||||
|
# should arrive at its own set at boot, and because this instance has a shard to
|
||||||
|
# be down for: the panel path would leave the site game-less between the image
|
||||||
|
# roll and someone clicking install.
|
||||||
|
#
|
||||||
|
# Bump the version deliberately, and read Module-uo's release notes when you do —
|
||||||
|
# the container resolves this at every start, so changing the version here is
|
||||||
|
# what upgrades the module. A version already unpacked is a no-op that makes no
|
||||||
|
# network call at all.
|
||||||
|
#
|
||||||
|
# This owns what is ON the volume, never whether the module RUNS: disabling it in
|
||||||
|
# Admin → Modules keeps it disabled across restarts even though its files return.
|
||||||
|
MODULES=uo@0.3.0=https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json
|
||||||
|
|
||||||
|
# Module-uo reads these as the DEFAULTS for its uo-link connection, used only
|
||||||
|
# until Admin → Shard has been saved once — after that the encrypted DB config
|
||||||
|
# (`uo_link_config`) is authoritative and these are ignored. Left unset here on
|
||||||
|
# purpose: an instance that has already saved Admin → Shard keeps that config
|
||||||
|
# across the extraction (the module's schema fragment is CREATE TABLE IF NOT
|
||||||
|
# EXISTS, so the existing row is untouched), and setting them would suggest they
|
||||||
|
# still decide something. Module-uo's README documents them.
|
||||||
|
# UOLINK_BASE_URL=
|
||||||
|
# UOLINK_WS_URL=
|
||||||
|
# UOLINK_PROTOCOL=
|
||||||
|
|||||||
@@ -47,10 +47,20 @@ jobs:
|
|||||||
node-version: 20
|
node-version: 20
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: server/package-lock.json
|
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
|
- name: Install server deps
|
||||||
run: npm ci --prefix server
|
run: npm ci --prefix server
|
||||||
- name: Run server tests
|
- name: Run server tests
|
||||||
run: npm test --prefix server
|
run: npm test --prefix server
|
||||||
|
|
||||||
- name: Check the route manifest is current
|
- name: Check the route manifest is current
|
||||||
# The URL surface is frozen while the routers are carved up by capability
|
# 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
|
# (docs/website/API_V2_PLAN.md § Phase 2). Regenerating from the live Express
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -21,6 +21,13 @@ uploads/
|
|||||||
server/logs/
|
server/logs/
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
|
# Installed modules (docs/website/MODULE_SYSTEM.md). Core ships no module, so
|
||||||
|
# anything here is an operator's install or a developer's scratch copy. The
|
||||||
|
# directory itself IS tracked, via its README: docker-compose.yml bind-mounts it,
|
||||||
|
# and a missing bind-mount source is recreated by Docker as root-owned.
|
||||||
|
modules/*
|
||||||
|
!modules/README.md
|
||||||
|
|
||||||
# Operator-supplied spawn atlas artwork. Creature art is never committed: sprites
|
# Operator-supplied spawn atlas artwork. Creature art is never committed: sprites
|
||||||
# are extracted from the operator's own UO client .mul/.uop files and are theirs,
|
# are extracted from the operator's own UO client .mul/.uop files and are theirs,
|
||||||
# not ours to redistribute. The images live under server/uploads/atlas/, already
|
# not ours to redistribute. The images live under server/uploads/atlas/, already
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ RUN if [ -f client/package.json ]; then \
|
|||||||
# Persistent uploads + logs live on mounted volumes.
|
# Persistent uploads + logs live on mounted volumes.
|
||||||
RUN mkdir -p /app/uploads /app/logs && chown -R node:node /app/uploads /app/logs
|
RUN mkdir -p /app/uploads /app/logs && chown -R node:node /app/uploads /app/logs
|
||||||
|
|
||||||
|
# Installed modules are mounted in too (docker-compose.yml), and .dockerignore
|
||||||
|
# keeps any local modules/ OUT of the image — a module must never be baked in.
|
||||||
|
# The directory is still created here so a container run without the mount finds
|
||||||
|
# an empty, writable modules dir rather than no directory at all.
|
||||||
|
RUN mkdir -p /app/modules && chown node:node /app/modules
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
286
README.md
286
README.md
@@ -8,16 +8,19 @@
|
|||||||
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||||
[](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
|
Public site, wiki, and protected admin panel for a game community — a full-stack app
|
||||||
full-stack app in one repo. Branding is instance-configurable via `BRAND_*` (see
|
in one repo. Everything specific to a *particular* game lives in an installable
|
||||||
[Branding](#branding)); **UOMysticmoon** is the first instance.
|
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:
|
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).
|
- **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).
|
- **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.
|
- **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.
|
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)
|
- [Pages & routes](#pages--routes)
|
||||||
- [API endpoints](#api-endpoints)
|
- [API endpoints](#api-endpoints)
|
||||||
- [API documentation (Swagger)](#api-documentation-swagger)
|
- [API documentation (Swagger)](#api-documentation-swagger)
|
||||||
- [Shard integration (uo-link)](#shard-integration-uo-link)
|
- [Modules](#modules)
|
||||||
- [Environment variables](#environment-variables)
|
- [Environment variables](#environment-variables)
|
||||||
- [Security](#security)
|
- [Security](#security)
|
||||||
- [Logging](#logging)
|
- [Logging](#logging)
|
||||||
@@ -48,8 +51,8 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
How the pieces fit together — the React SPA and native app talk to one Express backend
|
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
|
(`router → controller → model → db`), which persists to MariaDB. Anything that knows
|
||||||
game world only through the **uo-link** sidecar. The shard itself is never internet-facing.
|
what game this site is about lives in an installed module, on the right of the diagram.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
@@ -69,32 +72,29 @@ flowchart TB
|
|||||||
subgraph backend["server/ — Express backend"]
|
subgraph backend["server/ — Express backend"]
|
||||||
direction TB
|
direction TB
|
||||||
mw["Middleware<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
|
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"]
|
ctrl["Controllers"]
|
||||||
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
|
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
|
||||||
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
|
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
|
||||||
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
|
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
|
||||||
|
loader["modules/loader.js<br/>scans the volume · mounts · registries · lifecycle"]
|
||||||
subgraph shardutil["Shard integration (utils/)"]
|
|
||||||
ingest["shardIngest.js<br/>WS ingest dispatcher"]
|
|
||||||
restcli["uoLinkClient.js<br/>REST client (never throws)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
|
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
|
||||||
end
|
end
|
||||||
|
|
||||||
bot["bot/<br/>Discord bot"]
|
bot["bot/<br/>Discord bot"]
|
||||||
end
|
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 ----------
|
%% ---------- Module side ----------
|
||||||
subgraph shardside["Game shard (never internet-facing)"]
|
subgraph modside["modules/<id>/ — installed, not built (e.g. Module-uo)"]
|
||||||
direction TB
|
direction TB
|
||||||
sidecar["uo-link sidecar<br/>(Rust) — the only bridge exposed"]
|
modsrv["server/ — routers, models, schema fragment<br/>reaches core only through ctx"]
|
||||||
servuo["ServUO shard<br/>(C# plugin)"]
|
modcli["client/dist/entry.js — prebuilt ESM chunk<br/>React shared via window.__rg"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
game["The game<br/>whatever the module talks to<br/>(for Module-uo: a ServUO shard,<br/>via the uo-link sidecar)"]
|
||||||
|
|
||||||
%% ---------- Edges ----------
|
%% ---------- Edges ----------
|
||||||
browser <-->|"same-origin JSON + SSE (cookie)"| mw
|
browser <-->|"same-origin JSON + SSE (cookie)"| mw
|
||||||
mobile -->|"REST (bearer access/refresh)"| mw
|
mobile -->|"REST (bearer access/refresh)"| mw
|
||||||
@@ -104,40 +104,42 @@ flowchart TB
|
|||||||
mw --> router --> ctrl
|
mw --> router --> ctrl
|
||||||
ctrl --> auth
|
ctrl --> auth
|
||||||
ctrl --> model
|
ctrl --> model
|
||||||
ctrl --> restcli
|
|
||||||
ctrl --> sse
|
ctrl --> sse
|
||||||
auth --> model
|
auth --> model
|
||||||
model <--> db
|
model <--> db
|
||||||
auth -. reads/writes secrets .-> secret
|
auth -. reads/writes secrets .-> secret
|
||||||
restcli -. reads config/token .-> secret
|
|
||||||
ingest --> model
|
|
||||||
ingest --> sse
|
|
||||||
sse -->|"live events"| browser
|
sse -->|"live events"| browser
|
||||||
bot -->|"messages"| discord
|
bot -->|"messages"| discord
|
||||||
bot <--> db
|
bot <--> db
|
||||||
|
|
||||||
restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar
|
loader -->|"mounts under /api/v1/<tier>/<prefix>"| router
|
||||||
sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest
|
loader -->|"require() + register(ctx, api)"| modsrv
|
||||||
servuo -->|"loopback TCP 127.0.0.1:7788<br/>newline-delimited JSON (shard dials out)"| sidecar
|
modsrv -->|"ctx.db · ctx.push · ctx.activity …"| model
|
||||||
|
modsrv <--> game
|
||||||
|
browser -->|"<script type=module> injected by htmlShell"| modcli
|
||||||
|
|
||||||
%% ---------- Styling ----------
|
%% ---------- Styling ----------
|
||||||
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
|
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
|
||||||
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
|
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
|
||||||
classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8;
|
classDef mod fill:#2d2620,stroke:#94764c,color:#f0e6d8;
|
||||||
class idp,discord ext;
|
class idp,discord,game ext;
|
||||||
class db store;
|
class db store;
|
||||||
class sidecar,servuo bridge;
|
class modsrv,modcli mod;
|
||||||
```
|
```
|
||||||
|
|
||||||
- **One backend, layered.** Every request flows `middleware → router → controller → model → db`.
|
- **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
|
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.
|
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.
|
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
|
- **Core knows nothing about any game.** Routes, tables, nav entries, SPA pages and push streams for
|
||||||
sidecar; only the sidecar is exposed, and only the backend talks to it. The REST client
|
a specific game arrive from a module the operator installed. Core provides the seams; the module
|
||||||
(`uoLinkClient.js`) never throws, so the site degrades gracefully when the shard is down.
|
fills them. See [Modules](#modules).
|
||||||
- **Sensitive events stay private.** Ingested game events fan out to browsers over two SSE channels —
|
- **A module that fails must never take the site down.** The loader catches failures across a
|
||||||
a public allowlist stream and an admin-only stream that adds staff audit / cheat / login events.
|
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)
|
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
|
||||||
│ │ ├─ app.js middleware + static SPA + routes
|
│ │ ├─ 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)
|
│ │ ├─ 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
|
│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin / player route groups
|
||||||
│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities (.model + .db)
|
│ │ ├─ 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
|
│ │ ├─ 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
|
│ ├─ 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
|
│ └─ .env.example
|
||||||
├─ client/ React + Vite SPA
|
├─ client/ React + Vite SPA
|
||||||
│ ├─ src/
|
│ ├─ 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
|
│ │ ├─ 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), …
|
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, ProviderIcon (inline SSO SVGs), …
|
||||||
│ │ ├─ contexts/ AuthContext, SiteContext
|
│ │ ├─ contexts/ AuthContext, SiteContext
|
||||||
|
│ │ ├─ modules/ the client registry: routes · nav · slots · feature gates · window.__rg
|
||||||
│ │ ├─ api/client.js fetch wrapper (sends cookies)
|
│ │ ├─ api/client.js fetch wrapper (sends cookies)
|
||||||
│ │ └─ styles/theme.css design tokens
|
│ │ └─ styles/theme.css design tokens
|
||||||
│ └─ public/assets/img/ hero image
|
│ └─ public/assets/img/ hero image
|
||||||
|
├─ modules/ installed modules, one directory each — a Docker bind mount; empty here
|
||||||
├─ Dockerfile builds client → serves via Express
|
├─ Dockerfile builds client → serves via Express
|
||||||
├─ docker-compose.yml app + MariaDB
|
├─ docker-compose.yml app + MariaDB
|
||||||
├─ .env.example root env (used by Compose)
|
├─ .env.example root env (used by Compose)
|
||||||
@@ -222,6 +227,10 @@ IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d
|
|||||||
- Health check: `GET http://localhost:3000/api/health` → `{ "status": "ok" }`
|
- Health check: `GET http://localhost:3000/api/health` → `{ "status": "ok" }`
|
||||||
- Logs: `docker compose logs -f app` (and `./logs/app.log` on the host)
|
- Logs: `docker compose logs -f app` (and `./logs/app.log` on the host)
|
||||||
- Stop: `docker compose down` (add `-v` to also wipe the database + uploads volumes)
|
- Stop: `docker compose down` (add `-v` to also wipe the database + uploads volumes)
|
||||||
|
- Modules: installed into `./modules` on the host (bind-mounted to `/app/modules`), never baked into
|
||||||
|
the image — an operator adds one to a pull-only deployment without building anything. Adding or
|
||||||
|
removing one takes a `docker compose restart app`; the scan is synchronous at startup. See
|
||||||
|
[`modules/README.md`](modules/README.md).
|
||||||
|
|
||||||
**Build the images locally instead of pulling** (offline, or to test an unmerged change) — overlay
|
**Build the images locally instead of pulling** (offline, or to test an unmerged change) — overlay
|
||||||
the dev file, which adds `build:` back:
|
the dev file, which adds `build:` back:
|
||||||
@@ -302,7 +311,7 @@ npm start # node server → serves API + SPA at http://localhost:3
|
|||||||
| `/site/screenshots` | Screenshot gallery |
|
| `/site/screenshots` | Screenshot gallery |
|
||||||
| `/site/five-on-friday` | Five on Friday |
|
| `/site/five-on-friday` | Five on Friday |
|
||||||
| `/site/newsletter` · `/site/newsletter/:id` | Newsletter list + issue |
|
| `/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) |
|
| `/wiki` · `/wiki/:slug` | Wiki landing + article (auto table-of-contents) |
|
||||||
|
|
||||||
**Admin** (cookie auth, `noindex`):
|
**Admin** (cookie auth, `noindex`):
|
||||||
@@ -320,6 +329,13 @@ npm start # node server → serves API + SPA at http://localhost:3
|
|||||||
| `/admin/users` | User management |
|
| `/admin/users` | User management |
|
||||||
| `/admin/account` | Account security (self-service TOTP two-factor + linked SSO accounts) |
|
| `/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
|
## API endpoints
|
||||||
@@ -331,9 +347,15 @@ npm start # node server → serves API + SPA at http://localhost:3
|
|||||||
| SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow |
|
| 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 |
|
| 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) |
|
| 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 | `/api/v1/player` (`me`, credentials, 2FA, identities, appeals) | cookie/bearer (any signed-in account) |
|
||||||
| Player · Shard | `/api/v1/player/shard` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | cookie/bearer (player) |
|
| Modules | `/api/v1/public/modules` — id, name, version and capabilities of the modules currently serving | none |
|
||||||
| Admin · Shard | `/api/v1/admin/shard` (self linking, same as player) · `/api/v1/admin/uo-link` (`config`, `towncrier`, `stream`) | cookie (staff / admin) |
|
|
||||||
|
**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`.
|
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
|
||||||
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
|
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
|
||||||
@@ -376,6 +398,28 @@ npm run swagger # → server/swagger/swagger-output.json
|
|||||||
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
|
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
|
||||||
not crash).
|
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)
|
### The route manifest (frozen URL surface)
|
||||||
|
|
||||||
`server/routes.manifest.json` is a generated, sorted `{ method, path }` list of every route the two
|
`server/routes.manifest.json` is a generated, sorted `{ method, path }` list of every route the two
|
||||||
@@ -391,9 +435,10 @@ npm run routes:manifest -- --check # exit 1 if either file is stale (what CI ru
|
|||||||
|
|
||||||
The generator walks the live Express stack (runtime introspection, not source parsing — a route's path
|
The generator walks the live Express stack (runtime introspection, not source parsing — a route's path
|
||||||
sits on the line *after* `router.get(`, which defeats greps) and keeps only
|
sits on the line *after* `router.get(`, which defeats greps) and keeps only
|
||||||
`/api/**` and `/.well-known/**` plus the internal listener. The SPA catch-all, `/uploads` and `/brand`
|
`/api/**` and `/.well-known/**` plus the internal listener. The SPA catch-all, `/uploads`, `/brand`
|
||||||
are filesystem-conditional static mounts, not API contract, so they are excluded and the output does
|
and installed modules' `/modules/<id>` chunks are filesystem-conditional static mounts, not API
|
||||||
not depend on whether the client has been built.
|
contract, so they are excluded and the output depends neither on whether the client has been built
|
||||||
|
nor on which modules are mounted.
|
||||||
|
|
||||||
Two generated files, two very different meanings:
|
Two generated files, two very different meanings:
|
||||||
|
|
||||||
@@ -407,94 +452,101 @@ annotated routes appear), the manifest records reality.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Shard integration (uo-link)
|
## Modules
|
||||||
|
|
||||||
The site is wired to the live in-game world through **uo-link**, a standalone sidecar service that
|
**Everything specific to a game is a module.** Core has no idea what an "account", a "character" or
|
||||||
runs next to the ServUO shard. Its source lives in a separate repo:
|
a "shard" is; it provides seams, and a module fills them. That is what makes one image able to run a
|
||||||
**[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)**. uo-link speaks the shard's internals and
|
site for any game rather than for Ultima Online in particular.
|
||||||
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.
|
|
||||||
|
|
||||||
### 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
|
### An operator never builds anything
|
||||||
**[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,
|
That constraint shapes the whole design. Installing a module is the WordPress-plugin experience — an
|
||||||
registers the sidecar as a service, and ends by printing the four values this site needs:
|
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
|
modules/
|
||||||
WebSocket URL ws://<shard-host>:8080/ws
|
└─ uo/ one directory per module; the id is the directory name
|
||||||
Protocol version 3
|
├─ module.json id, version, coreApi range, mounts, extensions, capabilities
|
||||||
Auth token 4f9c…
|
├─ 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
|
`modules/` is a bind mount in `docker-compose.yml`, so placing a directory there by hand is a
|
||||||
[installer/INSTALL.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md);
|
supported install. The directory is tracked in git (via its README) on purpose: Docker recreates a
|
||||||
its [Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand)
|
*missing* bind-mount source as `root:root`, and the container is uid 1000.
|
||||||
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.
|
|
||||||
|
|
||||||
Nothing here needs the shard to exist: with no sidecar configured the site renders normally and
|
### Three ways in, and none of them is a build
|
||||||
shows the shard offline.
|
|
||||||
|
|
||||||
### 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
|
MODULES=uo@0.3.0=https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json
|
||||||
REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Connection is admin-managed, not env.** The sidecar's base URL, WebSocket URL, shared-secret
|
The id and the version are written out rather than discovered inside the manifest so that **the
|
||||||
token, and protocol version are stored in the database (`uoLinkConfig`), edited from the
|
no-op case needs no network**: a module already unpacked at the declared version is answered by
|
||||||
**Admin → Shard** panel. The token is **encrypted at rest** (AES-256-GCM) and is **write-only** in
|
reading its own `module.json`, so a restart with the internet down brings the site up exactly as it
|
||||||
the API — it is never returned to any client and never sent to the browser. Every call the backend
|
was. Only a missing or different version is fetched, and it goes through the same
|
||||||
makes carries `Authorization: Bearer <token>` and an `X-UOLink-Version` header (a protocol
|
verify-and-unpack path — allowlisted `https` host, sha256 from the manifest, whole-archive
|
||||||
mismatch fails fast with `409` instead of being mis-parsed).
|
inspection before anything is written — that the admin panel uses. A version that cannot be
|
||||||
- **Live ingest (WebSocket).** When enabled, the backend opens an outbound WebSocket to the sidecar
|
resolved is logged and shown on the admin screen; **it never stops the site from starting**.
|
||||||
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.
|
|
||||||
|
|
||||||
### 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.
|
At boot, `app.js` scans the volume synchronously, validates each `module.json`, and calls the
|
||||||
2. On the website (Player portal, or Admin → Account for staff) they enter the code.
|
module's `register(ctx, api)`:
|
||||||
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`.
|
|
||||||
|
|
||||||
That mirror is the authorization basis for character reads: roster/vendor/character-sheet endpoints
|
- **`ctx` is everything core hands over** — the database, the logger, settings, the session reader,
|
||||||
are **ownership-checked** so a user only sees accounts they linked. **Admins may view any
|
push, the secret box, the middleware, the rate-limit factory, the activity log, and **express
|
||||||
character**; players and editor/moderator staff are limited to their own linked accounts.
|
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 |
|
### What is running right now
|
||||||
|---|---|---|---|
|
|
||||||
| **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). |
|
|
||||||
|
|
||||||
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
|
GET /api/v1/public/modules
|
||||||
the shard as offline.
|
{ "modules": [ { "id": "uo", "name": "Ultima Online", "version": "0.3.0",
|
||||||
|
"capabilities": ["shard", "atlas", "market", …] } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Anonymous, database-free, never site-mode gated, and **`started` modules only** — a module that is
|
||||||
|
disabled or failed is absent, exactly as its routes and its nav already are. Clients feature-detect
|
||||||
|
against it; they do not use it to decide what to load (the HTML shell injects each chunk's tag).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -507,6 +559,9 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
|||||||
| `NODE_ENV` | `production` | |
|
| `NODE_ENV` | `production` | |
|
||||||
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
|
| `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) |
|
| `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_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_NAME` / `DB_USER` / `DB_PASSWORD` | `runic_gateway` / `runic` / — | app database credentials |
|
||||||
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
|
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
|
||||||
@@ -528,15 +583,14 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
|||||||
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
|
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
|
||||||
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
|
| `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) |
|
| `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) |
|
| `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 |
|
||||||
| `TOWNCRIER_DURATION_SEC` | `3600` | how long a news post's in-game town-crier message stays up (≤ `86400`) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Branding
|
## Branding
|
||||||
|
|
||||||
Instance identity is data, not code — set via `BRAND_*` env vars, so one prebuilt
|
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 |
|
| Var | What |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import RequireAuth from './components/RequireAuth.jsx'
|
|||||||
import RequirePlayer from './components/RequirePlayer.jsx'
|
import RequirePlayer from './components/RequirePlayer.jsx'
|
||||||
import RoleGate from './components/RoleGate.jsx'
|
import RoleGate from './components/RoleGate.jsx'
|
||||||
import { routesFor } from './modules/registry.js'
|
import { routesFor } from './modules/registry.js'
|
||||||
|
import { ModuleFeaturesProvider } from './modules/features.jsx'
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
import Portal from './routes/public/Portal.jsx'
|
import Portal from './routes/public/Portal.jsx'
|
||||||
@@ -17,16 +18,6 @@ import Newsletter from './routes/public/Newsletter.jsx'
|
|||||||
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
||||||
import About from './routes/public/About.jsx'
|
import About from './routes/public/About.jsx'
|
||||||
import Status from './routes/public/Status.jsx'
|
import Status from './routes/public/Status.jsx'
|
||||||
import Shard from './routes/public/Shard.jsx'
|
|
||||||
import ShardActivity from './routes/public/ShardActivity.jsx'
|
|
||||||
import ChampSpawns from './routes/public/ChampSpawns.jsx'
|
|
||||||
import Guilds from './routes/public/Guilds.jsx'
|
|
||||||
import Governors from './routes/public/Governors.jsx'
|
|
||||||
import Houses from './routes/public/Houses.jsx'
|
|
||||||
import Rules from './routes/public/Rules.jsx'
|
|
||||||
import Leaderboards from './routes/public/Leaderboards.jsx'
|
|
||||||
import Market from './routes/public/Market.jsx'
|
|
||||||
import MarketVendor from './routes/public/MarketVendor.jsx'
|
|
||||||
import Wiki from './routes/wiki/Wiki.jsx'
|
import Wiki from './routes/wiki/Wiki.jsx'
|
||||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||||
import CmsPage from './routes/public/CmsPage.jsx'
|
import CmsPage from './routes/public/CmsPage.jsx'
|
||||||
@@ -46,17 +37,11 @@ import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
|||||||
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
||||||
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
||||||
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.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 AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
||||||
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||||
import UserDetail from './routes/admin/views/UserDetail.jsx'
|
import UserDetail from './routes/admin/views/UserDetail.jsx'
|
||||||
import InvitesAdmin from './routes/admin/views/InvitesAdmin.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 AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||||
import Moderation from './routes/admin/views/Moderation.jsx'
|
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||||
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
||||||
@@ -68,9 +53,7 @@ import PlayerRegister from './routes/player/PlayerRegister.jsx'
|
|||||||
import ForgotPassword from './routes/player/ForgotPassword.jsx'
|
import ForgotPassword from './routes/player/ForgotPassword.jsx'
|
||||||
import ResetPassword from './routes/player/ResetPassword.jsx'
|
import ResetPassword from './routes/player/ResetPassword.jsx'
|
||||||
import AcceptInvite from './routes/player/AcceptInvite.jsx'
|
import AcceptInvite from './routes/player/AcceptInvite.jsx'
|
||||||
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
|
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
|
||||||
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
|
|
||||||
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
|
|
||||||
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
||||||
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
||||||
|
|
||||||
@@ -78,6 +61,13 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SiteProvider>
|
<SiteProvider>
|
||||||
|
{/* Inside the auth and site contexts, because a feature provider is a
|
||||||
|
hook that may well read either — a live-status one does, indirectly,
|
||||||
|
by asking an endpoint whose answer depends on the session. Outside the
|
||||||
|
routes, so the nav in every layout is filtered by the same gate and
|
||||||
|
the provider hooks are called once for the whole app rather than
|
||||||
|
once per screen. */}
|
||||||
|
<ModuleFeaturesProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Landing hero — always public, even in maintenance mode. The hero is
|
{/* Landing hero — always public, even in maintenance mode. The hero is
|
||||||
itself the pre-launch "coming soon" page, so it sits outside the
|
itself the pre-launch "coming soon" page, so it sits outside the
|
||||||
@@ -100,26 +90,18 @@ export default function App() {
|
|||||||
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||||
<Route path="/site/about" element={<About />} />
|
<Route path="/site/about" element={<About />} />
|
||||||
<Route path="/site/status" element={<Status />} />
|
<Route path="/site/status" element={<Status />} />
|
||||||
<Route path="/site/shard" element={<Shard />} />
|
|
||||||
<Route path="/site/shard/activity" element={<ShardActivity />} />
|
|
||||||
<Route path="/site/champs" element={<ChampSpawns />} />
|
|
||||||
<Route path="/site/guilds" element={<Guilds />} />
|
|
||||||
<Route path="/site/governors" element={<Governors />} />
|
|
||||||
<Route path="/site/houses" element={<Houses />} />
|
|
||||||
<Route path="/site/rules" element={<Rules />} />
|
|
||||||
<Route path="/site/leaderboards" element={<Leaderboards />} />
|
|
||||||
<Route path="/site/market" element={<Market />} />
|
|
||||||
<Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
|
|
||||||
<Route path="/wiki" element={<Wiki />} />
|
<Route path="/wiki" element={<Wiki />} />
|
||||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||||
{/* Installed modules' public pages, namespaced `/<id>/…` (§2.8).
|
{/* Installed modules' public pages, namespaced `/<id>/…` — the
|
||||||
Declared BEFORE the /:slug CMS catch-all: React Router ranks
|
registry prefixes the segment, so a module cannot spell its way
|
||||||
static segments over dynamic ones so the order is not what saves
|
out of it (docs/website/MODULE_API.md §3.3). Declared before the
|
||||||
us, but keeping them adjacent makes the relationship visible. */}
|
CMS catch-all below: React Router ranks a static segment over a
|
||||||
|
dynamic one, so the order is not what saves us, but keeping the
|
||||||
|
two adjacent makes the relationship visible to whoever adds the
|
||||||
|
next route here. */}
|
||||||
{routesFor('public').map((r) => (
|
{routesFor('public').map((r) => (
|
||||||
<Route key={r.path} path={`/${r.path}`} element={r.element} />
|
<Route key={r.path} path={`/${r.path}`} element={r.element} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* CMS pages: top-level /:slug, matched only after the named routes
|
{/* CMS pages: top-level /:slug, matched only after the named routes
|
||||||
above (React Router ranks static routes over this dynamic one). */}
|
above (React Router ranks static routes over this dynamic one). */}
|
||||||
<Route path="/:slug" element={<CmsPage />} />
|
<Route path="/:slug" element={<CmsPage />} />
|
||||||
@@ -184,36 +166,21 @@ export default function App() {
|
|||||||
<Route path="activity" element={<ActivityAdmin />} />
|
<Route path="activity" element={<ActivityAdmin />} />
|
||||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
<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="auth-providers" element={<AuthProvidersAdmin />} />
|
||||||
<Route path="users" element={<UsersAdmin />} />
|
<Route path="users" element={<UsersAdmin />} />
|
||||||
<Route path="users/:id" element={<UserDetail />} />
|
<Route path="users/:id" element={<UserDetail />} />
|
||||||
<Route path="invites" element={<InvitesAdmin />} />
|
<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 />} />
|
<Route path="account" element={<AccountAdmin />} />
|
||||||
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
||||||
RequireAuth + AdminLayout. A module cannot supply its own auth
|
RequireAuth + AdminLayout. A module cannot supply its own auth
|
||||||
wrapper — only an optional { roles } that core applies as the
|
wrapper — only an optional { roles }, which core applies as the
|
||||||
same RoleGate its own routes use (MODULE_API.md §3.3). */}
|
same RoleGate its own routes above use, so the sidebar and the
|
||||||
|
route table cannot disagree about who may see what. Before the
|
||||||
|
`*` redirect, which would otherwise swallow every one of them. */}
|
||||||
{routesFor('admin').map((r) => (
|
{routesFor('admin').map((r) => (
|
||||||
<Route
|
<Route
|
||||||
key={r.path}
|
key={r.path}
|
||||||
@@ -237,14 +204,29 @@ export default function App() {
|
|||||||
</RequirePlayer>
|
</RequirePlayer>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/player" element={<PlayerCharacters />} />
|
{/* The portal index resolves to the first nav row this viewer can
|
||||||
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
|
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" element={<PlayerAccount />} />
|
||||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
||||||
|
{/* Installed modules' player-portal pages, at /player/<id>/…. This
|
||||||
|
group's own routes are absolute (its layout route has no path),
|
||||||
|
so the prefix is written here rather than inherited — the one
|
||||||
|
place the three areas do not read alike. */}
|
||||||
|
{routesFor('player').map((r) => (
|
||||||
|
<Route
|
||||||
|
key={r.path}
|
||||||
|
path={`/player/${r.path}`}
|
||||||
|
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</ModuleFeaturesProvider>
|
||||||
</SiteProvider>
|
</SiteProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,15 +42,19 @@ function safeParse(text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The request PRIMITIVE, exported for installed modules (window.__rg.api — see
|
// The request PRIMITIVE, exported for installed modules and handed to them on
|
||||||
// docs/website/MODULE_API.md §3.5). A module owns the paths it calls, because it
|
// `window.__rg.api` (docs/website/MODULE_API.md §3.5). Core owns the fetch
|
||||||
// owns the routes at the other end; core owns only the fetch semantics —
|
// semantics — same-origin /api/v1, cookies included, JSON in and out, ApiError
|
||||||
// same-origin /api/v1, cookies included, JSON in/out, ApiError on non-2xx.
|
// on a non-2xx — and nothing above them: a module owns the paths it calls,
|
||||||
|
// because it owns the routes at the other end.
|
||||||
//
|
//
|
||||||
// `api` below stays core's own binding surface. Its `atlas` and `shard`
|
// The `api` object below is core's own binding surface and nothing else: every
|
||||||
// namespaces are module bindings that only still live here because Phase 3 has
|
// namespace in it belongs to a route core still serves. A module binds its own
|
||||||
// not moved them yet.
|
// paths in its own chunk, against this primitive.
|
||||||
export { req as request }
|
// `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 = {
|
export const api = {
|
||||||
// ----- auth -----
|
// ----- auth -----
|
||||||
@@ -137,110 +141,6 @@ export const api = {
|
|||||||
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
|
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
|
||||||
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
|
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 -----
|
||||||
admin: {
|
admin: {
|
||||||
dashboard: () => req('/admin/dashboard'),
|
dashboard: () => req('/admin/dashboard'),
|
||||||
@@ -332,21 +232,20 @@ export const api = {
|
|||||||
createInvite: (email, role, sendEmail = true) =>
|
createInvite: (email, role, sendEmail = true) =>
|
||||||
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
|
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
|
||||||
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
|
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
|
// Installed modules (MODULE_SYSTEM.md §2.7.2). `uninstallModule`'s purge flag
|
||||||
// reuse the admin-bypass /admin/shard/* endpoints (which already read any
|
// is a query parameter rather than a body because it hangs off a DELETE, and
|
||||||
// account) so the shared GameAccounts component works unchanged.
|
// it is spelled out at the call site rather than defaulted, so the
|
||||||
userShard: (id) => ({
|
// destructive branch is never the one you get by forgetting an argument.
|
||||||
accounts: () => req(`/admin/users/${id}/shard/accounts`),
|
listModules: () => req('/admin/modules'),
|
||||||
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
installModule: (url) => req('/admin/modules', { method: 'POST', body: { url } }),
|
||||||
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
enableModule: (id) => req(`/admin/modules/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||||
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
disableModule: (id) => req(`/admin/modules/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||||
sales: () => req(`/admin/users/${id}/shard/sales`),
|
uninstallModule: (id, { purge } = {}) =>
|
||||||
houses: () => req(`/admin/users/${id}/shard/houses`),
|
req(`/admin/modules/${encodeURIComponent(id)}${purge ? '?purge=true' : ''}`, { method: 'DELETE' }),
|
||||||
online: () => req(`/admin/users/${id}/shard/online`),
|
purgeModule: (id) => req(`/admin/modules/${encodeURIComponent(id)}/purge`, { method: 'POST' }),
|
||||||
standing: () => req(`/admin/users/${id}/shard/standing`),
|
setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }),
|
||||||
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
|
restartServer: () => req('/admin/modules/restart', { method: 'POST' }),
|
||||||
}),
|
|
||||||
|
|
||||||
// ----- moderation dashboard (admin + moderator) -----
|
// ----- moderation dashboard (admin + moderator) -----
|
||||||
modSummary: () => req('/admin/moderation/stats/summary'),
|
modSummary: () => req('/admin/moderation/stats/summary'),
|
||||||
@@ -419,19 +318,6 @@ export const api = {
|
|||||||
linkedIdentities: () => req('/admin/account/identities'),
|
linkedIdentities: () => req('/admin/account/identities'),
|
||||||
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
|
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) -----
|
// ----- auth providers / SSO config (admin only) -----
|
||||||
listAuthProviders: () => req('/admin/auth/providers'),
|
listAuthProviders: () => req('/admin/auth/providers'),
|
||||||
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
||||||
@@ -442,45 +328,6 @@ export const api = {
|
|||||||
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
||||||
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
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) -----
|
// ----- Email delivery / Gmail OAuth2 (admin only) -----
|
||||||
getEmailConfig: () => req('/admin/email/config'),
|
getEmailConfig: () => req('/admin/email/config'),
|
||||||
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
||||||
@@ -504,19 +351,6 @@ export const api = {
|
|||||||
linkedIdentities: () => req('/player/account/identities'),
|
linkedIdentities: () => req('/player/account/identities'),
|
||||||
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
|
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) -----
|
// ----- moderation appeals (self-service) -----
|
||||||
getMyAppeals: () => req('/player/appeals'),
|
getMyAppeals: () => req('/player/appeals'),
|
||||||
getEligibleAppeals: () => req('/player/appeals/eligible'),
|
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 { useSite } from '../contexts/SiteContext.jsx'
|
||||||
import Maintenance from '../routes/public/Maintenance.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).
|
// coming-soon page; a logged-in admin sees the real site (live preview).
|
||||||
export default function MaintenanceGate({ children }) {
|
export default function MaintenanceGate({ children }) {
|
||||||
const { mode, loading } = useSite()
|
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,12 +1,36 @@
|
|||||||
import SiteHeader from './SiteHeader.jsx'
|
import SiteHeader from './SiteHeader.jsx'
|
||||||
import SiteFooter from './SiteFooter.jsx'
|
import SiteFooter from './SiteFooter.jsx'
|
||||||
|
import { shellClass } from '../lib/pageShell.js'
|
||||||
|
|
||||||
// Standard page chrome for the public site + wiki.
|
// Standard page chrome for the public site + wiki.
|
||||||
export default function PublicLayout({ section = 'website', header = true, children }) {
|
//
|
||||||
|
// ── `shell` — added in MODULE_API_VERSION 1.5.0 ────────────────────────────
|
||||||
|
//
|
||||||
|
// This component supplies the chrome and NOT the body: every core public page
|
||||||
|
// wraps its own content in `<div className="shell-… page-body">`, which is what
|
||||||
|
// centres it in a max-width column, gives it its top and bottom padding, and —
|
||||||
|
// through `page-body { flex: 1 }` — pushes the footer to the bottom of the
|
||||||
|
// viewport. Nine of nine core pages do it, so the omission has never shown.
|
||||||
|
//
|
||||||
|
// A module page cannot: it is handed `PublicLayout` through the UI kit
|
||||||
|
// (MODULE_API.md §3.4) and has no way to learn about two class names that appear
|
||||||
|
// in no contract. The Integration Kit's acceptance run built a module exactly as
|
||||||
|
// the kit teaches and it rendered full-bleed at x=0 with the footer riding up
|
||||||
|
// under the content — the precise failure §3.4 says the kit exists to prevent
|
||||||
|
// ("a module page that does not look like the site it is installed in").
|
||||||
|
//
|
||||||
|
// So the wrapper moves behind the component a module already has. `shell` is
|
||||||
|
// OPT-IN and omitting it is exactly today's behaviour, which is why core's own
|
||||||
|
// nine pages are untouched by this change — they keep their own wrapper, and a
|
||||||
|
// page wanting an unusual body still writes its own. The width mapping and its
|
||||||
|
// fallback are in lib/pageShell.js, where the DOM-less test runner can reach them.
|
||||||
|
export default function PublicLayout({ section = 'website', header = true, shell, children }) {
|
||||||
|
const bodyClass = shellClass(shell)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
{header && <SiteHeader section={section} />}
|
{header && <SiteHeader section={section} />}
|
||||||
{children}
|
{bodyClass ? <div className={bodyClass}>{children}</div> : children}
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,5 +1,14 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useSite } from '../contexts/SiteContext.jsx'
|
import { useSite } from '../contexts/SiteContext.jsx'
|
||||||
|
import Slot from '../modules/Slot.jsx'
|
||||||
|
|
||||||
|
const FOOTER_SLOT = 'site.footer.status'
|
||||||
|
|
||||||
|
// Handed to the extension rather than left for it to guess. A module rendering
|
||||||
|
// its own link in this row should look like the row, and the alternative is
|
||||||
|
// every module restating core's colours and then drifting from them the next
|
||||||
|
// time this footer is themed.
|
||||||
|
const LINK_STYLE = { color: 'var(--accent)', textDecoration: 'none' }
|
||||||
|
|
||||||
export default function SiteFooter() {
|
export default function SiteFooter() {
|
||||||
const { contactEmail, siteTitle } = useSite()
|
const { contactEmail, siteTitle } = useSite()
|
||||||
@@ -31,15 +40,19 @@ export default function SiteFooter() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="site-footer-info">
|
<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' }}>
|
<span style={{ color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||||
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||||
{contactEmail}
|
{contactEmail}
|
||||||
</a>
|
</a>
|
||||||
·
|
{/* A module's spot in the footer, and core supplies only the
|
||||||
<Link to="/site/shard" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
position and the styling: the label, the target and whether
|
||||||
Shard Status
|
anything renders at all are the module's (MODULE_API.md §3.7).
|
||||||
</Link>
|
The separator goes through `wrap` rather than sitting beside the
|
||||||
|
slot, so it shares the extension's fate — no module installed and
|
||||||
|
a module whose link throws both render nothing here, rather than
|
||||||
|
the second leaving a stray middot behind. */}
|
||||||
|
<Slot name={FOOTER_SLOT} linkStyle={LINK_STYLE} wrap={(link) => <> · {link}</>} />
|
||||||
·
|
·
|
||||||
<Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}>
|
<Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}>
|
||||||
Admin
|
Admin
|
||||||
|
|||||||
@@ -4,23 +4,27 @@ import MoonDot from './MoonDot.jsx'
|
|||||||
import BrandLogo from './BrandLogo.jsx'
|
import BrandLogo from './BrandLogo.jsx'
|
||||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
import { useSite } from '../contexts/SiteContext.jsx'
|
import { useSite } from '../contexts/SiteContext.jsx'
|
||||||
import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
|
|
||||||
import NavDropdown from './NavDropdown.jsx'
|
import NavDropdown from './NavDropdown.jsx'
|
||||||
import { buildPublicNav, pruneNav } from '../lib/navOverrides.js'
|
import { buildPublicNav, pruneNav } from '../lib/navOverrides.js'
|
||||||
import { navFor } from '../modules/registry.js'
|
|
||||||
import { parseJsonSetting } from '../lib/settingsJson.js'
|
import { parseJsonSetting } from '../lib/settingsJson.js'
|
||||||
|
import { withModuleNav } from '../modules/nav.js'
|
||||||
|
import { useFeatureGate } from '../modules/features.jsx'
|
||||||
|
|
||||||
// One consistent top nav for the whole public site. Every page gets the same
|
// 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).
|
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
|
||||||
//
|
//
|
||||||
// Entries carrying a `feature` are shard surfaces an admin can disable or gate
|
// A row may carry a `feature`, naming a surface an installed module can disable
|
||||||
// to a higher audience (Admin -> Shard Visibility). They are hidden when this
|
// or gate to a higher audience; it is hidden when this viewer cannot reach it,
|
||||||
// viewer can't reach them, so we never render a link that would 403. The gate
|
// so we never render a link that would 403. No CORE row carries one today — the
|
||||||
// itself is server-side; this is only about not advertising a dead end.
|
// 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,
|
// Exported because Admin -> Navigation edits this list. It stays declared here,
|
||||||
// with this component as its owner: the editor may only relabel, reorder and
|
// with this component as its owner: the editor may only relabel, reorder and
|
||||||
// hide what it finds, and `to`/`feature` are never its to change (§7).
|
// hide what it finds, and `to`/`feature` are never its to change (§7). An
|
||||||
|
// installed module's rows join it in `withModuleNav` below — before the override
|
||||||
|
// merge, so an admin can edit those rows exactly as they edit these.
|
||||||
export const NAV = [
|
export const NAV = [
|
||||||
{ label: 'Home', to: '/', end: true },
|
{ label: 'Home', to: '/', end: true },
|
||||||
{ label: 'News', to: '/site/news' },
|
{ label: 'News', to: '/site/news' },
|
||||||
@@ -28,14 +32,6 @@ export const NAV = [
|
|||||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
{ label: 'Newsletter', to: '/site/newsletter' },
|
||||||
{ label: 'Wiki', to: '/wiki' },
|
{ label: 'Wiki', to: '/wiki' },
|
||||||
{ label: 'Shard', to: '/site/shard', feature: 'status' },
|
|
||||||
{ label: 'Champions', to: '/site/champs', feature: 'champs' },
|
|
||||||
{ label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
|
|
||||||
{ label: 'Governors', to: '/site/governors', feature: 'governors' },
|
|
||||||
{ label: 'Houses', to: '/site/houses', feature: 'houses' },
|
|
||||||
{ label: 'Rules', to: '/site/rules', feature: 'ruleset' },
|
|
||||||
{ label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
|
|
||||||
{ label: 'Market', to: '/site/market', feature: 'market' },
|
|
||||||
{ label: 'About', to: '/site/about' },
|
{ label: 'About', to: '/site/about' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -48,39 +44,28 @@ const linkStyle = ({ isActive }) => ({
|
|||||||
export default function SiteHeader() {
|
export default function SiteHeader() {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
const { siteTitle, settings } = useSite()
|
const { siteTitle, settings } = useSite()
|
||||||
const shardFeatures = useShardFeatures()
|
const isVisible = useFeatureGate()
|
||||||
|
|
||||||
|
// Core's rows plus every installed module's. Computed once: the registry is
|
||||||
|
// fixed before the first render and there is no unregistering, so this cannot
|
||||||
|
// change during a session (modules/nav.js).
|
||||||
|
const baseNav = useMemo(() => withModuleNav(NAV, 'public'), [])
|
||||||
|
|
||||||
// An admin may relabel, reorder and hide these entries from Admin →
|
// An admin may relabel, reorder and hide these entries from Admin →
|
||||||
// Navigation, and may group them into dropdown sections alongside links of
|
// Navigation, and may group them into dropdown sections alongside links of
|
||||||
// their own (THEMING_AND_NAV.md §7). Two things about the order here:
|
// 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
|
// • 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
|
// filter stays the boundary — an override cannot un-hide a surface this
|
||||||
// this viewer may not see, whatever it says. `pruneNav` applies the same
|
// viewer may not see, whatever it says. `pruneNav` applies the same
|
||||||
// check inside a section and drops one it leaves empty, so a dropdown
|
// check inside a section and drops one it leaves empty, so a dropdown
|
||||||
// never opens onto nothing;
|
// never opens onto nothing;
|
||||||
// • with no stored row this is the coded NAV, in code order, so an
|
// • with no stored row this is the coded NAV, in code order, so an
|
||||||
// untouched instance renders exactly what it renders today.
|
// untouched instance renders exactly what it renders today.
|
||||||
// Installed modules' entries interleave into this list by `order` BEFORE the
|
|
||||||
// override merge, so an admin edits one nav rather than "core's, plus whatever
|
|
||||||
// the module appended" — and a module item is hideable and re-labelable
|
|
||||||
// exactly like a core one. `order` defaults high, which lands module entries
|
|
||||||
// where the UO items already sat: after the content links, before About.
|
|
||||||
const base = useMemo(() => {
|
|
||||||
const items = navFor('public')
|
|
||||||
if (items.length === 0) return NAV
|
|
||||||
const merged = [...NAV]
|
|
||||||
for (const item of items) {
|
|
||||||
const at = Number.isFinite(item.order) ? item.order : merged.length
|
|
||||||
merged.splice(Math.min(at, merged.length), 0, { label: item.label, to: item.to, feature: item.feature })
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const nav = useMemo(() => {
|
const nav = useMemo(() => {
|
||||||
const tree = buildPublicNav(base, parseJsonSetting(settings.nav_public))
|
const tree = buildPublicNav(baseNav, parseJsonSetting(settings.nav_public))
|
||||||
return pruneNav(tree, (item) => !item.feature || canSee(shardFeatures, item.feature))
|
return pruneNav(tree, isVisible)
|
||||||
}, [base, settings.nav_public, shardFeatures])
|
}, [baseNav, settings.nav_public, isVisible])
|
||||||
|
|
||||||
// Where the auth entry points: staff → admin, player → portal, else sign in.
|
// Where the auth entry points: staff → admin, player → portal, else sign in.
|
||||||
let account
|
let account
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
100
client/src/lib/adminNav.js
Normal file
100
client/src/lib/adminNav.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// Who may see a row of the admin sidebar, and where that lets them go.
|
||||||
|
//
|
||||||
|
// Plain JS, in its own file, for two reasons. It is shared — AdminLayout renders
|
||||||
|
// by it and Admin -> Navigation builds its palette by it (THEMING_AND_NAV.md
|
||||||
|
// §8.1), and a second copy of this answer is exactly the thing this file exists
|
||||||
|
// to abolish. And it is the closest thing in the client to an authorization
|
||||||
|
// decision, so it belongs somewhere the test runner can reach, which a .jsx file
|
||||||
|
// is not.
|
||||||
|
//
|
||||||
|
// **A row's own `roles` is the whole answer.** Until Phase 2 PR 8 this was
|
||||||
|
// `roles` AND a hardcoded `MOD_PATHS` list of five paths that confined
|
||||||
|
// moderators, AND a third prefix list in the redirect effect that disagreed with
|
||||||
|
// both (docs/website/MODULE_SYSTEM.md §1.4). A module's rows could never be
|
||||||
|
// added to a list core hardcodes, which is what forced the derivation — but the
|
||||||
|
// lists had already drifted from each other without a module in sight.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can a viewer with this role see this row?
|
||||||
|
*
|
||||||
|
* Applied AFTER the override merge in both callers: an override is presentation
|
||||||
|
* and this is the boundary, so an override saying `hidden: false` on a row this
|
||||||
|
* role cannot see still shows nothing (THEMING_AND_NAV.md §7).
|
||||||
|
*
|
||||||
|
* A row with no `roles` is visible to everyone who reached the admin area at
|
||||||
|
* all — that is the self-service case (Account, My Characters), and staff are a
|
||||||
|
* superset of players.
|
||||||
|
*/
|
||||||
|
export function navItemVisibleTo(item, role) {
|
||||||
|
return !item.roles || item.roles.includes(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The paths a viewer with this role may reach, derived from the rows they see.
|
||||||
|
*
|
||||||
|
* Takes the BASE nav, never the override-merged one: an override must not be
|
||||||
|
* able to move this boundary in either direction. Hiding a row from a
|
||||||
|
* moderator's sidebar must not also bar them from the page behind it, and
|
||||||
|
* un-hiding one must not admit them to a page their role does not carry.
|
||||||
|
*
|
||||||
|
* @param {Array<{items: Array}>} baseNav the grouped admin nav
|
||||||
|
* @param {string} role
|
||||||
|
* @returns {Array<{to: string, exact: boolean}>}
|
||||||
|
*/
|
||||||
|
export function allowedPathsFor(baseNav, role) {
|
||||||
|
return (Array.isArray(baseNav) ? baseNav : [])
|
||||||
|
.flatMap((g) => g.items || [])
|
||||||
|
.filter((item) => navItemVisibleTo(item, role))
|
||||||
|
.map((item) => ({ to: item.to, exact: item.end === true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this pathname one of them?
|
||||||
|
*
|
||||||
|
* A row carrying `end` matches exactly — `/admin` is the dashboard, not a prefix
|
||||||
|
* of the whole admin area, and treating it as one would let every path through.
|
||||||
|
* Every other row also covers its sub-routes, which is what keeps
|
||||||
|
* `/admin/moderation/appeals/12` and a module's detail pages reachable without
|
||||||
|
* anyone listing them.
|
||||||
|
*/
|
||||||
|
export function isAllowedPath(pathname, allowed) {
|
||||||
|
return (allowed || []).some(({ to, exact }) =>
|
||||||
|
exact ? pathname === to : pathname === to || pathname.startsWith(`${to}/`),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first place in this nav a viewer with this role can actually go.
|
||||||
|
*
|
||||||
|
* Added in Phase 3 slice 3, for the player portal, whose index route was
|
||||||
|
* `PlayerCharacters` — a UO page. When it left, `/player` had nothing behind it,
|
||||||
|
* and the three ways out were: redirect somewhere fixed, invent a core landing
|
||||||
|
* page, or resolve the index from the nav the viewer already has. This is the
|
||||||
|
* third, and it is the only one that keeps today's behaviour — with the module
|
||||||
|
* installed the first row is still Characters, so a player still lands on their
|
||||||
|
* characters after signing in, and with nothing installed they land on Account.
|
||||||
|
*
|
||||||
|
* **From the BASE nav, never the override-merged one**, the same rule
|
||||||
|
* `allowedPathsFor` follows and for a sharper version of the same reason: an
|
||||||
|
* override is presentation, and a landing page is behaviour. An admin reordering
|
||||||
|
* the sidebar must not silently change where everybody arrives, and — more to
|
||||||
|
* the point — must not be able to move it somewhere a role cannot follow.
|
||||||
|
*
|
||||||
|
* Deliberately generic, and deliberately in this file rather than in the portal
|
||||||
|
* layout. The admin area has the same shape of question (its index is a
|
||||||
|
* hardcoded Dashboard), and the direction of travel is one logged-in area that
|
||||||
|
* shows the right things for the viewer's permissions rather than two that
|
||||||
|
* duplicate each other. When that happens this is the function it needs, and it
|
||||||
|
* already answers for both nav shapes.
|
||||||
|
*
|
||||||
|
* @param {Array} baseNav flat or grouped, before overrides
|
||||||
|
* @param {string} role
|
||||||
|
* @param {string} fallback where to go when the viewer can see nothing at all
|
||||||
|
*/
|
||||||
|
export function firstDestinationFor(baseNav, role, fallback) {
|
||||||
|
const items = (Array.isArray(baseNav) ? baseNav : []).flatMap((entry) =>
|
||||||
|
entry && Array.isArray(entry.items) ? entry.items : [entry],
|
||||||
|
)
|
||||||
|
const first = items.find((item) => item && item.to && navItemVisibleTo(item, role))
|
||||||
|
return first ? first.to : fallback
|
||||||
|
}
|
||||||
@@ -67,6 +67,11 @@ export function parseLayout(str) {
|
|||||||
// The current hardcoded hero as a HeroLayout, so the page is unchanged until
|
// 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
|
// staff publish their own. Font sizes use the existing clamp() strings so the
|
||||||
// default stays responsive (editor-created text uses px).
|
// 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') {
|
export function defaultLayout(teaser, name = 'Runic Gateway') {
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -84,9 +89,9 @@ export function defaultLayout(teaser, name = 'Runic Gateway') {
|
|||||||
align: 'center',
|
align: 'center',
|
||||||
width: 760,
|
width: 760,
|
||||||
lines: [
|
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: 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 },
|
{ 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)
|
||||||
|
}
|
||||||
@@ -20,7 +20,10 @@
|
|||||||
// Two shapes are supported, because two exist:
|
// Two shapes are supported, because two exist:
|
||||||
// flat [{ to, label, ... }] — public header, player portal
|
// flat [{ to, label, ... }] — public header, player portal
|
||||||
// grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar
|
// grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar
|
||||||
function isGrouped(nav) {
|
// Exported for modules/nav.js, which has to answer the same question about the
|
||||||
|
// same array a moment earlier — one implementation, so the interleave and the
|
||||||
|
// merge can never disagree about which shape they are looking at.
|
||||||
|
export function isGrouped(nav) {
|
||||||
return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items))
|
return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +204,7 @@ export function buildNavRows(baseNav, overrides) {
|
|||||||
//
|
//
|
||||||
// The base side is restricted to the rows the editor is actually holding: §8.1
|
// 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
|
// 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) {
|
function orderMatchesBase(groups, baseNav) {
|
||||||
const flatten = (gs) => gs.flatMap((g) => g.items.map((i) => `${g.title ?? ''}::${i.to}`))
|
const flatten = (gs) => gs.flatMap((g) => g.items.map((i) => `${g.title ?? ''}::${i.to}`))
|
||||||
const base = isGrouped(baseNav)
|
const base = isGrouped(baseNav)
|
||||||
@@ -405,9 +408,10 @@ export function buildPublicNav(baseNav, overrides, { keepHidden = false } = {})
|
|||||||
* Apply the caller's visibility gate — and drop a section it leaves empty.
|
* 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
|
* 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
|
* with real correctness risk: a section whose every entry is hidden by a
|
||||||
* visibility must not render as a menu that opens onto nothing. The predicate
|
* module's visibility rules must not render as a menu that opens onto nothing.
|
||||||
* stays the caller's, so this module still knows nothing about shard features.
|
* 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.
|
* Added links carry no gate, so they are always visible — see the note above.
|
||||||
*
|
*
|
||||||
@@ -486,7 +490,7 @@ export function buildPublicNavOverrides(tree, baseNav, stored = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Carry through an entry for a coded item this admin's palette never showed
|
// 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)
|
const { items: storedItems } = unwrapPublic(stored)
|
||||||
for (const [to, entry] of Object.entries(storedItems)) {
|
for (const [to, entry] of Object.entries(storedItems)) {
|
||||||
if (!shown.has(to) && baseLabels.has(to) && entry && typeof entry === 'object') items[to] = entry
|
if (!shown.has(to) && baseLabels.has(to) && entry && typeof entry === 'object') items[to] = entry
|
||||||
|
|||||||
26
client/src/lib/pageShell.js
Normal file
26
client/src/lib/pageShell.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// The page-body shell core's public pages sit in, as plain JS.
|
||||||
|
//
|
||||||
|
// Extracted from PublicLayout.jsx for the reason lib/adminNav.js was: the client
|
||||||
|
// test runner has no DOM and cannot import a .jsx file at all
|
||||||
|
// (client/test/moduleRegistry.test.js says the same about modules/shared.js), so
|
||||||
|
// anything with a rule worth asserting has to live outside the component.
|
||||||
|
//
|
||||||
|
// The rule worth asserting here is the fallback. `shell` is part of the module
|
||||||
|
// contract as of MODULE_API_VERSION 1.5.0 (MODULE_API.md §3.4), which means the
|
||||||
|
// value can come from a module core has never seen, written against a version of
|
||||||
|
// this list that is older or newer than the one running. An unknown width must
|
||||||
|
// therefore still produce a wrapper: a module page at the wrong width looks like
|
||||||
|
// the site, and a page with no wrapper does not — it renders full-bleed with the
|
||||||
|
// footer riding up under it, which is the defect the prop exists to fix.
|
||||||
|
|
||||||
|
const SHELLS = { narrow: 'shell-narrow', mid: 'shell-mid', wide: 'shell-wide' }
|
||||||
|
|
||||||
|
export const SHELL_WIDTHS = Object.keys(SHELLS)
|
||||||
|
|
||||||
|
// Returns the className for a page body, or null when no shell was asked for —
|
||||||
|
// null is "render children bare", which is every core page written before 1.5.0
|
||||||
|
// and stays the default forever.
|
||||||
|
export function shellClass(shell) {
|
||||||
|
if (!shell) return null
|
||||||
|
return `${SHELLS[shell] || SHELLS.narrow} page-body`
|
||||||
|
}
|
||||||
@@ -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,58 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { api } from '../api/client.js'
|
|
||||||
|
|
||||||
// Which shard surfaces the current viewer may reach, from
|
|
||||||
// GET /public/shard/features. Admins configure this per feature (Admin → Shard
|
|
||||||
// Visibility), so the nav can't be a static list any more.
|
|
||||||
//
|
|
||||||
// This is PRESENTATION only. The gate is server-side: a disabled feature 404s
|
|
||||||
// and an out-of-rung one 403s whether or not the link is rendered. So while the
|
|
||||||
// answer is still in flight we return `null` and callers show their default set
|
|
||||||
// — better a link that briefly 403s than a nav that flickers in on every load.
|
|
||||||
//
|
|
||||||
// Cached module-level: the answer is per-viewer but stable for a session, and
|
|
||||||
// every consumer would otherwise refetch it on mount.
|
|
||||||
let cached = null
|
|
||||||
let inFlight = null
|
|
||||||
|
|
||||||
export function resetShardFeatures() {
|
|
||||||
cached = null
|
|
||||||
inFlight = null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useShardFeatures() {
|
|
||||||
const [features, setFeatures] = useState(cached)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (cached) return undefined
|
|
||||||
let alive = true
|
|
||||||
inFlight =
|
|
||||||
inFlight ||
|
|
||||||
api.shard
|
|
||||||
.features()
|
|
||||||
.then((data) => {
|
|
||||||
cached = { level: data.level, set: new Set(data.features || []) }
|
|
||||||
return cached
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// A failed lookup must not blank the nav — fall back to "show
|
|
||||||
// everything" and let the server do the gating.
|
|
||||||
cached = null
|
|
||||||
inFlight = null
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
inFlight.then((result) => {
|
|
||||||
if (alive) setFeatures(result)
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
alive = false
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return features
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience: true when `name` is visible, or when we don't know yet.
|
|
||||||
export function canSee(features, name) {
|
|
||||||
return !features || features.set.has(name)
|
|
||||||
}
|
|
||||||
@@ -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,24 +3,84 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
import { publishSharedDependencies } from './modules/shared.js'
|
import { publishSharedDependencies } from './modules/shared.js'
|
||||||
|
import { declareSlot } from './modules/registry.js'
|
||||||
import './styles/theme.css'
|
import './styles/theme.css'
|
||||||
|
|
||||||
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
|
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
|
||||||
// Installed modules are `<script type="module" src="/modules/<id>/entry.js">`
|
// Installed modules arrive as `<script type="module" src="/modules/<id>/…">`
|
||||||
// tags the server injects into <head> (server/src/utils/htmlShell.js); module
|
// tags the server injects into the shell (server/src/utils/htmlShell.js), placed
|
||||||
// scripts are deferred, so they run after this bundle and resolve their
|
// after this bundle's own tag; module scripts execute in document order, so they
|
||||||
// externals against the global this call sets up.
|
// resolve their externals against the global this call sets up
|
||||||
|
// (docs/website/MODULE_API.md §3.2).
|
||||||
publishSharedDependencies()
|
publishSharedDependencies()
|
||||||
|
|
||||||
// Render after DOMContentLoaded rather than immediately.
|
// 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) ───────────────────────────────────
|
||||||
//
|
//
|
||||||
// Deferred scripts execute in document order and all of them finish before
|
// Declared HERE, in core's own bundle, which is what makes the ordering a fact
|
||||||
// DOMContentLoaded fires. Waiting for that event is therefore the guarantee that
|
// rather than a hope: module chunks are deferred scripts the shell injects after
|
||||||
// every installed module has finished registering its routes and nav before
|
// this one (§3.1), so a module can never reach registerExtension before the slot
|
||||||
// React reads the registry — no loading state, no re-render, no ordering race
|
// it names exists. "Unknown slot" therefore always means a typo or a version
|
||||||
// between core's bundle and a module's. If this bundle happens to evaluate after
|
// skew, never a load-order accident — which is why that case throws.
|
||||||
// the event has already fired (a cached, fast path), readyState is checked and
|
//
|
||||||
// render runs at once.
|
// Both slots are named for a PLACE, not for a meaning. `site.footer.status` is
|
||||||
|
// the spot in the footer's info row, not a declaration that core knows what a
|
||||||
|
// game server's status is; the label, the target and whether anything renders at
|
||||||
|
// all belong to whoever fills it. A slot typed by its content would put game
|
||||||
|
// semantics back into core, which is the thing Phase 3 takes out.
|
||||||
|
declareSlot('site.footer.status')
|
||||||
|
// Deliberately the same name as the server's slot (MODULE_API.md §2.4): one
|
||||||
|
// resource, one extension point, two halves. The module with routes under
|
||||||
|
// /api/v1/admin/users/:id is the module with something to show on that page.
|
||||||
|
declareSlot('admin.users.detail')
|
||||||
|
// The invite-acceptance page's optional next step. Core owns invites — staff are
|
||||||
|
// invited too — and owned the game-account step inside them until slice 3, which
|
||||||
|
// meant core reading a `gameAccountSignup` flag and posting to a shard route.
|
||||||
|
//
|
||||||
|
// Named for the place, like the other two: it is "the point after an invite has
|
||||||
|
// been accepted and before the invitee is sent on", not "create a game account".
|
||||||
|
// Whether there is a step at all is the filling module's decision, made from
|
||||||
|
// data core does not have; core renders the shell and a skip control, and hands
|
||||||
|
// over `onDone`. With the slot unfilled the invitee goes straight to the portal,
|
||||||
|
// which is what core's own code did whenever the flag was off.
|
||||||
|
declareSlot('player.invite.accepted')
|
||||||
|
|
||||||
|
// Core filled the first two itself until slice 3, with the components that were
|
||||||
|
// inline in SiteFooter.jsx and UserDetail.jsx. Both are gone: the module fills
|
||||||
|
// all three, and core's own fills had to go for it to be able to — the first
|
||||||
|
// fill wins, and core registered first (§3.7).
|
||||||
|
|
||||||
|
// Render on DOMContentLoaded rather than immediately, and that is the one line
|
||||||
|
// of core's boot the module system changes.
|
||||||
|
//
|
||||||
|
// Deferred scripts — which every `type="module"` script is — execute in document
|
||||||
|
// order and ALL of them finish before DOMContentLoaded fires. Waiting for that
|
||||||
|
// event is therefore the guarantee that every installed module has registered
|
||||||
|
// its routes before React reads the registry: no loading state, no re-render,
|
||||||
|
// and no ordering race between core's bundle and a module's. A module chunk that
|
||||||
|
// 404s or throws does not hold the event back, so a broken module costs its own
|
||||||
|
// pages and not the site.
|
||||||
|
//
|
||||||
|
// The readyState check below is `'complete'`, and it is not the obvious
|
||||||
|
// `'loading'`. A DEFERRED script — which every `type="module"` script is — runs
|
||||||
|
// after the document has been parsed, so by the time this line executes
|
||||||
|
// readyState is already `'interactive'`; DOMContentLoaded has NOT fired yet and
|
||||||
|
// still comes after every deferred script. Testing for `'loading'` therefore
|
||||||
|
// mounts immediately, before any module chunk has evaluated, and a module's
|
||||||
|
// routes are missing from the very first render — which looks exactly like a
|
||||||
|
// module that failed to load: its URL falls through to core's catch-all and
|
||||||
|
// redirects home. Found by loading a real chunk in a browser; no unit test in
|
||||||
|
// this repo can see it.
|
||||||
|
//
|
||||||
|
// `'complete'` is only reached after `load`, which is strictly later than any
|
||||||
|
// static deferred script, so this branch is the genuine "the event has already
|
||||||
|
// been and gone" case and not a wrong guess about our own timing.
|
||||||
function mount() {
|
function mount() {
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
@@ -31,8 +91,8 @@ function mount() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'complete') {
|
||||||
document.addEventListener('DOMContentLoaded', mount, { once: true })
|
|
||||||
} else {
|
|
||||||
mount()
|
mount()
|
||||||
|
} else {
|
||||||
|
document.addEventListener('DOMContentLoaded', mount, { once: true })
|
||||||
}
|
}
|
||||||
|
|||||||
68
client/src/modules/Slot.jsx
Normal file
68
client/src/modules/Slot.jsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// ── <Slot> — where core renders a module's content ─────────────────────────
|
||||||
|
//
|
||||||
|
// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1; the normative
|
||||||
|
// contract is docs/website/MODULE_API.md §3.7.
|
||||||
|
//
|
||||||
|
// The read side of registry.js's extension slots. Core puts one of these where a
|
||||||
|
// module may contribute to a core page, and gets back either the filling
|
||||||
|
// component with the props core passed, or nothing at all.
|
||||||
|
//
|
||||||
|
// **Nothing at all is the important half.** An instance with no module installed
|
||||||
|
// renders the identical page it renders today, which is the same untouched-path
|
||||||
|
// guarantee `withModuleNav` makes for nav — and the reason a core layout can
|
||||||
|
// place a slot without also acquiring an empty-state to design.
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { extensionFor } from './registry.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contain a module's render failure to the module's own section.
|
||||||
|
*
|
||||||
|
* This is where the client differs from the server, deliberately. A module
|
||||||
|
* *route* that throws costs the module's own page and core does not need to care.
|
||||||
|
* An extension throws inside CORE's page — the admin's user detail, the site
|
||||||
|
* footer — and the whole reason core keeps ownership of that page is that it
|
||||||
|
* stays usable. So a slot renders nothing and logs, rather than taking the
|
||||||
|
* surrounding page down with it.
|
||||||
|
*
|
||||||
|
* A class because that is what React gives us: there is no hook form of
|
||||||
|
* componentDidCatch, and this is the only error boundary core has.
|
||||||
|
*/
|
||||||
|
class SlotBoundary extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.state = { failed: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return { failed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error) {
|
||||||
|
// Named so the console says whose fault it is: a blank section with an
|
||||||
|
// anonymous stack is how a module bug becomes core's support ticket.
|
||||||
|
console.error(`[modules] extension in slot "${this.props.name}" threw and was dropped`, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return this.state.failed ? null : this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name the slot id, declared by core in main.jsx
|
||||||
|
* @param {function} [wrap] core markup that only makes sense AROUND a rendered
|
||||||
|
* extension — a separator, a heading, a rule. Called with the extension's
|
||||||
|
* element and rendered inside the boundary, so it shares the extension's fate:
|
||||||
|
* an unfilled slot and a failed one both render nothing at all, decoration
|
||||||
|
* included. Found in a browser, because the obvious alternative — asking
|
||||||
|
* whether the slot is filled and rendering the separator alongside — is right
|
||||||
|
* about the unfilled case and leaves a stray separator behind on the failed one.
|
||||||
|
* @param {object} props everything else is handed to the filling component
|
||||||
|
*/
|
||||||
|
export default function Slot({ name, wrap, ...props }) {
|
||||||
|
const Extension = extensionFor(name)
|
||||||
|
if (!Extension) return null
|
||||||
|
const element = <Extension {...props} />
|
||||||
|
return <SlotBoundary name={name}>{wrap ? wrap(element) : element}</SlotBoundary>
|
||||||
|
}
|
||||||
56
client/src/modules/featureGate.js
Normal file
56
client/src/modules/featureGate.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Which nav rows a viewer may see, when the answer belongs to a module.
|
||||||
|
//
|
||||||
|
// Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.5 states the problem);
|
||||||
|
// the contract is docs/website/MODULE_API.md §3.3.
|
||||||
|
//
|
||||||
|
// Nine of the sixteen rows in the public header used to carry a `feature`, and
|
||||||
|
// every one of them was a shard surface an admin can disable or gate to a higher
|
||||||
|
// audience. The provider that answered those questions moved out with the module
|
||||||
|
// in Phase 3 slice 3, and core cannot call it directly and still be a core. It
|
||||||
|
// keeps this generic seam instead, and the module fills it.
|
||||||
|
//
|
||||||
|
// **The namespace comes from the registration, not from the string.** A row's
|
||||||
|
// `feature` is resolved by the provider its OWN module registered, so a module
|
||||||
|
// author writes `feature: 'status'` exactly as it reads today: nothing parses a
|
||||||
|
// prefix, and a typo'd namespace is not a thing that can exist. Core's own rows
|
||||||
|
// carry no `moduleId` and resolve against the owner id `core` — which nothing
|
||||||
|
// registers now that the shard rows are gone, and that is the correct resting
|
||||||
|
// state rather than a gap: no core nav row carries a `feature`.
|
||||||
|
//
|
||||||
|
// Everything here fails OPEN, and that is deliberate: this is presentation, the
|
||||||
|
// gate is server-side
|
||||||
|
// (a disabled feature 404s and an out-of-rung one 403s whether or not a link was
|
||||||
|
// rendered), so an unknown answer shows the link rather than blanking the nav.
|
||||||
|
// The one thing a UI mistake must never do here is hide a page from someone
|
||||||
|
// entitled to it.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The predicate the layouts filter their nav with.
|
||||||
|
*
|
||||||
|
* @param {Map<string, {has: (name: string) => boolean} | null | undefined>} flagsByOwner
|
||||||
|
* one entry per registered provider, keyed by the id of the module that
|
||||||
|
* registered it. The value is whatever that provider's hook returned this
|
||||||
|
* render: a Set-like of the flags this viewer may see, or `null` while the
|
||||||
|
* answer is still in flight.
|
||||||
|
* @returns {(item: object) => boolean}
|
||||||
|
*/
|
||||||
|
export function buildFeatureGate(flagsByOwner) {
|
||||||
|
return function isVisible(item) {
|
||||||
|
if (!item || !item.feature) return true
|
||||||
|
const owner = item.moduleId ?? 'core'
|
||||||
|
// No provider for this owner: the row names a flag nothing answers for. That
|
||||||
|
// is the no-module-installed case — no core row carries a `feature` once the
|
||||||
|
// module is out — and it is a correct no-op rather than a hidden row.
|
||||||
|
if (!flagsByOwner || !flagsByOwner.has(owner)) return true
|
||||||
|
const flags = flagsByOwner.get(owner)
|
||||||
|
// Still loading, or a provider that returned something unusable. Both are
|
||||||
|
// "we do not know yet", and both show the link.
|
||||||
|
if (!flags || typeof flags.has !== 'function') return true
|
||||||
|
return flags.has(item.feature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The gate an area with no providers gets: everything is visible. */
|
||||||
|
export const OPEN_GATE = () => true
|
||||||
|
|
||||||
|
export default buildFeatureGate
|
||||||
65
client/src/modules/features.jsx
Normal file
65
client/src/modules/features.jsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { createContext, useContext, useMemo, useState } from 'react'
|
||||||
|
import { featureProviders } from './registry.js'
|
||||||
|
import { buildFeatureGate, OPEN_GATE } from './featureGate.js'
|
||||||
|
|
||||||
|
// The React half of the feature seam. The decision logic is featureGate.js,
|
||||||
|
// which is plain JS and therefore testable in a runner with no DOM; this file is
|
||||||
|
// wiring, the same split registry.js and shared.js already use.
|
||||||
|
//
|
||||||
|
// **Calling a hook per provider inside a loop is the point, and it is legal
|
||||||
|
// here.** The rules of hooks require the same hooks in the same order on every
|
||||||
|
// render of a component — not a statically known list. The provider list is
|
||||||
|
// fixed before the first render (registration happens while module chunks
|
||||||
|
// evaluate, and main.jsx does not mount until DOMContentLoaded), there is no
|
||||||
|
// unregistering, and the snapshot below freezes it per component instance
|
||||||
|
// anyway. So the loop's length cannot change between renders of this provider,
|
||||||
|
// which is the actual requirement.
|
||||||
|
//
|
||||||
|
// A provider hook returns a Set-like of the flags this viewer may see, or `null`
|
||||||
|
// while it is still fetching. Core knows nothing else about it: what a flag
|
||||||
|
// means, how it is fetched, and what it is gated on are all the module's.
|
||||||
|
|
||||||
|
const FeatureGateContext = createContext(OPEN_GATE)
|
||||||
|
|
||||||
|
export function ModuleFeaturesProvider({ children }) {
|
||||||
|
// Snapshotted once. useState's initialiser runs on the first render only, so
|
||||||
|
// even a provider that somehow registered late cannot change this instance's
|
||||||
|
// hook count mid-life — it would be ignored until the next mount, which is a
|
||||||
|
// far better failure than a crashed render.
|
||||||
|
const [providers] = useState(featureProviders)
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks -- fixed-length list, see above
|
||||||
|
const values = providers.map((provider) => provider.hook())
|
||||||
|
|
||||||
|
const gate = useMemo(
|
||||||
|
() => {
|
||||||
|
const byOwner = new Map()
|
||||||
|
// First registration wins for a given owner: a module that registers two
|
||||||
|
// namespaces answers its own nav rows from the first, rather than from
|
||||||
|
// whichever happened to be stored last.
|
||||||
|
providers.forEach((provider, i) => {
|
||||||
|
if (!byOwner.has(provider.id)) byOwner.set(provider.id, values[i])
|
||||||
|
})
|
||||||
|
return buildFeatureGate(byOwner)
|
||||||
|
},
|
||||||
|
// One dependency per provider — a fixed-length list, for the same reason the
|
||||||
|
// hook loop above is fixed-length.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[providers, ...values],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <FeatureGateContext.Provider value={gate}>{children}</FeatureGateContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The predicate to filter nav rows with: `(item) => boolean`, true when the row
|
||||||
|
* carries no `feature` or when its module says this viewer may see it.
|
||||||
|
*
|
||||||
|
* Outside a provider it is the open gate, so a component rendered in isolation
|
||||||
|
* (a test, a preview) shows its whole nav rather than none of it.
|
||||||
|
*/
|
||||||
|
export function useFeatureGate() {
|
||||||
|
return useContext(FeatureGateContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModuleFeaturesProvider
|
||||||
174
client/src/modules/nav.js
Normal file
174
client/src/modules/nav.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
// The interleave of module nav items into core's nav.
|
||||||
|
//
|
||||||
|
// Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.4 states the problem);
|
||||||
|
// the normative contract is docs/website/MODULE_API.md §3.3.
|
||||||
|
//
|
||||||
|
// **Module items join the BASE array, before anything else happens to it.** That
|
||||||
|
// is the whole design of this file and the override merge next door forces it:
|
||||||
|
// `applyNavOverrides` / `buildPublicNav` are keyed by `to` and drop any key the
|
||||||
|
// base array does not declare (lib/navOverrides.js — deliberately, so a deleted
|
||||||
|
// route cannot leave a stale row doing something unexpected later). Append
|
||||||
|
// module items *after* that merge and they are unreachable to Admin →
|
||||||
|
// Navigation: unorderable, unrelabellable, unhideable. Today's UO rows are all
|
||||||
|
// three of those things, so appending would make the extraction a visible
|
||||||
|
// regression for every operator who has ever touched their nav.
|
||||||
|
//
|
||||||
|
// So the pipeline gains one step at the front and nothing else changes:
|
||||||
|
//
|
||||||
|
// withModuleNav(NAV, area) → admin overrides → role/feature filter → rendered
|
||||||
|
//
|
||||||
|
// and the filter stays last, which is what keeps it the boundary an override
|
||||||
|
// cannot cross (THEMING_AND_NAV.md §7). MODULE_API.md §3.3 wrote those last two
|
||||||
|
// the other way round; the code is right and the contract was amended.
|
||||||
|
//
|
||||||
|
// The result is that a module row is, to everything downstream, an ordinary row.
|
||||||
|
// Nothing in navOverrides.js, NavEditor.jsx or the layouts knows a module exists.
|
||||||
|
|
||||||
|
import { navFor } from './registry.js'
|
||||||
|
import { isGrouped } from '../lib/navOverrides.js'
|
||||||
|
|
||||||
|
// Rows with no group of their own are collected under this key. A Symbol rather
|
||||||
|
// than a string so it cannot collide with a group an admin or a module names.
|
||||||
|
const UNGROUPED = Symbol('ungrouped')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort by effective position, where a row that asked for nothing keeps the index
|
||||||
|
* it already had. Three tie-breaks, in this order: an explicit `order` beats a
|
||||||
|
* coincidental index (the module said "third", so third), and two explicit
|
||||||
|
* orders keep registration order, which `navFor` has already put in scan order.
|
||||||
|
*
|
||||||
|
* The same rule byOrder/place use in lib/navOverrides.js, and it has to be — an
|
||||||
|
* admin who then drags that row is editing the position this produced.
|
||||||
|
*/
|
||||||
|
function place(entries) {
|
||||||
|
return entries
|
||||||
|
.map((entry, index) => ({ ...entry, index }))
|
||||||
|
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit) || a.index - b.index)
|
||||||
|
.map(({ item }) => item)
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryFor(item, fallbackKey) {
|
||||||
|
return { item, key: item.order ?? fallbackKey, explicit: item.order !== undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
function coreEntries(items) {
|
||||||
|
return items.map((item, index) => ({ item, key: index, explicit: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `to`s a base nav already claims, flat or grouped. */
|
||||||
|
function claimedPaths(baseNav, grouped) {
|
||||||
|
return new Set(grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a module row whose `to` is already on the nav, and say so.
|
||||||
|
*
|
||||||
|
* Not a policy about where a module may link — it is that `to` is the KEY the
|
||||||
|
* override layer stores under and React renders by. Two rows sharing one would
|
||||||
|
* give an admin a single editor row that silently moves both, and a duplicate
|
||||||
|
* key in the rendered list. Dropping the newcomer keeps core's row, which is the
|
||||||
|
* one any existing override was written against.
|
||||||
|
*
|
||||||
|
* Fail-safe like every other read in this area: the offending row goes, its
|
||||||
|
* neighbours stay.
|
||||||
|
*/
|
||||||
|
function withoutCollisions(items, claimed) {
|
||||||
|
const out = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item || typeof item.to !== 'string' || !item.to) continue
|
||||||
|
if (claimed.has(item.to)) {
|
||||||
|
console.warn(
|
||||||
|
`[modules] nav item "${item.to}" from module "${item.moduleId}" collides with an existing row and was dropped`,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
claimed.add(item.to)
|
||||||
|
out.push(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// The flat navs — the public header and the player portal.
|
||||||
|
//
|
||||||
|
// No groups, so `order` is a position in the one list: core rows are keyed by
|
||||||
|
// their index and a module row by the `order` it asked for. A module row with no
|
||||||
|
// order appends after the coded ones, in registration order, rather than jumping
|
||||||
|
// to the front on a 0 default — the same choice buildPublicNav makes for an
|
||||||
|
// admin-created link.
|
||||||
|
function mergeFlat(baseNav, items) {
|
||||||
|
return place([...coreEntries(baseNav), ...items.map((item, i) => entryFor(item, baseNav.length + i))])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The grouped nav — the admin sidebar.
|
||||||
|
//
|
||||||
|
// `group` names an existing core group and the row lands inside it: Moderation
|
||||||
|
// and System, where today's UO rows already sit (§1.4). An unknown group name
|
||||||
|
// creates a group at the end rather than dropping the row — a typo must cost a
|
||||||
|
// position, never a link. A row with no `group` at all lands in a trailing
|
||||||
|
// untitled group, which renders as ungrouped links; core does not invent a
|
||||||
|
// display title out of a module id.
|
||||||
|
//
|
||||||
|
// An ungrouped row is NOT folded into one of core's own untitled groups
|
||||||
|
// (Dashboard's, Account's): those are furniture pinned to the top and bottom of
|
||||||
|
// the sidebar, and a module page does not belong beside "Account".
|
||||||
|
//
|
||||||
|
// A group created here is a group as far as everything downstream is concerned,
|
||||||
|
// including as a destination in Admin → Navigation's "move to section" control:
|
||||||
|
// `readOverrides` builds its set of legal destinations from the base nav it is
|
||||||
|
// handed, which is this one.
|
||||||
|
function mergeGrouped(baseNav, items) {
|
||||||
|
const titles = new Set(baseNav.map((g) => g.title).filter((t) => typeof t === 'string'))
|
||||||
|
const into = new Map() // existing group title → rows
|
||||||
|
const fresh = new Map() // new group title (or UNGROUPED) → rows, first-seen order
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const named = typeof item.group === 'string' && item.group ? item.group : null
|
||||||
|
const key = named ?? UNGROUPED
|
||||||
|
const bucket = named !== null && titles.has(named) ? into : fresh
|
||||||
|
if (!bucket.has(key)) bucket.set(key, [])
|
||||||
|
bucket.get(key).push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const kept = baseNav.map((g) => {
|
||||||
|
const incoming = into.get(g.title)
|
||||||
|
if (!incoming) return g
|
||||||
|
return {
|
||||||
|
...g,
|
||||||
|
items: place([...coreEntries(g.items), ...incoming.map((item, i) => entryFor(item, g.items.length + i))]),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const created = [...fresh.entries()].map(([key, rows]) => {
|
||||||
|
const items_ = place(rows.map((item, i) => entryFor(item, i)))
|
||||||
|
return key === UNGROUPED ? { items: items_ } : { title: key, items: items_ }
|
||||||
|
})
|
||||||
|
|
||||||
|
return [...kept, ...created]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base nav a layout should render: core's coded array with every installed
|
||||||
|
* module's rows for this area interleaved into it.
|
||||||
|
*
|
||||||
|
* Returns `baseNav` ITSELF when no module registered anything for this area, so
|
||||||
|
* an instance with no modules installed renders the identical array it renders
|
||||||
|
* today — the same "untouched path" guarantee applyNavOverrides makes, and what
|
||||||
|
* makes a `useMemo` with an empty dependency list around this call honest.
|
||||||
|
*
|
||||||
|
* Safe to call once per component and cache: registration completes before the
|
||||||
|
* first render (main.jsx waits for DOMContentLoaded — MODULE_API.md §3.1) and
|
||||||
|
* there is no unregistering, so this answer cannot change during a session.
|
||||||
|
*
|
||||||
|
* @param {Array} baseNav the coded NAV, flat or grouped
|
||||||
|
* @param {'public'|'admin'|'player'} area
|
||||||
|
* @returns {Array} a nav of the same shape
|
||||||
|
*/
|
||||||
|
export function withModuleNav(baseNav, area) {
|
||||||
|
if (!Array.isArray(baseNav)) return []
|
||||||
|
const grouped = isGrouped(baseNav)
|
||||||
|
const items = withoutCollisions(navFor(area), claimedPaths(baseNav, grouped))
|
||||||
|
if (items.length === 0) return baseNav
|
||||||
|
return grouped ? mergeGrouped(baseNav, items) : mergeFlat(baseNav, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withModuleNav
|
||||||
@@ -1,23 +1,39 @@
|
|||||||
// ── The client-side module registry ────────────────────────────────────────
|
// ── The client-side module registry ────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// A module's prebuilt chunk registers its routes, nav entries and feature
|
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7. The normative contract is
|
||||||
// provider here, and App.jsx / the nav components read them back. This is the
|
// docs/website/MODULE_API.md §3.3; where the two disagree, the contract wins.
|
||||||
// client half of docs/website/MODULE_API.md §3.3.
|
|
||||||
//
|
//
|
||||||
// Timing is the whole design. Module chunks are `<script type="module" src>`
|
// A module's prebuilt chunk registers its routes, its nav entries and its feature
|
||||||
// tags injected into <head> by the server (utils/htmlShell.js). Module scripts
|
// provider here, and core reads them back. This is the client twin of the
|
||||||
// are deferred, so they evaluate after the SPA's own bundle has run — which is
|
// server's modules/loader.js — with one structural difference worth stating,
|
||||||
// where window.__rg is published — and before DOMContentLoaded. main.jsx waits
|
// because it is what makes the file this short: core *hands* the registry to the
|
||||||
// for that same event before calling render(), so registration is complete
|
// module (on `window.__rg`, see shared.js) rather than discovering it. There is
|
||||||
// before React reads any of this and there is no re-render to orchestrate.
|
// nothing to scan, nothing to validate a manifest against, and no failure mode
|
||||||
|
// where half a module is registered.
|
||||||
//
|
//
|
||||||
// Registration is therefore a plain synchronous write with no subscribers, not
|
// **Timing is the whole design.** Module chunks are `<script type="module" src>`
|
||||||
// an observable store. If that ever changes, it changes here and not in twelve
|
// tags the server injects before `</body>` (server/src/utils/htmlShell.js), after
|
||||||
// consumers.
|
// core's own bundle. Module scripts are deferred, so they evaluate after that
|
||||||
|
// bundle has run — which is where `window.__rg` is published — and all of them
|
||||||
|
// finish before DOMContentLoaded. main.jsx waits for that same event before
|
||||||
|
// calling render(), so registration is complete before React reads any of this.
|
||||||
|
//
|
||||||
|
// That is what buys the simplicity here: registration is a plain synchronous
|
||||||
|
// write with no subscribers, not an observable store, because nothing can
|
||||||
|
// register after the first render. If that ever stops being true it changes in
|
||||||
|
// this file and in main.jsx, not in a dozen consumers.
|
||||||
|
//
|
||||||
|
// What PR 7 wires up is `routesFor` (App.jsx). `navFor` and `featureProviderFor`
|
||||||
|
// are stored and returned faithfully but core does not read them yet — PR 8 adds
|
||||||
|
// the nav interleave and the feature-provider seam. Storing them is not the kind
|
||||||
|
// of accepting stub the server's registries refused to be: nothing is discarded
|
||||||
|
// here, so a module that registers nav in this core gets it back from `navFor`.
|
||||||
|
|
||||||
const routes = { public: [], admin: [], player: [] }
|
const routes = { public: [], admin: [], player: [] }
|
||||||
const nav = { public: [], admin: [], player: [] }
|
const nav = { public: [], admin: [], player: [] }
|
||||||
const featureProviders = new Map()
|
const providers = new Map()
|
||||||
|
// slot name → { Component, filledBy }.
|
||||||
|
const slots = new Map()
|
||||||
const registered = new Set()
|
const registered = new Set()
|
||||||
|
|
||||||
const AREAS = ['public', 'admin', 'player']
|
const AREAS = ['public', 'admin', 'player']
|
||||||
@@ -27,18 +43,26 @@ function assertArea(area, call) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route components for one area.
|
* Route components, by area.
|
||||||
* @param {string} id the module id, used to namespace the URL segment
|
*
|
||||||
|
* @param {string} id the module id — the URL segment its routes are namespaced under
|
||||||
* @param {{public?: Array, admin?: Array, player?: Array}} byArea
|
* @param {{public?: Array, admin?: Array, player?: Array}} byArea
|
||||||
* each entry `{ path, element, gate? }`; `path` is relative to the module's
|
* each entry `{ path, element, gate? }`. `path` is relative to the module's
|
||||||
* namespace and core prefixes it (`/uo/…`, `/admin/uo/…`, `/player/uo/…`)
|
* namespace; core prefixes it and mounts it inside the area's existing wrapper
|
||||||
|
* (`/<id>/…` under MaintenanceGate, `/admin/<id>/…` under RequireAuth +
|
||||||
|
* AdminLayout, `/player/<id>/…` under RequirePlayer + PlayerPortalLayout).
|
||||||
|
* `gate` is an optional `{ roles: [...] }` that core applies as its own
|
||||||
|
* RoleGate — a module cannot supply an auth wrapper, because the sidebar and
|
||||||
|
* the route table have to agree about who may see what (§3.3).
|
||||||
*/
|
*/
|
||||||
export function registerRoutes(id, byArea) {
|
export function registerRoutes(id, byArea) {
|
||||||
for (const [area, list] of Object.entries(byArea || {})) {
|
for (const [area, list] of Object.entries(byArea || {})) {
|
||||||
assertArea(area, 'registerRoutes')
|
assertArea(area, 'registerRoutes')
|
||||||
for (const route of list) {
|
for (const route of list || []) {
|
||||||
// Prefixed here rather than by the module, so a module cannot claim a path
|
// Prefixed HERE rather than by the module: a module cannot claim a path
|
||||||
// outside its own namespace however it spells `path`.
|
// outside its own namespace however it spells `path` — a leading `/`, a
|
||||||
|
// trailing one, or several — because it never gets to write the segment
|
||||||
|
// its routes hang under.
|
||||||
const path = `${id}/${String(route.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '')
|
const path = `${id}/${String(route.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '')
|
||||||
routes[area].push({ ...route, path, moduleId: id })
|
routes[area].push({ ...route, path, moduleId: id })
|
||||||
}
|
}
|
||||||
@@ -47,52 +71,159 @@ export function registerRoutes(id, byArea) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nav entries, interleaved into CORE groups rather than appended as a block —
|
* Nav entries, interleaved into CORE groups rather than appended as a block.
|
||||||
* today's UO items sit inside core's Moderation and System groups, and a "UO"
|
*
|
||||||
* group at the bottom would be a visible regression (MODULE_SYSTEM.md §1.4).
|
* Today's UO items sit inside core's own Moderation and System groups; a "UO"
|
||||||
|
* group at the bottom of the sidebar would be a visible regression on the day
|
||||||
|
* the module is extracted (MODULE_SYSTEM.md §1.4). `group` names an existing
|
||||||
|
* core group, `order` sorts within it, and an unknown group name appends rather
|
||||||
|
* than dropping the item — a mis-typed group must cost a position, never a link.
|
||||||
|
*
|
||||||
|
* `icon` is a component core renders exactly as it renders its own rows' icons
|
||||||
|
* (1.3.0). It exists because without it the six UO rows would have extracted as
|
||||||
|
* the only text-only entries in a sidebar where every other row has a glyph,
|
||||||
|
* which reads as breakage rather than as a design. Core does not supply a
|
||||||
|
* fallback: a module that omits it gets no icon, the same as a core row that
|
||||||
|
* omits it, and inventing one would be core making a presentation choice for
|
||||||
|
* content it knows nothing about. Note that `icon` is already among the fields
|
||||||
|
* an override may not touch (lib/navOverrides.js) — the concept predates a
|
||||||
|
* module being able to supply one.
|
||||||
|
*
|
||||||
* @param {string} id
|
* @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) {
|
export function registerNav(id, spec) {
|
||||||
const { area, items } = spec || {}
|
const { area, items } = spec || {}
|
||||||
assertArea(area, 'registerNav')
|
assertArea(area, 'registerNav')
|
||||||
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
|
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
|
||||||
|
registered.add(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The hook that answers "which of this module's features may this viewer see".
|
* 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; with no
|
*
|
||||||
* module installed the nav filter is a correct no-op, because no core nav item
|
* Core keeps a generic flag context and owns none of the semantics
|
||||||
* carries a `feature` today (MODULE_SYSTEM.md §1.5).
|
* (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) {
|
export function registerFeatureProvider(id, namespace, hook) {
|
||||||
featureProviders.set(namespace, { id, hook })
|
providers.set(namespace, { id, hook })
|
||||||
|
registered.add(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Extension slots (§3.7) ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The client twin of the server's declareSlot/registerExtension, and the same
|
||||||
|
// rule in both halves: core declares a slot, ONLY core declares one, and at most
|
||||||
|
// one module fills it. Core renders `<Slot name>` (Slot.jsx) and gets nothing
|
||||||
|
// back when the slot is unfilled — so an instance with no module installed
|
||||||
|
// renders exactly what it renders today.
|
||||||
|
//
|
||||||
|
// A slot is named for a PLACE, never for a meaning. `site.footer.status` is a
|
||||||
|
// position in the footer and the styling that goes with it; the label, the
|
||||||
|
// target, the data and whether anything renders at all are the module's. The
|
||||||
|
// moment core types a slot by its content it has re-acquired the game semantics
|
||||||
|
// this whole extraction removes.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name the slot id. Core-only — deliberately not on the
|
||||||
|
* `registry` object handed to modules.
|
||||||
|
*/
|
||||||
|
export function declareSlot(name) {
|
||||||
|
if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`)
|
||||||
|
slots.set(name, { Component: null, filledBy: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill a declared slot with a component.
|
||||||
|
*
|
||||||
|
* **This is the one place the client registry is not fail-open**, and the
|
||||||
|
* asymmetry is deliberate. A dropped nav row costs a link the viewer can reach
|
||||||
|
* another way; a silently dropped extension is invisible to everyone including
|
||||||
|
* its author. So an unknown slot, a non-component, and a second fill all throw —
|
||||||
|
* exactly as the server's checkExtensionShape does.
|
||||||
|
*
|
||||||
|
* A throw here is always a programming error and never a race, because
|
||||||
|
* declaration structurally precedes filling: core declares in main.jsx, inside
|
||||||
|
* its own bundle, and every module chunk is a deferred script injected after it
|
||||||
|
* (§3.1).
|
||||||
|
*/
|
||||||
|
export function registerExtension(id, slot, Component) {
|
||||||
|
const entry = slots.get(slot)
|
||||||
|
if (!entry) throw new Error(`registerExtension: unknown extension slot "${slot}"`)
|
||||||
|
if (typeof Component !== 'function') throw new Error(`registerExtension: ${slot} is not a component`)
|
||||||
|
if (entry.filledBy) throw new Error(`extension slot "${slot}" is already filled by "${entry.filledBy}"`)
|
||||||
|
entry.Component = Component
|
||||||
|
entry.filledBy = id
|
||||||
|
registered.add(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The filling component, or null.
|
||||||
|
*
|
||||||
|
* Read by Slot.jsx and nothing else — deliberately. There is no `hasExtension`
|
||||||
|
* for a core layout to branch on, because a layout that asks whether a slot is
|
||||||
|
* filled and then renders its own decoration alongside gets the *failed* case
|
||||||
|
* wrong: the extension is filled, so the decoration renders, and the component
|
||||||
|
* then throws into the boundary leaving the decoration behind on its own. Core
|
||||||
|
* decorates through `<Slot wrap>` instead, which puts the decoration inside the
|
||||||
|
* boundary where it shares the extension's fate. (Found in a browser, with the
|
||||||
|
* footer's separator.)
|
||||||
|
*
|
||||||
|
* Undeclared and unfilled both read null: reading is fail-safe, and only writing
|
||||||
|
* is strict.
|
||||||
|
*/
|
||||||
|
export const extensionFor = (slot) => (slots.get(slot) || {}).Component || null
|
||||||
|
|
||||||
export const routesFor = (area) => routes[area] || []
|
export const routesFor = (area) => routes[area] || []
|
||||||
|
|
||||||
// Sorted by the `order` a module asked for, stable within equal orders so two
|
// Sorted by the `order` a module asked for. Array#sort is stable in every engine
|
||||||
// modules registering the same slot stay in load (alphabetical id) order.
|
// this ships to, so two modules asking for the same slot keep load order —
|
||||||
|
// which is alphabetical by id, the same order the server scans in (§4.2).
|
||||||
export const navFor = (area) =>
|
export const navFor = (area) =>
|
||||||
[...(nav[area] || [])].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
[...(nav[area] || [])].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
||||||
|
|
||||||
export const featureProviderFor = (namespace) => featureProviders.get(namespace)
|
export const featureProviderFor = (namespace) => providers.get(namespace)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every registered provider, for core's feature context to call.
|
||||||
|
*
|
||||||
|
* Exported from the module but deliberately NOT a member of the `registry`
|
||||||
|
* object below: a module asks for a namespace it knows the name of, and has no
|
||||||
|
* business enumerating what everyone else registered. Core needs the list
|
||||||
|
* because it has to call each hook — unconditionally, in a fixed order, at the
|
||||||
|
* top of a component (modules/features.jsx).
|
||||||
|
*/
|
||||||
|
export const featureProviders = () =>
|
||||||
|
[...providers.entries()].map(([namespace, { id, hook }]) => ({ id, namespace, hook }))
|
||||||
|
|
||||||
export const registeredIds = () => [...registered]
|
export const registeredIds = () => [...registered]
|
||||||
|
|
||||||
// Test seam.
|
/** Test seam. Nothing in the app calls this — there is no unregistering. */
|
||||||
export function _reset() {
|
export function _reset() {
|
||||||
for (const area of AREAS) {
|
for (const area of AREAS) {
|
||||||
routes[area].length = 0
|
routes[area].length = 0
|
||||||
nav[area].length = 0
|
nav[area].length = 0
|
||||||
}
|
}
|
||||||
featureProviders.clear()
|
providers.clear()
|
||||||
|
// Declarations go too, unlike the server's, where a slot is declared once at
|
||||||
|
// require time by the router that owns it. Core declares its slots in
|
||||||
|
// main.jsx — the one file no test loads — so on this side there is nothing
|
||||||
|
// declared at import time for a surviving declaration to protect.
|
||||||
|
slots.clear()
|
||||||
registered.clear()
|
registered.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The object handed to modules on window.__rg.registry. Deliberately the write
|
||||||
|
// calls plus the read ones: a module reading `routesFor` is how it finds out
|
||||||
|
// another module is installed, which is the only supported form of module-to-
|
||||||
|
// module awareness (there is no dependency resolution).
|
||||||
export const registry = {
|
export const registry = {
|
||||||
registerRoutes,
|
registerRoutes,
|
||||||
registerNav,
|
registerNav,
|
||||||
registerFeatureProvider,
|
registerFeatureProvider,
|
||||||
|
registerExtension,
|
||||||
routesFor,
|
routesFor,
|
||||||
navFor,
|
navFor,
|
||||||
featureProviderFor,
|
featureProviderFor,
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
// ── window.__rg — the shared-dependency global ─────────────────────────────
|
// ── window.__rg — the shared-dependency global ─────────────────────────────
|
||||||
//
|
//
|
||||||
// A module's client half is a PREBUILT ESM chunk (the operator never builds
|
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7; the normative shape is
|
||||||
// anything), served same-origin, and loaded under `script-src 'self'` with no
|
// docs/website/MODULE_API.md §3.2.
|
||||||
// 'unsafe-inline'. That combination is what rules out an import map: an import
|
|
||||||
// map has to be an inline <script type="importmap">, and CSP forbids it
|
|
||||||
// (MODULE_SYSTEM.md §1.14). So the shared dependencies ride on a global and the
|
|
||||||
// module's externals resolve against it — docs/website/MODULE_API.md §3.2.
|
|
||||||
//
|
//
|
||||||
// There is exactly ONE React in the page and core owns it. A module that bundled
|
// A module's client half is a PREBUILT ESM chunk — the operator never builds
|
||||||
// its own would get a second hook dispatcher and fail at the first useState.
|
// anything (MODULE_SYSTEM.md §1.14) — served same-origin and loaded under
|
||||||
|
// `script-src 'self'` with no 'unsafe-inline'. That combination is what rules out
|
||||||
|
// an import map: an import map has to be an inline `<script type="importmap">`,
|
||||||
|
// and the policy forbids inline scripts outright. So the shared dependencies ride
|
||||||
|
// on a global, and the module's Rollup externals are aliased to two-line shims
|
||||||
|
// that re-export from it (§3.6).
|
||||||
|
//
|
||||||
|
// **There is exactly one React in the page and core owns it.** A module that
|
||||||
|
// bundled its own would get a second hook dispatcher and fail at its first
|
||||||
|
// useState. That is the same rule the server half enforces for `express` and
|
||||||
|
// `express-validator` on `ctx`, and for the same reason: anything shared between
|
||||||
|
// core and a module is owned by core and HANDED OVER, never resolved by the
|
||||||
|
// module.
|
||||||
|
|
||||||
import * as react from 'react'
|
import * as react from 'react'
|
||||||
import * as reactDom from 'react-dom/client'
|
import * as reactDom from 'react-dom/client'
|
||||||
import * as router from 'react-router-dom'
|
import * as router from 'react-router-dom'
|
||||||
// The automatic JSX runtime. Without this a module would have to build with
|
// The automatic JSX runtime, and it is not decoration. A module's bundler
|
||||||
// `jsxRuntime: 'classic'` — its bundler emits `react/jsx-runtime` imports by
|
// compiles every .jsx file to imports from `react/jsx-runtime` under the modern
|
||||||
// default, and those have to resolve to CORE's React like every other one.
|
// default, and those have to resolve to CORE's React like every other import.
|
||||||
// Exposing it here is what lets a module use the modern default.
|
// Without it here a module would have to build with `jsxRuntime: 'classic'`;
|
||||||
|
// with it, a module uses the default its tooling already assumes.
|
||||||
import * as jsxRuntime from 'react/jsx-runtime'
|
import * as jsxRuntime from 'react/jsx-runtime'
|
||||||
|
|
||||||
import { registry } from './registry.js'
|
import { registry } from './registry.js'
|
||||||
@@ -28,12 +37,26 @@ import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
|
|||||||
import { useAsync } from '../lib/useAsync.js'
|
import { useAsync } from '../lib/useAsync.js'
|
||||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
import { useSite } from '../contexts/SiteContext.jsx'
|
import { useSite } from '../contexts/SiteContext.jsx'
|
||||||
import { request, ApiError } from '../api/client.js'
|
import { request, ApiError, BASE } from '../api/client.js'
|
||||||
|
|
||||||
// The kit is CURATED AND CLOSED, not a re-export of components/ — see §3.4.
|
// The UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These
|
||||||
// Adding to it is a minor MODULE_API_VERSION bump; changing a member's props is
|
// eight exports — five table rows in §3.4, since `PageState` contributes three —
|
||||||
// a major one. That is a real constraint on core, and it is the price of module
|
// are what the smallest UO page already needs beyond React and the router:
|
||||||
// pages looking like the site they are installed in.
|
// without them a module either reaches into core's tree — violating the
|
||||||
|
// zero-import rule the whole boundary rests on — or ships its own copies, which
|
||||||
|
// means a module page that does not look like the site it is installed in, and
|
||||||
|
// that drifts further every time core's layout changes.
|
||||||
|
//
|
||||||
|
// Adding a member is a MINOR MODULE_API_VERSION bump; changing a member's props
|
||||||
|
// is a MAJOR one. That is a real constraint on core's own refactoring and it is
|
||||||
|
// the price of the boundary being worth anything.
|
||||||
|
//
|
||||||
|
// `AdminPage` was in an early draft of §3.4's table and is deliberately absent:
|
||||||
|
// core has no such component — admin views are plain markup inside AdminLayout —
|
||||||
|
// and inventing one to satisfy a table would be a core change with no consumer
|
||||||
|
// until Phase 3. The contract was amended rather than the code padded (it no
|
||||||
|
// longer lists it), and adding it later costs a minor bump, which is exactly the
|
||||||
|
// case the versioning is for.
|
||||||
const ui = {
|
const ui = {
|
||||||
PublicLayout,
|
PublicLayout,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
@@ -45,12 +68,26 @@ const ui = {
|
|||||||
useSite,
|
useSite,
|
||||||
}
|
}
|
||||||
|
|
||||||
// The request PRIMITIVE, not the api object: api.atlas and api.shard are module
|
// The request PRIMITIVE, not the `api` object (§3.5): a module builds its own
|
||||||
// bindings that live in core's client today and move out with the module (§3.5).
|
// namespace over `request` and owns the paths it calls, which is right, because
|
||||||
// A module owns the paths it calls, which is right — it owns the routes at the
|
// it owns the routes at the other end.
|
||||||
// other end.
|
//
|
||||||
const api = { request, ApiError }
|
// `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
|
||||||
|
* module chunk evaluates.
|
||||||
|
*
|
||||||
|
* Frozen, one level down as well as at the top: the object a module reaches for
|
||||||
|
* its React is not somewhere a module gets to leave something for the next one.
|
||||||
|
* Cross-module communication is a thing the contract does not have, and an
|
||||||
|
* unfrozen global is how a codebase acquires one by accident.
|
||||||
|
*/
|
||||||
export function publishSharedDependencies() {
|
export function publishSharedDependencies() {
|
||||||
window.__rg = Object.freeze({
|
window.__rg = Object.freeze({
|
||||||
version: MODULE_API_VERSION,
|
version: MODULE_API_VERSION,
|
||||||
@@ -62,4 +99,5 @@ export function publishSharedDependencies() {
|
|||||||
ui: Object.freeze(ui),
|
ui: Object.freeze(ui),
|
||||||
api: Object.freeze(api),
|
api: Object.freeze(api),
|
||||||
})
|
})
|
||||||
|
return window.__rg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,41 @@
|
|||||||
// The client's copy of MODULE_API_VERSION. Must equal the server's
|
// The client's copy of MODULE_API_VERSION. It must equal the server's
|
||||||
// (server/src/modules/version.js) — they version ONE contract, and a module
|
// (server/src/modules/version.js) — the two halves version ONE contract
|
||||||
// checks whichever half it is talking to.
|
// (docs/website/MODULE_API.md §1.1), and a module checks whichever half it is
|
||||||
|
// talking to: `coreApi` against the server's at load time, `window.__rg.version`
|
||||||
|
// against the client's before it registers anything.
|
||||||
//
|
//
|
||||||
// Duplicated rather than fetched: the value has to be on window.__rg before the
|
// Duplicated rather than fetched, and that is deliberate. The value has to be on
|
||||||
// first module script evaluates, and that is earlier than any network round trip.
|
// `window.__rg` before the first module chunk evaluates, which is earlier than
|
||||||
// A test asserts the two files agree.
|
// any network round trip could answer — a fetched version would mean either an
|
||||||
export const MODULE_API_VERSION = '1.0.0'
|
// await before render or a module reading `undefined`. The cost of the copy is
|
||||||
|
// that the two files can drift, so a test asserts they agree
|
||||||
|
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||||
|
// both.
|
||||||
|
// 1.5.0 — `PublicLayout` takes an optional `shell` prop ('narrow' | 'mid' |
|
||||||
|
// 'wide') that renders the `shell-… page-body` wrapper core's own pages write by
|
||||||
|
// hand. Additive: omitting it is 1.4.0's behaviour, so §3.4's "changing a kit
|
||||||
|
// component's props is major" does not bite — nothing already written changes
|
||||||
|
// meaning. It exists because the kit's acceptance run proved a module cannot
|
||||||
|
// discover the wrapper: the class names are theme.css's and appear in no
|
||||||
|
// contract, so a module page rendered outside the site's column while doing
|
||||||
|
// everything the kit said (docs/modules/kit-acceptance.md).
|
||||||
|
// 1.4.0 — a rule, not a member: §2.7 forbids a module opening a connection to a
|
||||||
|
// game server from the website process (it talks to a sidecar, which owns the
|
||||||
|
// durable copy). Nothing on window.__rg changed and nothing on the server's ctx
|
||||||
|
// changed either; this half bumps because the two halves state ONE version.
|
||||||
|
// 1.3.0 — three additions, all from Phase 3 slice 3 needing them: a nav item may
|
||||||
|
// carry an `icon` component (§3.3), core declares a third slot
|
||||||
|
// `player.invite.accepted` (§3.7), and `window.__rg.api` gained `BASE`, which
|
||||||
|
// §3.5 always documented and shared.js never published. Additive throughout: a
|
||||||
|
// module written against 1.2.0 is unaffected. The server half is untouched and
|
||||||
|
// bumps anyway, for the reason below.
|
||||||
|
// 1.2.0 — `registry` gained `registerExtension` and core gained extension slots
|
||||||
|
// (MODULE_API.md §3.7). The first change to window.__rg since 1.0.0, and an
|
||||||
|
// addition: a module that never fills a slot is unaffected. The server half is
|
||||||
|
// untouched and bumps anyway, for the reason below.
|
||||||
|
// 1.1.0 — the server's ctx gained activity.log, users.getById, site.baseUrl and
|
||||||
|
// the rate-limit factory (MODULE_API.md §2.3). Nothing on window.__rg changed,
|
||||||
|
// but the two halves state ONE version: a module declares a single coreApi range
|
||||||
|
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||||
|
// answered 1.1.0 would be two answers to one question.
|
||||||
|
export const MODULE_API_VERSION = '1.5.0'
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { useAuth } from '../../contexts/AuthContext.jsx'
|
|||||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
import { applyNavOverrides } from '../../lib/navOverrides.js'
|
import { applyNavOverrides } from '../../lib/navOverrides.js'
|
||||||
import { useNavOverrides } from '../../lib/useNavOverrides.js'
|
import { useNavOverrides } from '../../lib/useNavOverrides.js'
|
||||||
|
import { withModuleNav } from '../../modules/nav.js'
|
||||||
|
import { useFeatureGate } from '../../modules/features.jsx'
|
||||||
|
import { navItemVisibleTo, allowedPathsFor, isAllowedPath } from '../../lib/adminNav.js'
|
||||||
|
|
||||||
// Small inline stroke icons (16px, currentColor) — same style as ProviderIcon.
|
// Small inline stroke icons (16px, currentColor) — same style as ProviderIcon.
|
||||||
// One shared frame keeps them terse; each item just supplies its path(s).
|
// One shared frame keeps them terse; each item just supplies its path(s).
|
||||||
@@ -40,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 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 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 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 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 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
|
// 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`
|
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
|
||||||
@@ -73,8 +76,6 @@ export const NAV = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
|
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||||
{ to: '/admin/moderation/appeals', label: 'Appeals', 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'] },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -83,20 +84,20 @@ export const NAV = [
|
|||||||
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
|
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
|
||||||
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
|
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
|
||||||
{ to: '/admin/settings', label: 'Settings', icon: IconGear, 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/appearance', label: 'Appearance', icon: IconPalette, roles: ['admin'] },
|
||||||
{ to: '/admin/navigation', label: 'Navigation', icon: IconNav, roles: ['admin'] },
|
{ to: '/admin/navigation', label: 'Navigation', icon: IconNav, roles: ['admin'] },
|
||||||
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
|
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
|
||||||
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, 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/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'] },
|
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
items: [
|
items: [
|
||||||
{ to: '/admin/characters', label: 'My Characters', icon: IconShard },
|
|
||||||
{ to: '/admin/account', label: 'Account', icon: IconUser },
|
{ to: '/admin/account', label: 'Account', icon: IconUser },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -104,10 +105,6 @@ export const NAV = [
|
|||||||
|
|
||||||
const COLLAPSE_KEY = 'admin.nav.collapsed'
|
const COLLAPSE_KEY = 'admin.nav.collapsed'
|
||||||
|
|
||||||
// Moderators only get the moderation section (Discord + in-game ops) + their
|
|
||||||
// own account security.
|
|
||||||
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
|
|
||||||
|
|
||||||
// The one row an override may never hide: the nav editor itself, which is the
|
// The one row an override may never hide: the nav editor itself, which is the
|
||||||
// only screen that can un-hide anything. The write path already refuses it
|
// only screen that can un-hide anything. The write path already refuses it
|
||||||
// (server/src/utils/navOverrides.js) and the editor's own toggle is disabled —
|
// (server/src/utils/navOverrides.js) and the editor's own toggle is disabled —
|
||||||
@@ -123,15 +120,11 @@ function keepEditorReachable(overrides) {
|
|||||||
return { ...overrides, [UNHIDEABLE]: rest }
|
return { ...overrides, [UNHIDEABLE]: rest }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Who may see a sidebar row. The single authority for that question: the layout
|
// Who may see a sidebar row, and where that lets them go, both derived from the
|
||||||
// applies it after the override merge (overrides are presentation, this is the
|
// row's own `roles` — lib/adminNav.js, which is where the two hardcoded path
|
||||||
// boundary — §7), and Admin -> Navigation applies it to build its palette, so an
|
// lists this component used to carry went (MODULE_SYSTEM.md §1.4). Re-exported
|
||||||
// admin is never offered a row they cannot themselves see (§8.1).
|
// because Admin -> Navigation has always imported it from here.
|
||||||
export function navItemVisibleTo(item, role) {
|
export { navItemVisibleTo }
|
||||||
if (item.roles && !item.roles.includes(role)) return false
|
|
||||||
if (role === 'moderator') return MOD_PATHS.includes(item.to)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const TITLES = {
|
const TITLES = {
|
||||||
'/admin': 'Dashboard',
|
'/admin': 'Dashboard',
|
||||||
@@ -141,28 +134,34 @@ const TITLES = {
|
|||||||
'/admin/hero': 'Hero Editor',
|
'/admin/hero': 'Hero Editor',
|
||||||
'/admin/moderation': 'Moderation',
|
'/admin/moderation': 'Moderation',
|
||||||
'/admin/moderation/appeals': 'Appeals',
|
'/admin/moderation/appeals': 'Appeals',
|
||||||
'/admin/shard-ops': 'In-Game Ops',
|
|
||||||
'/admin/houses': 'House Registry',
|
|
||||||
'/admin/settings': 'Site Settings',
|
'/admin/settings': 'Site Settings',
|
||||||
'/admin/appearance': 'Appearance',
|
'/admin/appearance': 'Appearance',
|
||||||
'/admin/navigation': 'Navigation',
|
'/admin/navigation': 'Navigation',
|
||||||
'/admin/activity': 'Activity Log',
|
'/admin/activity': 'Activity Log',
|
||||||
'/admin/bot-activity': 'Web Bot Activity',
|
'/admin/bot-activity': 'Web Bot Activity',
|
||||||
'/admin/discord-bot': 'Discord Bot',
|
'/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/auth-providers': 'Authentication',
|
||||||
'/admin/users': 'Users',
|
'/admin/users': 'Users',
|
||||||
'/admin/invites': 'Invites',
|
'/admin/invites': 'Invites',
|
||||||
'/admin/account': 'Account Security',
|
'/admin/account': 'Account Security',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An installed module's admin pages are not in TITLES and cannot be — core does
|
||||||
|
// not know what they are called. Their nav row does, so the row is the title:
|
||||||
|
// the longest matching module row wins, so a detail page under a section titles
|
||||||
|
// as that section rather than falling through to a bare "Admin". Restricted to
|
||||||
|
// rows a module registered, which is what keeps every core path resolving
|
||||||
|
// through TITLES and sectionTitle exactly as it does today.
|
||||||
|
function moduleTitle(baseNav, pathname) {
|
||||||
|
return baseNav
|
||||||
|
.flatMap((g) => g.items)
|
||||||
|
.filter((i) => i.moduleId && (pathname === i.to || pathname.startsWith(`${i.to}/`)))
|
||||||
|
.sort((a, b) => b.to.length - a.to.length)[0]?.label
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback page title for dynamic sub-routes not in the exact-match TITLES map.
|
// Fallback page title for dynamic sub-routes not in the exact-match TITLES map.
|
||||||
function sectionTitle(pathname) {
|
function sectionTitle(pathname) {
|
||||||
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
|
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
|
||||||
if (pathname.startsWith('/admin/characters')) return 'My Characters'
|
|
||||||
if (pathname.startsWith('/admin/users/')) return 'User'
|
if (pathname.startsWith('/admin/users/')) return 'User'
|
||||||
return 'Admin'
|
return 'Admin'
|
||||||
}
|
}
|
||||||
@@ -186,7 +185,15 @@ export default function AdminLayout() {
|
|||||||
const navOverrides = useNavOverrides()
|
const navOverrides = useNavOverrides()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const title = TITLES[location.pathname] || sectionTitle(location.pathname)
|
const isVisible = useFeatureGate()
|
||||||
|
|
||||||
|
// Core's rows plus every installed module's, before the override merge sees
|
||||||
|
// them — so a module row is editable in Admin -> Navigation like any other
|
||||||
|
// (modules/nav.js). Computed once: the registry is fixed before the first
|
||||||
|
// render and nothing unregisters.
|
||||||
|
const baseNav = useMemo(() => withModuleNav(NAV, 'admin'), [])
|
||||||
|
const title =
|
||||||
|
TITLES[location.pathname] || moduleTitle(baseNav, location.pathname) || sectionTitle(location.pathname)
|
||||||
// The hero canvas editor needs room — let it use the full content width.
|
// The hero canvas editor needs room — let it use the full content width.
|
||||||
const wide = location.pathname === '/admin/hero'
|
const wide = location.pathname === '/admin/hero'
|
||||||
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
||||||
@@ -200,11 +207,17 @@ export default function AdminLayout() {
|
|||||||
// NAV itself and this is exactly the code that ran before the feature.
|
// NAV itself and this is exactly the code that ran before the feature.
|
||||||
const navGroups = useMemo(
|
const navGroups = useMemo(
|
||||||
() =>
|
() =>
|
||||||
applyNavOverrides(NAV, keepEditorReachable(navOverrides.nav_admin))
|
applyNavOverrides(baseNav, keepEditorReachable(navOverrides.nav_admin))
|
||||||
.map((g) => ({ ...g, items: g.items.filter((item) => navItemVisibleTo(item, user?.role)) }))
|
.map((g) => ({
|
||||||
|
...g,
|
||||||
|
// `isVisible` is a no-op for every core row — none carries a `feature`
|
||||||
|
// — and is applied here so that a module row which does carry one is
|
||||||
|
// gated on the sidebar rather than silently advertised.
|
||||||
|
items: g.items.filter((item) => navItemVisibleTo(item, user?.role) && isVisible(item)),
|
||||||
|
}))
|
||||||
// Drop any now-empty group so an empty category header never renders.
|
// Drop any now-empty group so an empty category header never renders.
|
||||||
.filter((g) => g.items.length > 0),
|
.filter((g) => g.items.length > 0),
|
||||||
[navOverrides.nav_admin, user?.role],
|
[baseNav, navOverrides.nav_admin, user?.role, isVisible],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Accordion: track which titled categories are collapsed. Persist across
|
// Accordion: track which titled categories are collapsed. Persist across
|
||||||
@@ -231,17 +244,22 @@ export default function AdminLayout() {
|
|||||||
g.title && g.items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
|
g.title && g.items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
|
||||||
)?.title
|
)?.title
|
||||||
|
|
||||||
|
// Where a moderator may go, from the same `roles` that decide what they see.
|
||||||
|
// It used to be a third hardcoded list — a prefix check over three paths —
|
||||||
|
// which disagreed with the sidebar's own five-path allowlist: `/admin/houses`
|
||||||
|
// was on the sidebar and not in the redirect, so a moderator who clicked
|
||||||
|
// Houses in their own nav was bounced straight back to Moderation. One
|
||||||
|
// derivation cannot disagree with itself, which is the point of deriving it.
|
||||||
|
const allowed = useMemo(() => allowedPathsFor(baseNav, user?.role), [baseNav, user?.role])
|
||||||
|
|
||||||
// Confine a moderator who deep-links (or is redirected to the index) to a page
|
// Confine a moderator who deep-links (or is redirected to the index) to a page
|
||||||
// outside their remit — the API would 403 anyway, so send them to their home.
|
// outside their remit — the API would 403 anyway, so send them to their home.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isModerator) return
|
if (!isModerator) return
|
||||||
const p = location.pathname
|
if (!isAllowedPath(location.pathname, allowed)) {
|
||||||
const allowed =
|
|
||||||
p.startsWith('/admin/moderation') || p.startsWith('/admin/shard-ops') || p === '/admin/account'
|
|
||||||
if (!allowed) {
|
|
||||||
navigate('/admin/moderation', { replace: true })
|
navigate('/admin/moderation', { replace: true })
|
||||||
}
|
}
|
||||||
}, [isModerator, location.pathname, navigate])
|
}, [isModerator, location.pathname, navigate, allowed])
|
||||||
|
|
||||||
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
|||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||||
import { useShardFeatures, canSee } from '../../../lib/useShardFeatures.js'
|
import { withModuleNav } from '../../../modules/nav.js'
|
||||||
|
import { useFeatureGate } from '../../../modules/features.jsx'
|
||||||
import { buildNavRows, buildNavOverrides, buildPublicNav, buildPublicNavOverrides } from '../../../lib/navOverrides.js'
|
import { buildNavRows, buildNavOverrides, buildPublicNav, buildPublicNavOverrides } from '../../../lib/navOverrides.js'
|
||||||
import PublicNavTree from './PublicNavTree.jsx'
|
import PublicNavTree from './PublicNavTree.jsx'
|
||||||
import { parseJsonSetting } from '../../../lib/settingsJson.js'
|
import { parseJsonSetting } from '../../../lib/settingsJson.js'
|
||||||
@@ -34,7 +35,8 @@ import { NAV as PLAYER_NAV } from '../../player/PlayerPortalLayout.jsx'
|
|||||||
// Three things shape the screen:
|
// Three things shape the screen:
|
||||||
//
|
//
|
||||||
// • The palette is filtered to the editing admin's OWN visible rows (§8.1) —
|
// • 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
|
// admin cannot drag in, and so can never accidentally advertise, something
|
||||||
// they cannot see themselves. An override on a row they cannot see is
|
// they cannot see themselves. An override on a row they cannot see is
|
||||||
// carried through their save untouched rather than quietly reset.
|
// carried through their save untouched rather than quietly reset.
|
||||||
@@ -244,7 +246,7 @@ export function Row({ row, id, destinations, destination, onDestination, onChang
|
|||||||
export default function NavEditor() {
|
export default function NavEditor() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const { refresh: refreshSite } = useSite()
|
const { refresh: refreshSite } = useSite()
|
||||||
const shardFeatures = useShardFeatures()
|
const isVisible = useFeatureGate()
|
||||||
const [tab, setTab] = useState('nav_public')
|
const [tab, setTab] = useState('nav_public')
|
||||||
// Per nav: the editable groups, the overrides as loaded (so a row this admin
|
// Per nav: the editable groups, the overrides as loaded (so a row this admin
|
||||||
// cannot see survives their save), and whether a settings row exists at all.
|
// cannot see survives their save), and whether a settings row exists at all.
|
||||||
@@ -255,25 +257,39 @@ export default function NavEditor() {
|
|||||||
const [saved, setSaved] = useState('')
|
const [saved, setSaved] = useState('')
|
||||||
const [dirty, setDirty] = useState({})
|
const [dirty, setDirty] = useState({})
|
||||||
|
|
||||||
// The palette: each base nav, filtered to what THIS admin can see (§8.1). The
|
|
||||||
// public nav's gates are the shard-feature ones; the admin nav's are roles.
|
|
||||||
// The player portal has no gates at all.
|
|
||||||
// The nav as coded, unfiltered. The palette below is what this admin may EDIT;
|
// The nav as coded, unfiltered. The palette below is what this admin may EDIT;
|
||||||
// this is what still EXISTS, and the two are different questions. Saving needs
|
// this is what still EXISTS, and the two are different questions. Saving needs
|
||||||
// both: an entry for a row their palette filtered out must be carried through
|
// both: an entry for a row their palette filtered out must be carried through
|
||||||
// rather than reset, and only an entry for a route the code no longer declares
|
// rather than reset, and only an entry for a route the code no longer declares
|
||||||
// at all should be dropped.
|
// at all should be dropped.
|
||||||
const fullNavs = { nav_public: PUBLIC_NAV, nav_admin: ADMIN_NAV, nav_player: PLAYER_NAV }
|
//
|
||||||
|
// Each nav is the coded array with every installed module's rows already
|
||||||
|
// interleaved (modules/nav.js) — the same array the layout renders, which is
|
||||||
|
// what makes a module row editable here at all: the override merge is keyed by
|
||||||
|
// `to` and drops a key the base it is handed does not declare, so a nav built
|
||||||
|
// from core alone would silently discard every stored override on a module row
|
||||||
|
// the moment it was saved.
|
||||||
|
const fullNavs = useMemo(
|
||||||
|
() => ({
|
||||||
|
nav_public: withModuleNav(PUBLIC_NAV, 'public'),
|
||||||
|
nav_admin: withModuleNav(ADMIN_NAV, 'admin'),
|
||||||
|
nav_player: withModuleNav(PLAYER_NAV, 'player'),
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
// The palette: each base nav, filtered to what THIS admin can see (§8.1). Two
|
||||||
|
// gates, and neither is core's own opinion any more — `roles` on a row, and
|
||||||
|
// the owning module's answer for a row that names a `feature`.
|
||||||
const palettes = useMemo(
|
const palettes = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
nav_public: PUBLIC_NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature)),
|
nav_public: fullNavs.nav_public.filter(isVisible),
|
||||||
nav_admin: ADMIN_NAV.map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role)) })).filter(
|
nav_admin: fullNavs.nav_admin
|
||||||
(g) => g.items.length > 0,
|
.map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role) && isVisible(i)) }))
|
||||||
),
|
.filter((g) => g.items.length > 0),
|
||||||
nav_player: PLAYER_NAV,
|
nav_player: fullNavs.nav_player.filter(isVisible),
|
||||||
}),
|
}),
|
||||||
[shardFeatures, user?.role],
|
[fullNavs, isVisible, user?.role],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -443,8 +459,8 @@ export default function NavEditor() {
|
|||||||
<section style={{ maxWidth: 860, display: 'flex', flexDirection: 'column', gap: 22 }}>
|
<section style={{ maxWidth: 860, display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.7 }}>
|
<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
|
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
|
only decides what is advertised, and it can never show anyone a link their role, or the visibility
|
||||||
visibility settings would hide.
|
settings of an installed module, would hide.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* ── Tabs ───────────────────────────────────────────────── */}
|
{/* ── Tabs ───────────────────────────────────────────────── */}
|
||||||
@@ -537,8 +553,8 @@ export default function NavEditor() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
|
<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
|
Only entries you can see yourself are listed. Anything hidden from you by your role, or by a
|
||||||
Visibility keeps whatever it was already set to.
|
module’s visibility settings, keeps whatever it was already set to.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -177,14 +177,15 @@ const delStyle = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Announcement status panel ────────────────────────────────────────────────
|
// ── Announcement status panel ────────────────────────────────────────────────
|
||||||
// Shows the town-crier + Discord delivery state for a published news post and
|
// Shows each delivery leg's state for a published news post and offers a per-leg
|
||||||
// offers a per-leg retry (useful after fixing the sidecar / news channel without
|
// retry (useful after fixing the sidecar / news channel without re-publishing).
|
||||||
// re-publishing). Only rendered for news posts in edit mode; renders nothing
|
// Only rendered for news posts in edit mode; renders nothing until the post has
|
||||||
// until the post has actually been announced (no job row yet → nothing to show).
|
// actually been announced (no job row yet → nothing to show).
|
||||||
const LEG_META = {
|
//
|
||||||
towncrier: { label: 'In-game town crier' },
|
// The legs and their labels come from the JOB, not from a constant here: which
|
||||||
discord: { label: 'Discord #news' },
|
// legs exist is decided by what the server has registered, so an installed module
|
||||||
}
|
// brings its own leg and this panel renders it with no client change
|
||||||
|
// (docs/website/MODULE_SYSTEM.md §1.8).
|
||||||
const STATUS_STYLE = {
|
const STATUS_STYLE = {
|
||||||
done: { color: '#7bbf8f', label: 'delivered' },
|
done: { color: '#7bbf8f', label: 'delivered' },
|
||||||
pending: { color: '#d9b84a', label: 'pending' },
|
pending: { color: '#d9b84a', label: 'pending' },
|
||||||
@@ -227,14 +228,12 @@ function AnnouncePanel({ postId }) {
|
|||||||
return (
|
return (
|
||||||
<div style={panelStyle}>
|
<div style={panelStyle}>
|
||||||
<span className="field-label" style={{ marginBottom: 2 }}>Announcement</span>
|
<span className="field-label" style={{ marginBottom: 2 }}>Announcement</span>
|
||||||
{['towncrier', 'discord'].map((leg) => {
|
{(job.legs || []).map(({ leg, label, status, last_error: err }) => {
|
||||||
const status = job[`${leg}_status`]
|
|
||||||
const err = job[`${leg}_last_error`]
|
|
||||||
const s = STATUS_STYLE[status] || STATUS_STYLE.pending
|
const s = STATUS_STYLE[status] || STATUS_STYLE.pending
|
||||||
return (
|
return (
|
||||||
<div key={leg} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<div key={leg} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{LEG_META[leg].label}</span>
|
<span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{label}</span>
|
||||||
<span className="sans" style={{ fontSize: '0.8rem', color: s.color, fontWeight: 600 }}>● {s.label}</span>
|
<span className="sans" style={{ fontSize: '0.8rem', color: s.color, fontWeight: 600 }}>● {s.label}</span>
|
||||||
{status !== 'done' && (
|
{status !== 'done' && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -35,18 +35,6 @@ const FIELDS = [
|
|||||||
],
|
],
|
||||||
fallback: 'disabled',
|
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() {
|
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
import { useAsync } from '../../../lib/useAsync.js'
|
import { useAsync } from '../../../lib/useAsync.js'
|
||||||
import { dateTime, ago } from '../../../lib/format.js'
|
import { dateTime } from '../../../lib/format.js'
|
||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
import CharacterStats from '../../../components/CharacterStats.jsx'
|
import Slot from '../../../modules/Slot.jsx'
|
||||||
import GameAccounts from '../../../components/GameAccounts.jsx'
|
|
||||||
import VendorSales from '../../../components/VendorSales.jsx'
|
|
||||||
|
|
||||||
// Admin read-only view of one user's shard (uo-link) footprint: linked game
|
// Admin view of one user: who they are, their security posture (trusted devices
|
||||||
// accounts + character rosters, currently-online characters, houses (incl.
|
// and MFA), and then whatever the installed module contributes about them —
|
||||||
// IDOC) and recent vendor sales — everything scoped to that user's accounts.
|
// today core's own UO footprint, via the `admin.users.detail` extension slot
|
||||||
// Reached from the Users table's "View" action; Edit stays a separate modal.
|
// (MODULE_API.md §3.7). Reached from the Users table's "View" action; Edit stays
|
||||||
|
// a separate modal.
|
||||||
|
|
||||||
const ROLE_BADGE = {
|
const ROLE_BADGE = {
|
||||||
admin: 'badge-admin',
|
admin: 'badge-admin',
|
||||||
@@ -28,114 +27,6 @@ function SectionTitle({ children }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Currently-online characters on the user's accounts, with where they are. The
|
|
||||||
// per-character Online/Offline badge lives in the roster; this adds location.
|
|
||||||
function OnlineNow({ scope }) {
|
|
||||||
const { data } = useAsync(() => scope.online(), [scope])
|
|
||||||
if (!data) return null
|
|
||||||
return (
|
|
||||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
|
||||||
<SectionTitle>Online now</SectionTitle>
|
|
||||||
{data.length === 0 ? (
|
|
||||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
|
|
||||||
) : (
|
|
||||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{data.map((c) => (
|
|
||||||
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
|
||||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
|
|
||||||
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
|
|
||||||
</span>
|
|
||||||
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
|
|
||||||
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shard "standing": city governorships held and guilds led by this user's
|
|
||||||
// accounts (both reliable current-state lookups). Renders nothing when empty.
|
|
||||||
function Standing({ scope }) {
|
|
||||||
const { data } = useAsync(() => scope.standing(), [scope])
|
|
||||||
if (!data) return null
|
|
||||||
const govs = data.governorOf || []
|
|
||||||
const guilds = data.guildsLed || []
|
|
||||||
if (govs.length === 0 && guilds.length === 0) return null
|
|
||||||
return (
|
|
||||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
|
||||||
<SectionTitle>Standing</SectionTitle>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
|
||||||
{govs.map((g) => (
|
|
||||||
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
|
|
||||||
Governor of {g.city}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{guilds.map((g) => (
|
|
||||||
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
|
|
||||||
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// One house row — the many optional detail fields are gathered here so the
|
|
||||||
// Houses list stays a simple map.
|
|
||||||
function HouseRow({ house: h }) {
|
|
||||||
const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown')
|
|
||||||
const coords = h.x != null ? ` · ${h.x}, ${h.y}` : ''
|
|
||||||
const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : ''
|
|
||||||
const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
|
|
||||||
>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
|
|
||||||
{h.name || 'Unnamed house'}
|
|
||||||
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
|
|
||||||
</div>
|
|
||||||
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
|
|
||||||
{location}
|
|
||||||
{coords}
|
|
||||||
{owner}
|
|
||||||
{shares}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
|
|
||||||
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
|
|
||||||
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
|
|
||||||
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Houses owned by the user's accounts, IDOC first (flagged).
|
|
||||||
function Houses({ scope }) {
|
|
||||||
const { data } = useAsync(() => scope.houses(), [scope])
|
|
||||||
if (!data) return null
|
|
||||||
return (
|
|
||||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
|
||||||
<SectionTitle>Houses</SectionTitle>
|
|
||||||
{data.length === 0 ? (
|
|
||||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this user’s accounts.</p>
|
|
||||||
) : (
|
|
||||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
||||||
{data.map((h) => (
|
|
||||||
<HouseRow key={h.serial} house={h} />
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin security controls for one user: their trusted devices (view + revoke) and
|
// Admin security controls for one user: their trusted devices (view + revoke) and
|
||||||
// an MFA reset for a locked-out user. Every action is audit-logged server-side.
|
// an MFA reset for a locked-out user. Every action is audit-logged server-side.
|
||||||
function SecurityAdmin({ userId }) {
|
function SecurityAdmin({ userId }) {
|
||||||
@@ -244,25 +135,8 @@ function SecurityAdmin({ userId }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShardSections({ scope }) {
|
|
||||||
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} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UserDetail() {
|
export default function UserDetail() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
// Memoize so the child components' effects (keyed on `scope`) don't refetch
|
|
||||||
// on every render.
|
|
||||||
const scope = useMemo(() => api.admin.userShard(id), [id])
|
|
||||||
const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id])
|
const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id])
|
||||||
|
|
||||||
if (loading) return <Loading />
|
if (loading) return <Loading />
|
||||||
@@ -296,7 +170,11 @@ export default function UserDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SecurityAdmin userId={id} />
|
<SecurityAdmin userId={id} />
|
||||||
<ShardSections scope={scope} />
|
{/* Whatever the installed module has to say about this user, or nothing
|
||||||
|
at all — core filled this with its own UO sections until Phase 3 slice
|
||||||
|
3, and now nothing does unless a module is installed
|
||||||
|
(MODULE_API.md §3.7). */}
|
||||||
|
<Slot name="admin.users.detail" userId={id} />
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
import { api } from '../../api/client.js'
|
import { api } from '../../api/client.js'
|
||||||
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
|
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,
|
// Public, token-gated invite acceptance (/invite/:token). Validates the invite,
|
||||||
// lets the invitee set a username + password (their email + role are pre-assigned),
|
// 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
|
// creates the account at that role and logs them in.
|
||||||
// offers the built-in "create game account" step before sending them to the portal.
|
//
|
||||||
|
// 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() {
|
export default function AcceptInvite() {
|
||||||
const { token } = useParams()
|
const { token } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -16,7 +24,6 @@ export default function AcceptInvite() {
|
|||||||
|
|
||||||
const [invite, setInvite] = useState(null) // fields email and role
|
const [invite, setInvite] = useState(null) // fields email and role
|
||||||
const [loadErr, setLoadErr] = useState('')
|
const [loadErr, setLoadErr] = useState('')
|
||||||
const [signupOk, setSignupOk] = useState(false)
|
|
||||||
|
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
@@ -30,14 +37,20 @@ export default function AcceptInvite() {
|
|||||||
api.getInvite(token)
|
api.getInvite(token)
|
||||||
.then((iv) => active && setInvite(iv))
|
.then((iv) => active && setInvite(iv))
|
||||||
.catch((err) => active && setLoadErr(err.status === 404 ? 'This invitation is invalid or has expired.' : 'Could not load this invitation.'))
|
.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 }
|
return () => { active = false }
|
||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
const dest = invite && invite.role === 'player' ? '/player' : '/admin'
|
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) {
|
async function onSubmit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@@ -48,8 +61,9 @@ export default function AcceptInvite() {
|
|||||||
await api.acceptInvite(token, username.trim(), password, { company })
|
await api.acceptInvite(token, username.trim(), password, { company })
|
||||||
await refresh() // pull the freshly-issued session into context
|
await refresh() // pull the freshly-issued session into context
|
||||||
setAccepted(true)
|
setAccepted(true)
|
||||||
// Staff invites are web-only — no game step; go straight in.
|
// Staff invites go straight in, and so does a player invite when nothing
|
||||||
if (!(invite.role === 'player' && signupOk)) navigate(dest, { replace: true })
|
// is installed that has a step to offer.
|
||||||
|
if (!(invite.role === 'player' && hasNextStep)) navigate(dest, { replace: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 409) setError('That username is already taken, or the invite was already used.')
|
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.')
|
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) {
|
if (accepted) {
|
||||||
return (
|
return (
|
||||||
<PlayerShell subtitle="Set up your game account">
|
<PlayerShell subtitle="One more step">
|
||||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
<Slot name="player.invite.accepted" onDone={finish} />
|
||||||
Your account is ready. Create a game account now to play, or skip and do it later from your portal.
|
|
||||||
</p>
|
|
||||||
<CreateGameAccountForm
|
|
||||||
submit={api.player.shard.createAccount}
|
|
||||||
onCreated={() => navigate('/player', { replace: true })}
|
|
||||||
/>
|
|
||||||
<p className="sans" style={{ textAlign: 'center', margin: '18px 0 0' }}>
|
<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 →
|
Skip for now →
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</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,11 +1,14 @@
|
|||||||
import { useMemo } from 'react'
|
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 MoonDot from '../../components/MoonDot.jsx'
|
||||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
import { applyNavOverrides } from '../../lib/navOverrides.js'
|
import { applyNavOverrides } from '../../lib/navOverrides.js'
|
||||||
|
import { firstDestinationFor } from '../../lib/adminNav.js'
|
||||||
import { useNavOverrides } from '../../lib/useNavOverrides.js'
|
import { useNavOverrides } from '../../lib/useNavOverrides.js'
|
||||||
|
import { withModuleNav } from '../../modules/nav.js'
|
||||||
|
import { useFeatureGate } from '../../modules/features.jsx'
|
||||||
|
|
||||||
// Shared shell for the logged-in player portal. Uses the same sidebar shell as
|
// Shared shell for the logged-in player portal. Uses the same sidebar shell as
|
||||||
// Admin (icon nav, sticky content header, footer sign-out) so the two logged-in
|
// Admin (icon nav, sticky content header, footer sign-out) so the two logged-in
|
||||||
@@ -30,28 +33,37 @@ function Icon({ children, size = 16 }) {
|
|||||||
</svg>
|
</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 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>
|
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;
|
// Exported because Admin -> Navigation edits this list. It stays declared here;
|
||||||
// the editor may only relabel, reorder and hide what it finds (§7). No row
|
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
|
||||||
// carries a gate — every player sees all three — so the merged result is what
|
// carries a gate — every player sees both — but an installed module's rows join
|
||||||
// renders, with no filter after it.
|
// 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 = [
|
export const NAV = [
|
||||||
{ to: '/player', label: 'Characters', end: true, icon: IconUser },
|
|
||||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||||
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
||||||
]
|
]
|
||||||
|
|
||||||
// The sticky content header mirrors the active page. Character sheets live under
|
// The sticky content header mirrors the active page. A module's pages are not
|
||||||
// /player/char/:serial and keep their own in-page back link.
|
// 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 = {
|
const TITLES = {
|
||||||
'/player': 'Characters',
|
|
||||||
'/account': 'Account',
|
'/account': 'Account',
|
||||||
'/account/appeals': 'Appeals',
|
'/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 = {
|
const navBtnBase = {
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
@@ -69,12 +81,15 @@ export default function PlayerPortalLayout() {
|
|||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const { siteTitle } = useSite()
|
const { siteTitle } = useSite()
|
||||||
const navOverrides = useNavOverrides()
|
const navOverrides = useNavOverrides()
|
||||||
const nav = useMemo(() => applyNavOverrides(NAV, navOverrides.nav_player), [navOverrides.nav_player])
|
const isVisible = useFeatureGate()
|
||||||
|
const baseNav = useMemo(() => withModuleNav(NAV, 'player'), [])
|
||||||
|
const nav = useMemo(
|
||||||
|
() => applyNavOverrides(baseNav, navOverrides.nav_player).filter(isVisible),
|
||||||
|
[baseNav, navOverrides.nav_player, isVisible],
|
||||||
|
)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const title =
|
const title = TITLES[location.pathname] || moduleTitle(baseNav, location.pathname) || 'Player Portal'
|
||||||
TITLES[location.pathname] ||
|
|
||||||
(location.pathname.startsWith('/player/char/') ? 'Character' : 'Player Portal')
|
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
await logout()
|
await logout()
|
||||||
@@ -122,7 +137,13 @@ export default function PlayerPortalLayout() {
|
|||||||
borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
|
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>
|
<span>{n.label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
@@ -174,3 +195,28 @@ export default function PlayerPortalLayout() {
|
|||||||
</div>
|
</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}`} />
|
<PageHeader eyebrow="About" title={`About ${siteShortName}`} />
|
||||||
<div className="prose">
|
<div className="prose">
|
||||||
<p>
|
<p>
|
||||||
{siteShortName} is an independent, privately-run Ultima Online shard built by a small group of long-time players.
|
{siteShortName} is an independent, privately-run game server built by a small group of long-time
|
||||||
It is not affiliated with or endorsed by the owners of Ultima Online — it is a labor of love for the old
|
players. It is not affiliated with or endorsed by the owners of the game it runs — it is a labor of
|
||||||
worlds and the friendships made in them.
|
love for the old worlds and the friendships made in them.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Our aim is a calm, hand-tended world: a contested wilderness worth exploring, safe towns worth living in,
|
Our aim is a calm, hand-tended world: somewhere worth exploring, somewhere worth living in, and
|
||||||
and systems that reward curiosity over grind. We are building slowly and in the open, sharing news,
|
systems that reward curiosity over grind. We are building slowly and in the open, sharing news,
|
||||||
screenshots, and guides as the world comes online.
|
screenshots, and guides as the world comes online.
|
||||||
</p>
|
</p>
|
||||||
<h2>What to expect</h2>
|
<h2>What to expect</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>A hybrid ruleset — safe towns, a dangerous wild.</li>
|
<li>A world that is hand-tended rather than left to run itself.</li>
|
||||||
<li>Custom crafting, housing, and exploration content.</li>
|
<li>Custom content, and changes explained before they land.</li>
|
||||||
<li>A small, friendly population and an active wiki.</li>
|
<li>A small, friendly population and an active wiki.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
<PageHeader
|
||||||
eyebrow="Gallery"
|
eyebrow="Gallery"
|
||||||
title="Gameplay Pictures"
|
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 />}
|
{loading && <Loading />}
|
||||||
{error && <ErrorState message="Could not load the gallery right now." />}
|
{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 (
|
return (
|
||||||
<PublicLayout section="website">
|
<PublicLayout section="website">
|
||||||
<div className="shell-narrow page-body">
|
<div className="shell-narrow page-body">
|
||||||
<PageHeader eyebrow="Live" title="Shard Status" />
|
<PageHeader eyebrow="Live" title="Site Status" />
|
||||||
|
|
||||||
{loading && <Loading />}
|
{loading && <Loading />}
|
||||||
{error && <ErrorState message="Could not load status right now." />}
|
{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'}
|
{isLive ? 'Live — the gates are open' : 'Maintenance — building in progress'}
|
||||||
</strong>
|
</strong>
|
||||||
<span style={{ color: isLive ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import PageHeader from '../../components/PageHeader.jsx'
|
|||||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
const CARDS = [
|
const CARDS = [
|
||||||
{ kicker: 'Gallery', title: 'Gameplay Pictures', body: 'Screenshots from towns, dungeons, events, and daily life on the shard.', to: '/site/screenshots' },
|
{ 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, shard milestones, and public announcements.', to: '/site/news' },
|
{ 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: '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: '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: '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() {
|
export default function Website() {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export default function Wiki() {
|
|||||||
center
|
center
|
||||||
eyebrow="Knowledge base"
|
eyebrow="Knowledge base"
|
||||||
title={`${siteShortName} Wiki`}
|
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} />
|
<SearchBox initial={activeQ || ''} onSubmit={runSearch} />
|
||||||
|
|
||||||
|
|||||||
172
client/test/adminNav.test.js
Normal file
172
client/test/adminNav.test.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { navItemVisibleTo, allowedPathsFor, isAllowedPath, firstDestinationFor } from '../src/lib/adminNav.js'
|
||||||
|
|
||||||
|
// Moderator confinement, derived from each row's `roles` (Phase 2 PR 8 —
|
||||||
|
// MODULE_SYSTEM.md §1.4). This replaced two hardcoded path lists that had
|
||||||
|
// drifted apart from each other, so the tests worth having are the ones that
|
||||||
|
// pin what a moderator may now see and reach, and the shape of the match.
|
||||||
|
|
||||||
|
// The real sidebar, trimmed to the rows that decide something here.
|
||||||
|
const NAV = [
|
||||||
|
{ items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'editor', 'moderator'] }] },
|
||||||
|
{
|
||||||
|
title: 'Moderation',
|
||||||
|
items: [
|
||||||
|
{ to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] },
|
||||||
|
{ to: '/admin/moderation/appeals', label: 'Appeals', roles: ['admin', 'moderator'] },
|
||||||
|
{ to: '/admin/shard-ops', label: 'In-Game Ops', roles: ['admin', 'moderator'] },
|
||||||
|
{ to: '/admin/houses', label: 'Houses', roles: ['admin', 'moderator'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'System',
|
||||||
|
items: [
|
||||||
|
{ to: '/admin/users', label: 'Users', roles: ['admin'] },
|
||||||
|
{ to: '/admin/settings', label: 'Settings', roles: ['admin'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{ to: '/admin/characters', label: 'My Characters' },
|
||||||
|
{ to: '/admin/account', label: 'Account' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const visibleTo = (role) =>
|
||||||
|
NAV.flatMap((g) => g.items)
|
||||||
|
.filter((i) => navItemVisibleTo(i, role))
|
||||||
|
.map((i) => i.to)
|
||||||
|
|
||||||
|
test('a row with no roles is visible to every staff role', () => {
|
||||||
|
// Self-service: staff are a superset of players, so a moderator reaching their
|
||||||
|
// own characters is not a privilege, it is the thing every account has.
|
||||||
|
for (const role of ['admin', 'editor', 'moderator']) {
|
||||||
|
assert.equal(navItemVisibleTo({ to: '/admin/account' }, role), true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a role not named on the row cannot see it', () => {
|
||||||
|
assert.equal(navItemVisibleTo({ to: '/admin/users', roles: ['admin'] }, 'moderator'), false)
|
||||||
|
assert.equal(navItemVisibleTo({ to: '/admin/users', roles: ['admin'] }, 'admin'), true)
|
||||||
|
// An unknown or absent role sees only the ungated rows.
|
||||||
|
assert.equal(navItemVisibleTo({ to: '/admin/users', roles: ['admin'] }, undefined), false)
|
||||||
|
assert.equal(navItemVisibleTo({ to: '/admin/account' }, undefined), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('what a moderator sees is exactly the moderation section, plus self-service', () => {
|
||||||
|
// The two additions the derivation makes over the old MOD_PATHS list are
|
||||||
|
// Dashboard — whose roles have always named moderator, so the two lists
|
||||||
|
// disagreed — and My Characters. Both are already permitted server-side.
|
||||||
|
assert.deepEqual(visibleTo('moderator'), [
|
||||||
|
'/admin',
|
||||||
|
'/admin/moderation',
|
||||||
|
'/admin/moderation/appeals',
|
||||||
|
'/admin/shard-ops',
|
||||||
|
'/admin/houses',
|
||||||
|
'/admin/characters',
|
||||||
|
'/admin/account',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an admin still sees everything and an editor still sees nothing extra', () => {
|
||||||
|
assert.equal(visibleTo('admin').length, NAV.flatMap((g) => g.items).length)
|
||||||
|
assert.deepEqual(visibleTo('editor'), ['/admin', '/admin/characters', '/admin/account'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a row with `end` matches exactly — the dashboard is not a prefix', () => {
|
||||||
|
// The bug this shape exists to prevent: treating `/admin` as a prefix would
|
||||||
|
// make every path in the admin area allowed for anyone who can see Dashboard.
|
||||||
|
const allowed = allowedPathsFor(NAV, 'moderator')
|
||||||
|
assert.equal(isAllowedPath('/admin', allowed), true)
|
||||||
|
assert.equal(isAllowedPath('/admin/users', allowed), false)
|
||||||
|
assert.equal(isAllowedPath('/admin/users/12', allowed), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every other row covers its own sub-routes', () => {
|
||||||
|
const allowed = allowedPathsFor(NAV, 'moderator')
|
||||||
|
assert.equal(isAllowedPath('/admin/moderation/appeals/12', allowed), true)
|
||||||
|
assert.equal(isAllowedPath('/admin/characters/0x4001', allowed), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a sibling path that merely shares a prefix is NOT covered', () => {
|
||||||
|
const allowed = allowedPathsFor(NAV, 'moderator')
|
||||||
|
// `/admin/houses-secret` starts with `/admin/houses` as a string; the match is
|
||||||
|
// on path segments, so it does not start with `/admin/houses/`.
|
||||||
|
assert.equal(isAllowedPath('/admin/houses-secret', allowed), false)
|
||||||
|
assert.equal(isAllowedPath('/admin/houses/42', allowed), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Houses is reachable, which is the defect the derivation fixed', () => {
|
||||||
|
// The redirect used to allow only /admin/moderation*, /admin/shard-ops* and
|
||||||
|
// /admin/account, while the sidebar showed Houses — so a moderator clicking a
|
||||||
|
// row in their own nav was bounced back to Moderation.
|
||||||
|
const allowed = allowedPathsFor(NAV, 'moderator')
|
||||||
|
assert.equal(isAllowedPath('/admin/houses', allowed), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module row a moderator may see is reachable without core listing it', () => {
|
||||||
|
// The reason this is derived at all: core cannot hardcode a path it has never
|
||||||
|
// heard of, and a module row arrives with `roles` like any other.
|
||||||
|
const withModule = [
|
||||||
|
...NAV,
|
||||||
|
{ title: 'Shard', items: [{ to: '/admin/uo/shard-ops', label: 'Ops', roles: ['admin', 'moderator'], moduleId: 'uo' }] },
|
||||||
|
]
|
||||||
|
const allowed = allowedPathsFor(withModule, 'moderator')
|
||||||
|
assert.equal(isAllowedPath('/admin/uo/shard-ops', allowed), true)
|
||||||
|
assert.equal(isAllowedPath('/admin/uo/shard-ops/queue', allowed), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a nav that is not there does not throw', () => {
|
||||||
|
assert.deepEqual(allowedPathsFor(null, 'moderator'), [])
|
||||||
|
assert.equal(isAllowedPath('/admin', undefined), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── firstDestinationFor: where an area's index goes (Phase 3 slice 3) ────────
|
||||||
|
//
|
||||||
|
// `/player` used to be `PlayerCharacters`, a UO page. When the client half was
|
||||||
|
// extracted the portal had no index at all, and rather than pick a fixed page or
|
||||||
|
// invent a core landing screen, the index resolves to the first row this viewer
|
||||||
|
// can reach. The interesting properties are that it follows the ROLE and that it
|
||||||
|
// reads the base nav, not the merged one.
|
||||||
|
|
||||||
|
const PORTAL_NAV = [
|
||||||
|
{ to: '/account/appeals', label: 'Appeals' },
|
||||||
|
{ to: '/account', label: 'Account', end: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
test('the index is the first row a viewer can actually reach', () => {
|
||||||
|
assert.equal(firstDestinationFor(PORTAL_NAV, 'player', '/account'), '/account/appeals')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module row registered at the front becomes the index', () => {
|
||||||
|
// The behaviour that makes this a non-regression: with module-uo installed,
|
||||||
|
// Characters is the first row again and a player still lands on it.
|
||||||
|
const withModule = [{ to: '/player/uo/characters', label: 'Characters', moduleId: 'uo' }, ...PORTAL_NAV]
|
||||||
|
assert.equal(firstDestinationFor(withModule, 'player', '/account'), '/player/uo/characters')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a row this role cannot see is skipped, not landed on', () => {
|
||||||
|
const gated = [{ to: '/player/uo/staff', label: 'Staff', roles: ['admin'] }, ...PORTAL_NAV]
|
||||||
|
assert.equal(firstDestinationFor(gated, 'player', '/account'), '/account/appeals')
|
||||||
|
assert.equal(firstDestinationFor(gated, 'admin', '/account'), '/player/uo/staff')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an empty or all-gated nav falls back rather than resolving to nothing', () => {
|
||||||
|
assert.equal(firstDestinationFor([], 'player', '/account'), '/account')
|
||||||
|
assert.equal(firstDestinationFor(null, 'player', '/account'), '/account')
|
||||||
|
const allGated = [{ to: '/x', label: 'X', roles: ['admin'] }]
|
||||||
|
assert.equal(firstDestinationFor(allGated, 'player', '/account'), '/account')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('it reads the grouped admin nav too, flattening in order', () => {
|
||||||
|
// Same function for both areas, which is the point: the admin index is a
|
||||||
|
// hardcoded Dashboard today, and if the two logged-in areas ever merge this is
|
||||||
|
// what answers for the result.
|
||||||
|
assert.equal(firstDestinationFor(NAV, 'moderator', '/admin/account'), '/admin')
|
||||||
|
// An editor cannot see Dashboard's neighbours in Moderation, so they land on
|
||||||
|
// the first row they can see wherever it is.
|
||||||
|
assert.equal(firstDestinationFor(NAV, 'editor', '/admin/account'), '/admin')
|
||||||
|
})
|
||||||
@@ -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')
|
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: {} })
|
willReply({ body: {} })
|
||||||
await api.shard.governorHistory('Serpent’s Hold', 5)
|
await api.getInvite('a b/c?d')
|
||||||
assert.match(calls[0].url, /\/governors\/Serpent%E2%80%99s%20Hold\/history\?limit=5/)
|
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 () => {
|
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$/)
|
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────
|
// ── admin: installed modules (MODULE_SYSTEM.md §2.7.2) ──────────────────
|
||||||
// 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.
|
// These pin the URLs, because the destructive one differs from the harmless one
|
||||||
// Asserted here because the split is a design decision, not an accident of
|
// by a query parameter and nothing else.
|
||||||
// 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')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('atlas.creatures() sends only the filters that are set', async () => {
|
test('module actions hit the right paths and methods', async () => {
|
||||||
willReply({ body: { creatures: [] } })
|
const cases = [
|
||||||
await api.atlas.creatures({ q: 'lizard man', facet: 'Ter Mur', limit: 25 })
|
[() => api.admin.listModules(), 'GET', '/api/v1/admin/modules'],
|
||||||
const url = new URL(calls[0].url, 'http://x')
|
[() => api.admin.installModule('https://x/y.json'), 'POST', '/api/v1/admin/modules'],
|
||||||
assert.equal(url.pathname, '/api/v1/public/atlas/creatures')
|
[() => api.admin.enableModule('uo'), 'POST', '/api/v1/admin/modules/uo/enable'],
|
||||||
assert.equal(url.searchParams.get('q'), 'lizard man')
|
[() => api.admin.disableModule('uo'), 'POST', '/api/v1/admin/modules/uo/disable'],
|
||||||
assert.equal(url.searchParams.get('facet'), 'Ter Mur')
|
[() => api.admin.purgeModule('uo'), 'POST', '/api/v1/admin/modules/uo/purge'],
|
||||||
assert.equal(url.searchParams.get('limit'), '25')
|
[() => api.admin.setModuleSources('a.com'), 'PUT', '/api/v1/admin/modules/sources'],
|
||||||
assert.equal(url.searchParams.get('offset'), null) // 0 is not sent
|
[() => api.admin.restartServer(), 'POST', '/api/v1/admin/modules/restart'],
|
||||||
})
|
]
|
||||||
|
for (const [call, method, url] of cases) {
|
||||||
test('atlas.creature() encodes the slug and carries the facet filter through', async () => {
|
calls = []
|
||||||
willReply({ body: {} })
|
willReply({ body: {} })
|
||||||
await api.atlas.creature('lizardman/rare', { facet: 'Felucca' })
|
await call()
|
||||||
assert.match(calls[0].url, /\/public\/atlas\/creatures\/lizardman%2Frare\?facet=Felucca$/)
|
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: {} })
|
willReply({ body: {} })
|
||||||
await api.admin.atlas.import(true)
|
await api.admin.uninstallModule('uo')
|
||||||
assert.equal(calls[0].url, '/api/v1/admin/shard/atlas/import')
|
assert.equal(calls[0].url, '/api/v1/admin/modules/uo')
|
||||||
assert.equal(calls[0].opts.method, 'POST')
|
assert.equal(calls[0].opts.method, 'DELETE')
|
||||||
assert.equal(calls[0].opts.body, JSON.stringify({ force: true }))
|
|
||||||
|
|
||||||
|
calls = []
|
||||||
willReply({ body: {} })
|
willReply({ body: {} })
|
||||||
await api.admin.atlas.setPath('/srv/servuo')
|
await api.admin.uninstallModule('uo', { purge: true })
|
||||||
assert.equal(calls[1].opts.method, 'PUT')
|
assert.equal(calls[0].url, '/api/v1/admin/modules/uo?purge=true')
|
||||||
assert.equal(calls[1].opts.body, JSON.stringify({ path: '/srv/servuo' }))
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
})
|
})
|
||||||
|
|||||||
85
client/test/featureGate.test.js
Normal file
85
client/test/featureGate.test.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { buildFeatureGate, OPEN_GATE } from '../src/modules/featureGate.js'
|
||||||
|
|
||||||
|
// The feature seam's decision logic (MODULE_SYSTEM.md §1.5, MODULE_API.md §3.3).
|
||||||
|
// Every branch here fails OPEN, and that is the property under test as much as
|
||||||
|
// the happy path: this is presentation, the server is the gate, and a UI mistake
|
||||||
|
// that hides a page from someone entitled to it is worse in every case than one
|
||||||
|
// that shows a link which then 403s.
|
||||||
|
|
||||||
|
const flags = (...names) => new Set(names)
|
||||||
|
|
||||||
|
test('a row with no feature is always visible', () => {
|
||||||
|
const gate = buildFeatureGate(new Map([['uo', flags()]]))
|
||||||
|
assert.equal(gate({ to: '/site/news' }), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a core row resolves against the owner id `core`', () => {
|
||||||
|
// Core's ten shard-gated rows carry no moduleId, and core registers its
|
||||||
|
// provider under `core` (main.jsx) precisely so they resolve without one.
|
||||||
|
const gate = buildFeatureGate(new Map([['core', flags('atlas')]]))
|
||||||
|
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
|
||||||
|
assert.equal(gate({ to: '/site/market', feature: 'market' }), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module row resolves against ITS module, not another one', () => {
|
||||||
|
const gate = buildFeatureGate(
|
||||||
|
new Map([
|
||||||
|
['uo', flags('atlas')],
|
||||||
|
['rust', flags('market')],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
assert.equal(gate({ to: '/uo/atlas', feature: 'atlas', moduleId: 'uo' }), true)
|
||||||
|
// `market` is a flag the OTHER module grants. Resolution is by registration,
|
||||||
|
// so there is no string a module can write to borrow it.
|
||||||
|
assert.equal(gate({ to: '/uo/market', feature: 'market', moduleId: 'uo' }), false)
|
||||||
|
assert.equal(gate({ to: '/rust/market', feature: 'market', moduleId: 'rust' }), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('no provider for the owner shows the row', () => {
|
||||||
|
// The no-module-installed case, and the reason the filter is a correct no-op
|
||||||
|
// on a bare core rather than a nav that renders nothing.
|
||||||
|
const gate = buildFeatureGate(new Map())
|
||||||
|
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
|
||||||
|
assert.equal(gate({ to: '/uo/atlas', feature: 'atlas', moduleId: 'uo' }), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a provider still loading shows the row', () => {
|
||||||
|
// useShardFlags returns null until its fetch lands. Blanking the nav on every
|
||||||
|
// page load and filling it in a moment later is the behaviour this avoids.
|
||||||
|
const gate = buildFeatureGate(new Map([['core', null]]))
|
||||||
|
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a provider that returned something unusable shows the row', () => {
|
||||||
|
for (const bad of [undefined, 42, 'atlas', {}, []]) {
|
||||||
|
const gate = buildFeatureGate(new Map([['core', bad]]))
|
||||||
|
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true, `failed closed on ${JSON.stringify(bad)}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an array-backed provider is not silently treated as a Set', () => {
|
||||||
|
// `[].has` does not exist, so this is the unusable case above rather than a
|
||||||
|
// membership test that quietly always fails. Asserted so that a future
|
||||||
|
// "helpful" normalisation knows it changed a documented behaviour.
|
||||||
|
const gate = buildFeatureGate(new Map([['core', ['atlas']]]))
|
||||||
|
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a missing map, or a junk row, shows rather than throws', () => {
|
||||||
|
assert.equal(buildFeatureGate(null)({ feature: 'atlas' }), true)
|
||||||
|
assert.equal(buildFeatureGate(new Map())(null), true)
|
||||||
|
assert.equal(buildFeatureGate(new Map())(undefined), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('any Set-like satisfies a provider — core does not require a Set', () => {
|
||||||
|
const gate = buildFeatureGate(new Map([['uo', { has: (name) => name === 'ruleset' }]]))
|
||||||
|
assert.equal(gate({ feature: 'ruleset', moduleId: 'uo' }), true)
|
||||||
|
assert.equal(gate({ feature: 'champs', moduleId: 'uo' }), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the open gate is what a component outside the provider gets', () => {
|
||||||
|
assert.equal(OPEN_GATE({ feature: 'anything' }), true)
|
||||||
|
})
|
||||||
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/)
|
||||||
|
})
|
||||||
217
client/test/moduleNav.test.js
Normal file
217
client/test/moduleNav.test.js
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { test, beforeEach } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { withModuleNav } from '../src/modules/nav.js'
|
||||||
|
import { registerNav, _reset } from '../src/modules/registry.js'
|
||||||
|
import { applyNavOverrides, buildPublicNav } from '../src/lib/navOverrides.js'
|
||||||
|
|
||||||
|
// The interleave of module nav rows into core's nav (MODULE_API.md §3.3, Phase 2
|
||||||
|
// PR 8). Tested against the real merge next door rather than in isolation,
|
||||||
|
// because the property that matters is a relationship between the two: a module
|
||||||
|
// row has to be indistinguishable from a core row to everything downstream, and
|
||||||
|
// the way to prove that is to run the downstream thing on it.
|
||||||
|
|
||||||
|
const PUBLIC = [
|
||||||
|
{ label: 'Home', to: '/', end: true },
|
||||||
|
{ label: 'News', to: '/site/news' },
|
||||||
|
{ label: 'About', to: '/site/about' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ADMIN = [
|
||||||
|
{ items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'moderator'] }] },
|
||||||
|
{ title: 'Moderation', items: [{ to: '/admin/moderation', label: 'Moderation' }] },
|
||||||
|
{ title: 'System', items: [{ to: '/admin/users', label: 'Users' }, { to: '/admin/settings', label: 'Settings' }] },
|
||||||
|
{ items: [{ to: '/admin/account', label: 'Account' }] },
|
||||||
|
]
|
||||||
|
|
||||||
|
beforeEach(() => _reset())
|
||||||
|
|
||||||
|
test('with no module installed the base array is returned unchanged', () => {
|
||||||
|
// Identity, not a copy: this is what makes the useMemo in each layout honest,
|
||||||
|
// and what guarantees an instance with no modules renders what it renders now.
|
||||||
|
assert.equal(withModuleNav(PUBLIC, 'public'), PUBLIC)
|
||||||
|
assert.equal(withModuleNav(ADMIN, 'admin'), ADMIN)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a flat nav places a module row by the order it asked for', () => {
|
||||||
|
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', order: 1 }] })
|
||||||
|
assert.deepEqual(
|
||||||
|
withModuleNav(PUBLIC, 'public').map((i) => i.label),
|
||||||
|
['Home', 'Atlas', 'News', 'About'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a flat row with no order appends rather than jumping to the front', () => {
|
||||||
|
// The 0-default trap: `order ?? 0` would put an unordered row first, which is
|
||||||
|
// the one place a module could take over the nav without asking for anything.
|
||||||
|
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }] })
|
||||||
|
assert.deepEqual(
|
||||||
|
withModuleNav(PUBLIC, 'public').map((i) => i.label),
|
||||||
|
['Home', 'News', 'About', 'Atlas'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an explicit order beats a core row that merely sits at that index', () => {
|
||||||
|
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', order: 2 }] })
|
||||||
|
const labels = withModuleNav(PUBLIC, 'public').map((i) => i.label)
|
||||||
|
assert.deepEqual(labels, ['Home', 'News', 'Atlas', 'About'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an admin row lands INSIDE the core group it names', () => {
|
||||||
|
registerNav('uo', {
|
||||||
|
area: 'admin',
|
||||||
|
items: [
|
||||||
|
{ label: 'In-Game Ops', to: '/admin/uo/shard-ops', group: 'Moderation', order: 30 },
|
||||||
|
{ label: 'Shard', to: '/admin/uo/link', group: 'System', order: 0 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const nav = withModuleNav(ADMIN, 'admin')
|
||||||
|
assert.deepEqual(nav.map((g) => g.title), [undefined, 'Moderation', 'System', undefined])
|
||||||
|
assert.deepEqual(nav[1].items.map((i) => i.label), ['Moderation', 'In-Game Ops'])
|
||||||
|
// order 0 puts it above both core rows, which is the whole point of the field.
|
||||||
|
assert.deepEqual(nav[2].items.map((i) => i.label), ['Shard', 'Users', 'Settings'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unknown group appends a new group instead of dropping the row', () => {
|
||||||
|
// A typo must cost a position, never a link.
|
||||||
|
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Moderaton' }] })
|
||||||
|
const nav = withModuleNav(ADMIN, 'admin')
|
||||||
|
assert.equal(nav.length, ADMIN.length + 1)
|
||||||
|
assert.deepEqual(nav.at(-1), { title: 'Moderaton', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Moderaton', moduleId: 'uo' }] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an admin row with no group gets a trailing untitled group of its own', () => {
|
||||||
|
// NOT folded into one of core's untitled groups: those are Dashboard at the
|
||||||
|
// top and Account at the bottom, and a module page belongs beside neither.
|
||||||
|
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas' }] })
|
||||||
|
const nav = withModuleNav(ADMIN, 'admin')
|
||||||
|
assert.equal(nav.length, ADMIN.length + 1)
|
||||||
|
assert.equal(nav.at(-1).title, undefined)
|
||||||
|
assert.deepEqual(nav.at(-1).items.map((i) => i.label), ['Atlas'])
|
||||||
|
assert.deepEqual(nav[0].items.map((i) => i.label), ['Dashboard'])
|
||||||
|
assert.deepEqual(nav[3].items.map((i) => i.label), ['Account'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a row whose `to` collides with a core row is dropped, not rendered twice', () => {
|
||||||
|
// `to` is the key the override layer stores under and React renders by. Two
|
||||||
|
// rows sharing one would give an admin a single editor row that moves both.
|
||||||
|
const warnings = []
|
||||||
|
const warn = console.warn
|
||||||
|
console.warn = (msg) => warnings.push(msg)
|
||||||
|
try {
|
||||||
|
registerNav('uo', {
|
||||||
|
area: 'public',
|
||||||
|
items: [{ label: 'Not News', to: '/site/news' }, { label: 'Atlas', to: '/uo/atlas' }],
|
||||||
|
})
|
||||||
|
const nav = withModuleNav(PUBLIC, 'public')
|
||||||
|
assert.deepEqual(nav.map((i) => i.label), ['Home', 'News', 'About', 'Atlas'])
|
||||||
|
assert.equal(warnings.length, 1)
|
||||||
|
assert.match(warnings[0], /\/site\/news.*collides/)
|
||||||
|
} finally {
|
||||||
|
console.warn = warn
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('two modules cannot claim the same path either', () => {
|
||||||
|
const warn = console.warn
|
||||||
|
console.warn = () => {}
|
||||||
|
try {
|
||||||
|
registerNav('aa', { area: 'public', items: [{ label: 'First', to: '/shared' }] })
|
||||||
|
registerNav('zz', { area: 'public', items: [{ label: 'Second', to: '/shared' }] })
|
||||||
|
const labels = withModuleNav(PUBLIC, 'public').map((i) => i.label)
|
||||||
|
assert.deepEqual(labels, ['Home', 'News', 'About', 'First'])
|
||||||
|
} finally {
|
||||||
|
console.warn = warn
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module row carries its moduleId through, which is how the gate finds it', () => {
|
||||||
|
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas' }] })
|
||||||
|
const row = withModuleNav(PUBLIC, 'public').at(-1)
|
||||||
|
assert.equal(row.moduleId, 'uo')
|
||||||
|
assert.equal(row.feature, 'atlas')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module row carries its icon through, and an override cannot touch it', () => {
|
||||||
|
// 1.3.0. Without an `icon` the six extracted UO rows would have been the only
|
||||||
|
// text-only entries in a sidebar where every other row has a glyph. Core
|
||||||
|
// renders whatever component the row carries and supplies no fallback — an
|
||||||
|
// invented one would be core making a presentation choice for content it knows
|
||||||
|
// nothing about.
|
||||||
|
const Glyph = () => null
|
||||||
|
registerNav('uo', {
|
||||||
|
area: 'admin',
|
||||||
|
items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System', icon: Glyph }],
|
||||||
|
})
|
||||||
|
const merged = withModuleNav(ADMIN, 'admin')
|
||||||
|
const row = merged.find((g) => g.title === 'System').items.find((i) => i.to === '/admin/uo/link')
|
||||||
|
assert.equal(row.icon, Glyph)
|
||||||
|
|
||||||
|
// `icon` was already on navOverrides' list of fields an override may not
|
||||||
|
// touch, from long before a module could supply one. It still is.
|
||||||
|
const overridden = applyNavOverrides(merged, { '/admin/uo/link': { label: 'Renamed', icon: 'nope' } })
|
||||||
|
const after = overridden.find((g) => g.title === 'System').items.find((i) => i.to === '/admin/uo/link')
|
||||||
|
assert.equal(after.label, 'Renamed')
|
||||||
|
assert.equal(after.icon, Glyph)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a row with no icon simply has none, the same as a core row with none', () => {
|
||||||
|
registerNav('uo', { area: 'player', items: [{ label: 'Characters', to: '/player/uo/characters', order: 0 }] })
|
||||||
|
const row = withModuleNav([{ to: '/account', label: 'Account' }], 'player')[0]
|
||||||
|
assert.equal(row.to, '/player/uo/characters')
|
||||||
|
assert.equal(row.icon, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('areas do not leak into one another', () => {
|
||||||
|
registerNav('uo', { area: 'admin', items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System' }] })
|
||||||
|
assert.equal(withModuleNav(PUBLIC, 'public'), PUBLIC)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The relationship that is the actual requirement ───────────────────────
|
||||||
|
|
||||||
|
test('an admin override applies to a module row exactly as to a core row', () => {
|
||||||
|
// The reason the interleave happens BEFORE the merge and not after: the merge
|
||||||
|
// drops any key its base array does not declare, so appending module rows
|
||||||
|
// afterwards would make every one of them unorderable, unrelabellable and
|
||||||
|
// unhideable — a visible regression the day the UO rows leave core.
|
||||||
|
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }] })
|
||||||
|
const base = withModuleNav(PUBLIC, 'public')
|
||||||
|
const merged = applyNavOverrides(base, {
|
||||||
|
'/uo/atlas': { label: 'Bestiary', order: 0 },
|
||||||
|
'/site/news': { order: 3 },
|
||||||
|
})
|
||||||
|
assert.deepEqual(merged.map((i) => i.label), ['Bestiary', 'Home', 'About', 'News'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an override can hide a module row, and the public tree can section it', () => {
|
||||||
|
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }, { label: 'Market', to: '/uo/market' }] })
|
||||||
|
const base = withModuleNav(PUBLIC, 'public')
|
||||||
|
|
||||||
|
const hidden = buildPublicNav(base, { '/uo/atlas': { hidden: true } })
|
||||||
|
assert.equal(hidden.some((n) => n.to === '/uo/atlas'), false)
|
||||||
|
|
||||||
|
const sectioned = buildPublicNav(base, {
|
||||||
|
items: { '/uo/market': { section: 'sec_shard' } },
|
||||||
|
sections: [{ id: 'sec_shard', label: 'Shard', order: 0 }],
|
||||||
|
})
|
||||||
|
assert.equal(sectioned[0].kind, 'section')
|
||||||
|
assert.deepEqual(sectioned[0].items.map((i) => i.to), ['/uo/market'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module row can be moved between admin groups by an override', () => {
|
||||||
|
registerNav('uo', { area: 'admin', items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System' }] })
|
||||||
|
const base = withModuleNav(ADMIN, 'admin')
|
||||||
|
const merged = applyNavOverrides(base, { '/admin/uo/link': { group: 'Moderation' } })
|
||||||
|
assert.deepEqual(merged[1].items.map((i) => i.to), ['/admin/moderation', '/admin/uo/link'])
|
||||||
|
assert.deepEqual(merged[2].items.map((i) => i.to), ['/admin/users', '/admin/settings'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a group a module created is itself a legal override destination', () => {
|
||||||
|
// Falls out of building the destination set from the base nav it is handed —
|
||||||
|
// recorded because it is the kind of thing that would otherwise be discovered
|
||||||
|
// by an admin finding a section they cannot move anything into.
|
||||||
|
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Shard' }] })
|
||||||
|
const base = withModuleNav(ADMIN, 'admin')
|
||||||
|
const merged = applyNavOverrides(base, { '/admin/users': { group: 'Shard' } })
|
||||||
|
assert.deepEqual(merged.at(-1).items.map((i) => i.to), ['/admin/uo/atlas', '/admin/users'])
|
||||||
|
})
|
||||||
188
client/test/moduleRegistry.test.js
Normal file
188
client/test/moduleRegistry.test.js
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { test, beforeEach } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import {
|
||||||
|
registry,
|
||||||
|
registerRoutes,
|
||||||
|
registerNav,
|
||||||
|
registerFeatureProvider,
|
||||||
|
routesFor,
|
||||||
|
navFor,
|
||||||
|
featureProviderFor,
|
||||||
|
featureProviders,
|
||||||
|
registeredIds,
|
||||||
|
_reset,
|
||||||
|
} from '../src/modules/registry.js'
|
||||||
|
import { MODULE_API_VERSION } from '../src/modules/version.js'
|
||||||
|
|
||||||
|
// The client-side module registry (docs/website/MODULE_API.md §3.3). Tested in
|
||||||
|
// isolation from React, like the nav-override merge next door, because the
|
||||||
|
// property worth proving has nothing to do with rendering: a module gets exactly
|
||||||
|
// the URL namespace core gave it, however it spells the paths it registers.
|
||||||
|
//
|
||||||
|
// window.__rg itself (modules/shared.js) is not tested here — it imports .jsx and
|
||||||
|
// there is no DOM in this runner. What it publishes is React, the router and
|
||||||
|
// core components: a wiring test would assert that an import statement imported
|
||||||
|
// something. Phase 1's spike proved the half that can actually fail, which is a
|
||||||
|
// real chunk resolving its externals against the global in a browser under an
|
||||||
|
// enforced CSP.
|
||||||
|
|
||||||
|
beforeEach(() => _reset())
|
||||||
|
|
||||||
|
test('a module route is namespaced under the module id', () => {
|
||||||
|
registerRoutes('uo', { public: [{ path: 'atlas', element: 'ATLAS' }] })
|
||||||
|
assert.deepEqual(
|
||||||
|
routesFor('public').map((r) => r.path),
|
||||||
|
['uo/atlas'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module cannot spell its way out of its namespace', () => {
|
||||||
|
// Whatever the module writes, the segment it lands under is core's to choose:
|
||||||
|
// leading slashes, several of them, a trailing one, or nothing at all.
|
||||||
|
registerRoutes('uo', {
|
||||||
|
public: [
|
||||||
|
{ path: '/atlas' },
|
||||||
|
{ path: '//atlas/creatures' },
|
||||||
|
{ path: 'atlas/' },
|
||||||
|
{ path: '' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert.deepEqual(
|
||||||
|
routesFor('public').map((r) => r.path),
|
||||||
|
['uo/atlas', 'uo/atlas/creatures', 'uo/atlas', 'uo'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a path is namespaced, not sanitised — traversal stays a literal segment', () => {
|
||||||
|
// `..` is not stripped, and does not need to be: React Router matches path
|
||||||
|
// patterns literally, so `/uo/../admin` is a route nothing navigates to rather
|
||||||
|
// than a route that resolves somewhere else. Asserted so that a future
|
||||||
|
// "cleanup" that starts resolving these knows it changed a behaviour.
|
||||||
|
registerRoutes('uo', { public: [{ path: '../admin' }] })
|
||||||
|
assert.deepEqual(routesFor('public')[0].path, 'uo/../admin')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('routes keep their gate and carry the owning module id', () => {
|
||||||
|
registerRoutes('uo', {
|
||||||
|
admin: [{ path: 'shard-ops', element: 'OPS', gate: { roles: ['admin', 'moderator'] } }],
|
||||||
|
})
|
||||||
|
const [route] = routesFor('admin')
|
||||||
|
assert.deepEqual(route.gate, { roles: ['admin', 'moderator'] })
|
||||||
|
assert.equal(route.moduleId, 'uo')
|
||||||
|
assert.equal(route.element, 'OPS')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the three areas are kept apart', () => {
|
||||||
|
registerRoutes('uo', {
|
||||||
|
public: [{ path: 'atlas' }],
|
||||||
|
admin: [{ path: 'link' }],
|
||||||
|
player: [{ path: 'chars' }],
|
||||||
|
})
|
||||||
|
assert.equal(routesFor('public').length, 1)
|
||||||
|
assert.equal(routesFor('admin').length, 1)
|
||||||
|
assert.equal(routesFor('player').length, 1)
|
||||||
|
// An area nobody registered is an empty list, never undefined: App.jsx maps
|
||||||
|
// over all three unconditionally.
|
||||||
|
_reset()
|
||||||
|
for (const area of ['public', 'admin', 'player']) assert.deepEqual(routesFor(area), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unknown area throws rather than being dropped', () => {
|
||||||
|
// Loudly, because the alternative is a module whose pages simply never appear
|
||||||
|
// and no indication anywhere of why.
|
||||||
|
assert.throws(() => registerRoutes('uo', { publik: [{ path: 'atlas' }] }), /unknown area/)
|
||||||
|
assert.throws(() => registerNav('uo', { area: 'sidebar', items: [] }), /unknown area/)
|
||||||
|
assert.equal(registeredIds().length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('nav items sort by order, and equal orders keep load order', () => {
|
||||||
|
registerNav('aa', { area: 'admin', items: [{ label: 'Second', to: '/a', order: 30 }] })
|
||||||
|
registerNav('zz', { area: 'admin', items: [{ label: 'Third', to: '/z', order: 30 }] })
|
||||||
|
registerNav('mm', { area: 'admin', items: [{ label: 'First', to: '/m', order: 10 }] })
|
||||||
|
assert.deepEqual(
|
||||||
|
navFor('admin').map((i) => i.label),
|
||||||
|
['First', 'Second', 'Third'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a nav item with no order sorts after the ones that asked for a place', () => {
|
||||||
|
registerNav('uo', {
|
||||||
|
area: 'public',
|
||||||
|
items: [{ label: 'Unordered', to: '/u' }, { label: 'Early', to: '/e', order: 5 }],
|
||||||
|
})
|
||||||
|
assert.deepEqual(
|
||||||
|
navFor('public').map((i) => i.label),
|
||||||
|
['Early', 'Unordered'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a feature provider is stored under its namespace, with its owner', () => {
|
||||||
|
const hook = () => ({ atlas: true })
|
||||||
|
registerFeatureProvider('uo', 'shard', hook)
|
||||||
|
assert.deepEqual(featureProviderFor('shard'), { id: 'uo', hook })
|
||||||
|
assert.equal(featureProviderFor('nothing'), undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('providers can be enumerated in registration order, with their owner', () => {
|
||||||
|
// Core's feature context has to CALL each of these, as a hook, in a fixed
|
||||||
|
// order — so it needs the list, and it needs the owner id to resolve a nav
|
||||||
|
// row whose `moduleId` says who it belongs to (modules/features.jsx).
|
||||||
|
const uo = () => null
|
||||||
|
const rust = () => null
|
||||||
|
registerFeatureProvider('uo', 'shard', uo)
|
||||||
|
registerFeatureProvider('rust', 'server', rust)
|
||||||
|
assert.deepEqual(featureProviders(), [
|
||||||
|
{ id: 'uo', namespace: 'shard', hook: uo },
|
||||||
|
{ id: 'rust', namespace: 'server', hook: rust },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('enumerating providers is NOT part of the module-facing surface', () => {
|
||||||
|
// A module asks for a namespace it knows the name of; enumerating what
|
||||||
|
// everyone else registered is core's business, so `featureProviders` is a
|
||||||
|
// module export and not a member of window.__rg.registry.
|
||||||
|
assert.equal(registry.featureProviders, undefined)
|
||||||
|
assert.equal(typeof featureProviders, 'function')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every registration marks the module registered', () => {
|
||||||
|
registerRoutes('a', { public: [{ path: 'x' }] })
|
||||||
|
registerNav('b', { area: 'public', items: [] })
|
||||||
|
registerFeatureProvider('c', 'ns', () => {})
|
||||||
|
assert.deepEqual(registeredIds().sort(), ['a', 'b', 'c'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the registry object handed to modules exposes the whole surface', () => {
|
||||||
|
// window.__rg.registry is the ONLY way a module reaches any of this, so a
|
||||||
|
// member missing from the object is a member that does not exist.
|
||||||
|
assert.deepEqual(Object.keys(registry).sort(), [
|
||||||
|
'featureProviderFor',
|
||||||
|
'navFor',
|
||||||
|
'registerExtension',
|
||||||
|
'registerFeatureProvider',
|
||||||
|
'registerNav',
|
||||||
|
'registerRoutes',
|
||||||
|
'registeredIds',
|
||||||
|
'routesFor',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the client and server halves declare the same MODULE_API_VERSION', () => {
|
||||||
|
// The value is duplicated because it has to be on window.__rg before the first
|
||||||
|
// module chunk evaluates, which is earlier than a fetch could answer. This is
|
||||||
|
// the test that pays for the copy: a bump that edits one file fails here
|
||||||
|
// instead of shipping a core whose two halves disagree about the contract they
|
||||||
|
// implement.
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const server = fs.readFileSync(
|
||||||
|
path.join(here, '..', '..', 'server', 'src', 'modules', 'version.js'),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const match = server.match(/MODULE_API_VERSION\s*=\s*'([^']+)'/)
|
||||||
|
assert.ok(match, 'server/src/modules/version.js no longer declares MODULE_API_VERSION as a literal')
|
||||||
|
assert.equal(MODULE_API_VERSION, match[1])
|
||||||
|
})
|
||||||
94
client/test/moduleSlots.test.js
Normal file
94
client/test/moduleSlots.test.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { test, beforeEach } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import {
|
||||||
|
registry,
|
||||||
|
declareSlot,
|
||||||
|
registerExtension,
|
||||||
|
extensionFor,
|
||||||
|
registeredIds,
|
||||||
|
_reset,
|
||||||
|
} from '../src/modules/registry.js'
|
||||||
|
|
||||||
|
// Client extension slots (docs/website/MODULE_API.md §3.7) — the client twin of
|
||||||
|
// the server's declareSlot/registerExtension.
|
||||||
|
//
|
||||||
|
// The registry half only. `<Slot>` itself renders, and there is no DOM in this
|
||||||
|
// runner, so what it does with what these functions return — including the error
|
||||||
|
// boundary — is proved by the §7.7 browser smoke instead. Everything below is a
|
||||||
|
// rule that can be stated without rendering anything, and every one of them can
|
||||||
|
// be got wrong in a way a browser check would not obviously catch.
|
||||||
|
|
||||||
|
beforeEach(() => _reset())
|
||||||
|
|
||||||
|
const Fake = () => null
|
||||||
|
const Other = () => null
|
||||||
|
|
||||||
|
test('an unfilled slot reads as nothing', () => {
|
||||||
|
// The guarantee core's layouts rest on: place a slot, install no module, and
|
||||||
|
// the page renders what it rendered before.
|
||||||
|
declareSlot('site.footer.status')
|
||||||
|
assert.equal(extensionFor('site.footer.status'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an undeclared slot reads as nothing rather than throwing', () => {
|
||||||
|
// Reading is core's side and stays fail-safe: a typo in a layout costs that
|
||||||
|
// spot, not the page. Only WRITING is strict, which is the next test.
|
||||||
|
assert.equal(extensionFor('nope'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module fills a declared slot and core reads it back', () => {
|
||||||
|
declareSlot('admin.users.detail')
|
||||||
|
registerExtension('uo', 'admin.users.detail', Fake)
|
||||||
|
assert.equal(extensionFor('admin.users.detail'), Fake)
|
||||||
|
assert.deepEqual(registeredIds(), ['uo'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('filling an unknown slot throws, naming the slot', () => {
|
||||||
|
// This is the one place the client registry is NOT fail-open, and the reason
|
||||||
|
// is asymmetry of consequence: a dropped nav row costs a link the viewer can
|
||||||
|
// reach another way, a silently dropped extension is invisible to everyone
|
||||||
|
// including its author. Declaration structurally precedes filling (§3.1), so
|
||||||
|
// this can only ever be a typo or a version skew.
|
||||||
|
assert.throws(() => registerExtension('uo', 'site.footer.sttaus', Fake), /unknown extension slot "site\.footer\.sttaus"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a non-component fill throws', () => {
|
||||||
|
declareSlot('site.footer.status')
|
||||||
|
assert.throws(() => registerExtension('uo', 'site.footer.status', { render: true }), /is not a component/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a second module cannot take a filled slot, and the first keeps it', () => {
|
||||||
|
// Matches the server's rule exactly (registries.js): first fill wins, second
|
||||||
|
// is an error. The second half of the assertion is the one that matters — a
|
||||||
|
// rejected fill must not have half-replaced the incumbent.
|
||||||
|
declareSlot('admin.users.detail')
|
||||||
|
registerExtension('uo', 'admin.users.detail', Fake)
|
||||||
|
assert.throws(() => registerExtension('other', 'admin.users.detail', Other), /already filled by "uo"/)
|
||||||
|
assert.equal(extensionFor('admin.users.detail'), Fake)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('declaring a slot twice throws', () => {
|
||||||
|
// Core-side programming error: two owners for one position means whichever
|
||||||
|
// module registered first wins by file order.
|
||||||
|
declareSlot('site.footer.status')
|
||||||
|
assert.throws(() => declareSlot('site.footer.status'), /already declared/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('core fills a slot through the same seam a module uses', () => {
|
||||||
|
// The client twin of registries.registerCore(). Core is a registrant with an
|
||||||
|
// id like any other, which is what makes slice 3 a deletion: the module
|
||||||
|
// registers the same slot and core drops its line.
|
||||||
|
declareSlot('site.footer.status')
|
||||||
|
registerExtension('core', 'site.footer.status', Fake)
|
||||||
|
assert.deepEqual(registeredIds(), ['core'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('declareSlot and extensionFor are not on the module-facing registry', () => {
|
||||||
|
// Declaring is core's alone (§3.7), and reading who filled a slot is core's
|
||||||
|
// too — the same line featureProviders() draws. registerExtension IS on the
|
||||||
|
// object, because filling is the whole point.
|
||||||
|
assert.equal(registry.declareSlot, undefined)
|
||||||
|
assert.equal(registry.extensionFor, undefined)
|
||||||
|
assert.equal(typeof registry.registerExtension, 'function')
|
||||||
|
})
|
||||||
59
client/test/pageShell.test.js
Normal file
59
client/test/pageShell.test.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { shellClass, SHELL_WIDTHS } from '../src/lib/pageShell.js'
|
||||||
|
|
||||||
|
// `PublicLayout`'s `shell` prop (MODULE_API.md §3.4, MODULE_API_VERSION 1.5.0).
|
||||||
|
// The component itself is .jsx and unreachable from this runner — there is no DOM
|
||||||
|
// here — so the rule lives in lib/pageShell.js and is asserted here, and the
|
||||||
|
// rendering is proved in a browser (MODULE_API.md §7.7), which is where the
|
||||||
|
// defect that produced this prop was found in the first place.
|
||||||
|
|
||||||
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
test('no shell means no wrapper — the behaviour every page had before 1.5.0', () => {
|
||||||
|
// null, not an empty string: PublicLayout branches on it to render `children`
|
||||||
|
// bare, and '' would render a <div class=""> that changes core's nine pages.
|
||||||
|
assert.equal(shellClass(undefined), null)
|
||||||
|
assert.equal(shellClass(null), null)
|
||||||
|
assert.equal(shellClass(''), null)
|
||||||
|
assert.equal(shellClass(false), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('each documented width maps to its theme.css class, plus page-body', () => {
|
||||||
|
assert.equal(shellClass('narrow'), 'shell-narrow page-body')
|
||||||
|
assert.equal(shellClass('mid'), 'shell-mid page-body')
|
||||||
|
assert.equal(shellClass('wide'), 'shell-wide page-body')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('page-body is always present — it is what pushes the footer down', () => {
|
||||||
|
// `.page` is a flex column and `.page-body { flex: 1 }` is the only thing
|
||||||
|
// filling it. A width class on its own centres the content and still lets the
|
||||||
|
// footer ride up under it, which is half the reported defect and the half that
|
||||||
|
// is easy to lose in a refactor.
|
||||||
|
for (const w of SHELL_WIDTHS) {
|
||||||
|
assert.match(shellClass(w), /\bpage-body\b/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unknown width still renders a wrapper, at the narrow default', () => {
|
||||||
|
// The value can arrive from a module built against a different version of this
|
||||||
|
// list, so the failure mode has to be "wrong width" and never "no wrapper".
|
||||||
|
assert.equal(shellClass('enormous'), 'shell-narrow page-body')
|
||||||
|
assert.equal(shellClass(true), 'shell-narrow page-body')
|
||||||
|
assert.equal(shellClass('NARROW'), 'shell-narrow page-body')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every width this module offers is a class theme.css actually defines', () => {
|
||||||
|
// The contract now names these widths to module authors, so a rename in
|
||||||
|
// theme.css has to fail here rather than silently in a module's page.
|
||||||
|
const css = fs.readFileSync(path.join(HERE, '../src/styles/theme.css'), 'utf8')
|
||||||
|
for (const w of SHELL_WIDTHS) {
|
||||||
|
const cls = shellClass(w).split(' ')[0]
|
||||||
|
assert.ok(css.includes(`.${cls} {`), `theme.css defines .${cls}`)
|
||||||
|
}
|
||||||
|
assert.ok(css.includes('.page-body {'), 'theme.css defines .page-body')
|
||||||
|
})
|
||||||
@@ -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), '')
|
|
||||||
})
|
|
||||||
@@ -35,6 +35,28 @@ services:
|
|||||||
DB_HOST: db
|
DB_HOST: db
|
||||||
UPLOAD_DIR: /app/uploads
|
UPLOAD_DIR: /app/uploads
|
||||||
LOG_DIR: /app/logs
|
LOG_DIR: /app/logs
|
||||||
|
# Where the loader scans for installed modules. Same path the code already
|
||||||
|
# defaults to (<repo>/modules, and the repo is /app in the image), set
|
||||||
|
# explicitly because the bind mount below is what makes it meaningful.
|
||||||
|
MODULES_DIR: /app/modules
|
||||||
|
# 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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -47,6 +69,27 @@ services:
|
|||||||
# the image, so this mount only matters for custom brand images. Create
|
# the image, so this mount only matters for custom brand images. Create
|
||||||
# ./brand/ on the host and drop assets in; read-only in the container.
|
# ./brand/ on the host and drop assets in; read-only in the container.
|
||||||
- ./brand:/app/brand:ro
|
- ./brand:/app/brand:ro
|
||||||
|
# Installed modules (docs/website/MODULE_SYSTEM.md). Modules live on a
|
||||||
|
# mount, NEVER in the image: that is what lets an operator add one to a
|
||||||
|
# pull-only deployment without building anything. A bind mount rather than
|
||||||
|
# a named volume because placing a module directory by hand is a supported
|
||||||
|
# install — `tar -xf uo-1.0.0.tgz -C ./modules` then restart — and that has
|
||||||
|
# to be doable from the host, not through `docker cp`. It is no longer the
|
||||||
|
# usual way in: declare MODULES above, or install from Admin → Modules.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
# would recreate a missing bind-mount source as root:root and the container
|
||||||
|
# user could no longer write it. If the app runs as a uid that does not own
|
||||||
|
# ./modules, `chown 1000:1000 modules` on the host.
|
||||||
|
#
|
||||||
|
# Adding or removing a module takes a RESTART: the scan is synchronous at
|
||||||
|
# require time (MODULE_API.md §4.1), so nothing here is picked up live.
|
||||||
|
- ./modules:/app/modules
|
||||||
# Only the PUBLIC API port (3000) is published. The internal server<->bot
|
# Only the PUBLIC API port (3000) is published. The internal server<->bot
|
||||||
# port (INTERNAL_PORT, default 3001) is deliberately NOT listed here, so it
|
# port (INTERNAL_PORT, default 3001) is deliberately NOT listed here, so it
|
||||||
# stays reachable only over the private compose network — Pangolin/the public
|
# stays reachable only over the private compose network — Pangolin/the public
|
||||||
|
|||||||
75
modules/README.md
Normal file
75
modules/README.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Installed modules
|
||||||
|
|
||||||
|
This directory is bind-mounted into the container at `/app/modules` (see
|
||||||
|
`docker-compose.yml`). It is where **installed modules** live — the game-specific
|
||||||
|
routes, tables, screens and nav that are not part of core. Design of record:
|
||||||
|
[`docs/website/MODULE_SYSTEM.md`](../../docs/website/MODULE_SYSTEM.md); the
|
||||||
|
normative contract module authors build against is
|
||||||
|
[`docs/website/MODULE_API.md`](../../docs/website/MODULE_API.md).
|
||||||
|
|
||||||
|
**Core ships no module.** This directory is empty in a fresh checkout, and the
|
||||||
|
site runs cleanly that way — an empty `modules/` is the normal state for bare
|
||||||
|
core, not a misconfiguration. Everything below is ignored by git except this
|
||||||
|
README, which exists so the directory itself is tracked: `docker-compose.yml`
|
||||||
|
bind-mounts it, and Docker recreates a *missing* bind-mount source as a
|
||||||
|
root-owned directory the container user cannot write.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
One directory per module, named for its id, each holding a prebuilt bundle:
|
||||||
|
|
||||||
|
```
|
||||||
|
modules/
|
||||||
|
uo/
|
||||||
|
module.json # the manifest the loader reads
|
||||||
|
server/index.js # registers routes, streams, hooks
|
||||||
|
server/db/schema.sql # tables, replayed every boot
|
||||||
|
server/db/purge.sql # only ever run by an explicit purge
|
||||||
|
client/dist/entry.js # prebuilt ESM chunk, served at /modules/uo/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nothing here is compiled by the operator.** A module arrives already built —
|
||||||
|
that is the whole point of the design. There is no install step that runs a
|
||||||
|
bundler, and none that needs one.
|
||||||
|
|
||||||
|
## Installing a module
|
||||||
|
|
||||||
|
Two supported paths, both writing the same `installed_modules` row:
|
||||||
|
|
||||||
|
- **The admin panel** downloads the bundle from the module's release, verifies it
|
||||||
|
against its `sha256`, and unpacks it here.
|
||||||
|
- **By hand**, for a compose-managed host: unpack the bundle into a directory
|
||||||
|
named for the module id, e.g. `tar -xf uo-1.0.0.tgz -C ./modules`.
|
||||||
|
|
||||||
|
Either way, **adding or removing a module takes a restart.** The loader scans
|
||||||
|
this directory synchronously at startup (`MODULE_API.md` §4.1); nothing placed
|
||||||
|
here is picked up by a running server.
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
On boot each module is validated, mounted, its schema fragment replayed and its
|
||||||
|
`onBoot` hook run — reaching `started`, or `startup_failed` with the stage and
|
||||||
|
reason recorded. A module that fails to start does not stop the site: core, and
|
||||||
|
every other module, carry on without it.
|
||||||
|
|
||||||
|
## Uninstalling
|
||||||
|
|
||||||
|
Removing a directory and restarting is enough to stop a module serving. Note that
|
||||||
|
this is *not* the same as an uninstall through the admin panel, which also marks
|
||||||
|
the row `disabled` — a directory that simply vanishes leaves a row claiming to be
|
||||||
|
enabled, which the loader records as `startup_failed`.
|
||||||
|
|
||||||
|
A module's **tables and data are retained** in both cases. Dropping them is a
|
||||||
|
separate, explicit, destructive purge; it is never bundled into an uninstall.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
The container runs as uid 1000 (`node`) and the admin panel writes here, so the
|
||||||
|
app must be able to write this directory. It is created by your checkout, with
|
||||||
|
your ownership. If they differ:
|
||||||
|
|
||||||
|
```
|
||||||
|
chown -R 1000:1000 modules
|
||||||
|
```
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# module-uo — the Phase 1 spike
|
|
||||||
|
|
||||||
**This branch is evidence, not implementation.** `spike/module-atlas` is cut from `edge` and is
|
|
||||||
never merged. Phase 2 rebuilds the loader properly, with the `installed_modules` table, the full
|
|
||||||
state machine and the admin panel behind it; Phase 3 does the real extraction.
|
|
||||||
|
|
||||||
What it demonstrates, and the results, are written up in
|
|
||||||
[`docs/website/MODULE_API.md`](../../../docs/website/MODULE_API.md) Part 7. In one line: the six
|
|
||||||
public spawn-atlas routes now live in a module, at byte-identical URLs, with the client half loading
|
|
||||||
as a prebuilt ESM chunk under `script-src 'self'`.
|
|
||||||
|
|
||||||
## Reproducing it
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. build the module's client chunk (its CI would do this and ship the result)
|
|
||||||
cd modules/uo/client && npm install && npm run build # → dist/entry.js
|
|
||||||
|
|
||||||
# 2. build core's client
|
|
||||||
cd ../../../client && npm install && npm run build
|
|
||||||
|
|
||||||
# 3. run the server against the local MariaDB
|
|
||||||
cd ../server && npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
Then:
|
|
||||||
|
|
||||||
- `/uo/atlas` and `/uo/atlas/lizardman` render from the module's chunk.
|
|
||||||
- `GET /api/v1/public/atlas/*` answers exactly as before — `npm run routes:manifest -- --check`
|
|
||||||
reports the surface unchanged.
|
|
||||||
- `npm test` in `server/` (core, 729) and `node --test` in `modules/uo/server/` (module, 81).
|
|
||||||
|
|
||||||
`dist/entry.js` is committed here **only** because this branch is the evidence for a design
|
|
||||||
decision and a reviewer should be able to inspect the built artifact without a toolchain. A real
|
|
||||||
module publishes it from CI into its release bundle and never commits it.
|
|
||||||
|
|
||||||
## What in here is not design
|
|
||||||
|
|
||||||
Three things are consequences of stopping at six routes, spelled out in MODULE_API.md §7.5:
|
|
||||||
|
|
||||||
1. **Core reaches into this module twice** — `server/src/router/v1/admin/shardAtlas.controller.js`
|
|
||||||
and `server/test/atlasController.test.js`. The five admin atlas routes sit inside the `/shard`
|
|
||||||
admin prefix core still owns, so they cannot move until the whole prefix does.
|
|
||||||
2. **`server/utils/visibility.js` is a copy** of core's `utils/shardVisibility.js`, which core still
|
|
||||||
needs for the shard routes not yet extracted. Two caches over one table, briefly.
|
|
||||||
3. **There is no `swagger-fragment.json`** — it needs core's merge helper on the other side, which
|
|
||||||
is Phase 2.
|
|
||||||
|
|
||||||
Also note the table names here are `shard_*`, not `uo_*`. That is deliberate and grandfathered by an
|
|
||||||
allowlist in the loader: renaming twenty-seven live tables is a data migration this workstream does
|
|
||||||
not do. Every module written after this one carries its id as a table prefix.
|
|
||||||
409
modules/uo/client/dist/entry.js
vendored
409
modules/uo/client/dist/entry.js
vendored
@@ -1,409 +0,0 @@
|
|||||||
const B = window.__rg.jsxRuntime, { jsx: t, jsxs: l, Fragment: A } = B, U = window.__rg.react, {
|
|
||||||
useState: h,
|
|
||||||
useEffect: I,
|
|
||||||
useMemo: j,
|
|
||||||
useCallback: W,
|
|
||||||
useRef: ne,
|
|
||||||
useContext: se,
|
|
||||||
useReducer: re,
|
|
||||||
createElement: ie,
|
|
||||||
cloneElement: le,
|
|
||||||
createContext: oe,
|
|
||||||
forwardRef: ce,
|
|
||||||
memo: de,
|
|
||||||
Fragment: me,
|
|
||||||
Children: pe,
|
|
||||||
isValidElement: ue,
|
|
||||||
StrictMode: he,
|
|
||||||
Suspense: ge,
|
|
||||||
lazy: fe
|
|
||||||
} = U, E = window.__rg.router, {
|
|
||||||
Link: z,
|
|
||||||
NavLink: ye,
|
|
||||||
Navigate: xe,
|
|
||||||
Outlet: we,
|
|
||||||
Route: be,
|
|
||||||
Routes: ve,
|
|
||||||
useParams: M,
|
|
||||||
useNavigate: Se,
|
|
||||||
useLocation: Ne,
|
|
||||||
useSearchParams: $e,
|
|
||||||
createBrowserRouter: Ce,
|
|
||||||
RouterProvider: ke
|
|
||||||
} = E, { PublicLayout: F, PageHeader: D, Loading: C, ErrorState: v, EmptyState: S, useAsync: k, useAuth: Re, useSite: ze } = window.__rg.ui, { request: f } = window.__rg.api, w = (e) => e ? `?${e}` : "", O = {
|
|
||||||
creatures: (e = {}) => {
|
|
||||||
const a = new URLSearchParams();
|
|
||||||
return e.q && a.set("q", e.q), e.facet && a.set("facet", e.facet), e.limit && a.set("limit", e.limit), e.offset && a.set("offset", e.offset), f(`/public/atlas/creatures${w(a.toString())}`);
|
|
||||||
},
|
|
||||||
creature: (e, a = {}) => {
|
|
||||||
const s = new URLSearchParams();
|
|
||||||
return a.facet && s.set("facet", a.facet), a.points && s.set("points", a.points), f(`/public/atlas/creatures/${encodeURIComponent(e)}${w(s.toString())}`);
|
|
||||||
},
|
|
||||||
regions: (e = {}) => {
|
|
||||||
const a = new URLSearchParams();
|
|
||||||
return e.facet && a.set("facet", e.facet), e.q && a.set("q", e.q), f(`/public/atlas/regions${w(a.toString())}`);
|
|
||||||
},
|
|
||||||
landmarks: (e = {}) => {
|
|
||||||
const a = new URLSearchParams();
|
|
||||||
return e.facet && a.set("facet", e.facet), e.q && a.set("q", e.q), f(`/public/atlas/landmarks${w(a.toString())}`);
|
|
||||||
},
|
|
||||||
champions: (e = {}) => {
|
|
||||||
const a = new URLSearchParams();
|
|
||||||
return e.facet && a.set("facet", e.facet), f(`/public/atlas/champions${w(a.toString())}`);
|
|
||||||
},
|
|
||||||
meta: () => f("/public/atlas/meta")
|
|
||||||
}, y = { atlas: O }, H = 50, x = (e) => Number.isFinite(e) ? e.toLocaleString() : "—", Q = [
|
|
||||||
{ key: "creatures", label: "Creatures" },
|
|
||||||
{ key: "champions", label: "Champion altars" },
|
|
||||||
{ key: "places", label: "Places" }
|
|
||||||
];
|
|
||||||
function R({ active: e, onClick: a, children: s }) {
|
|
||||||
return /* @__PURE__ */ t(
|
|
||||||
"button",
|
|
||||||
{
|
|
||||||
type: "button",
|
|
||||||
onClick: a,
|
|
||||||
className: "sans",
|
|
||||||
style: {
|
|
||||||
fontSize: "0.78rem",
|
|
||||||
padding: "5px 12px",
|
|
||||||
borderRadius: 999,
|
|
||||||
cursor: "pointer",
|
|
||||||
color: e ? "var(--bg-deep)" : "var(--muted)",
|
|
||||||
background: e ? "var(--accent)" : "transparent",
|
|
||||||
border: `1px solid ${e ? "var(--accent)" : "var(--line)"}`
|
|
||||||
},
|
|
||||||
children: s
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function G({ creature: e }) {
|
|
||||||
const a = Object.entries(e.facets || {}).sort((s, r) => r[1] - s[1]);
|
|
||||||
return /* @__PURE__ */ l(
|
|
||||||
z,
|
|
||||||
{
|
|
||||||
to: `/uo/atlas/${encodeURIComponent(e.slug)}`,
|
|
||||||
className: "panel",
|
|
||||||
style: {
|
|
||||||
padding: "13px 15px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 14,
|
|
||||||
textDecoration: "none",
|
|
||||||
color: "inherit"
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
/* @__PURE__ */ l("div", { style: { minWidth: 0, flex: 1 }, children: [
|
|
||||||
/* @__PURE__ */ t(
|
|
||||||
"div",
|
|
||||||
{
|
|
||||||
className: "display",
|
|
||||||
style: {
|
|
||||||
fontSize: "0.98rem",
|
|
||||||
color: "var(--head)",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap"
|
|
||||||
},
|
|
||||||
children: e.name
|
|
||||||
}
|
|
||||||
),
|
|
||||||
/* @__PURE__ */ t("div", { className: "sans dim", style: { fontSize: "0.74rem", marginTop: 3 }, children: a.length === 0 ? "—" : a.map(([s, r]) => `${s} (${r})`).join(" · ") })
|
|
||||||
] }),
|
|
||||||
/* @__PURE__ */ l("div", { className: "sans", style: { flex: "none", textAlign: "right" }, children: [
|
|
||||||
/* @__PURE__ */ t("div", { style: { color: "var(--head)", fontSize: "0.92rem" }, children: x(e.total) }),
|
|
||||||
/* @__PURE__ */ l("div", { className: "dim", style: { fontSize: "0.68rem", letterSpacing: "0.05em" }, children: [
|
|
||||||
x(e.points),
|
|
||||||
" spawners"
|
|
||||||
] })
|
|
||||||
] })
|
|
||||||
]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function V({ q: e, facet: a }) {
|
|
||||||
const [s, r] = h({ loading: !0, error: null, items: [], total: 0 }), [i, p] = h(!1), o = W(
|
|
||||||
async (n) => await y.atlas.creatures({ q: e, facet: a, limit: H, offset: n }),
|
|
||||||
[e, a]
|
|
||||||
);
|
|
||||||
I(() => {
|
|
||||||
let n = !0;
|
|
||||||
return r({ loading: !0, error: null, items: [], total: 0 }), o(0).then((d) => {
|
|
||||||
n && r({ loading: !1, error: null, items: d.creatures || [], total: d.total || 0 });
|
|
||||||
}).catch((d) => n && r({ loading: !1, error: d, items: [], total: 0 })), () => {
|
|
||||||
n = !1;
|
|
||||||
};
|
|
||||||
}, [o]);
|
|
||||||
const c = async () => {
|
|
||||||
p(!0);
|
|
||||||
try {
|
|
||||||
const n = await o(s.items.length);
|
|
||||||
r((d) => ({ ...d, items: [...d.items, ...n.creatures || []], total: n.total ?? d.total }));
|
|
||||||
} catch {
|
|
||||||
} finally {
|
|
||||||
p(!1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return s.loading ? /* @__PURE__ */ t(C, {}) : s.error ? /* @__PURE__ */ t(v, { message: "Could not load the bestiary right now." }) : s.items.length === 0 ? /* @__PURE__ */ t(S, { children: "Nothing in the atlas matches that." }) : /* @__PURE__ */ l(A, { children: [
|
|
||||||
/* @__PURE__ */ l("p", { className: "sans dim", style: { fontSize: "0.78rem", margin: "0 0 12px" }, children: [
|
|
||||||
"Showing ",
|
|
||||||
x(s.items.length),
|
|
||||||
" of ",
|
|
||||||
x(s.total)
|
|
||||||
] }),
|
|
||||||
/* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: s.items.map((n) => /* @__PURE__ */ t(G, { creature: n }, n.slug)) }),
|
|
||||||
s.items.length < s.total && /* @__PURE__ */ t("div", { style: { textAlign: "center", marginTop: 16 }, children: /* @__PURE__ */ t("button", { type: "button", className: "btn", onClick: c, disabled: i, children: i ? "Loading…" : "Load more" }) })
|
|
||||||
] });
|
|
||||||
}
|
|
||||||
function X({ facet: e }) {
|
|
||||||
const { loading: a, error: s, data: r } = k(() => y.atlas.champions(e), [e]);
|
|
||||||
return a ? /* @__PURE__ */ t(C, {}) : s ? /* @__PURE__ */ t(v, { message: "Could not load the champion altars right now." }) : !r || r.length === 0 ? /* @__PURE__ */ t(S, { children: "No champion altars are configured." }) : /* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: r.map((i) => /* @__PURE__ */ l("div", { className: "panel", style: { padding: "13px 15px", display: "flex", gap: 14, alignItems: "center" }, children: [
|
|
||||||
/* @__PURE__ */ l("div", { style: { minWidth: 0, flex: 1 }, children: [
|
|
||||||
/* @__PURE__ */ t("div", { className: "display", style: { fontSize: "0.98rem", color: "var(--head)" }, children: i.label || i.name }),
|
|
||||||
/* @__PURE__ */ l("div", { className: "sans dim", style: { fontSize: "0.74rem", marginTop: 3 }, children: [
|
|
||||||
i.facet,
|
|
||||||
i.group ? ` · ${i.group}` : "",
|
|
||||||
" · ",
|
|
||||||
i.x,
|
|
||||||
", ",
|
|
||||||
i.y
|
|
||||||
] })
|
|
||||||
] }),
|
|
||||||
/* @__PURE__ */ t("span", { className: "sans", style: { flex: "none", fontSize: "0.76rem", color: "var(--muted)" }, children: i.randomType ? "Random champion" : i.type || "—" })
|
|
||||||
] }, i.slug)) });
|
|
||||||
}
|
|
||||||
function J({ q: e, facet: a }) {
|
|
||||||
const { loading: s, error: r, data: i } = k(
|
|
||||||
() => Promise.all([y.atlas.regions({ q: e, facet: a }), y.atlas.landmarks({ q: e, facet: a })]),
|
|
||||||
[e, a]
|
|
||||||
), p = j(() => {
|
|
||||||
if (!i) return [];
|
|
||||||
const [o, c] = i;
|
|
||||||
return [
|
|
||||||
...o.map((n) => ({ key: `r:${n.facet}:${n.name}`, name: n.name, facet: n.facet, detail: n.parent || n.type || "Region", kind: "Region" })),
|
|
||||||
...c.map((n) => ({ key: `l:${n.facet}:${n.group || ""}:${n.name}:${n.x}:${n.y}`, name: n.group ? `${n.group} — ${n.name}` : n.name, facet: n.facet, detail: `${n.x}, ${n.y}`, kind: "Landmark" }))
|
|
||||||
].sort((n, d) => n.name.localeCompare(d.name));
|
|
||||||
}, [i]);
|
|
||||||
return s ? /* @__PURE__ */ t(C, {}) : r ? /* @__PURE__ */ t(v, { message: "Could not load places right now." }) : p.length === 0 ? /* @__PURE__ */ t(S, { children: "No regions or landmarks match that." }) : /* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: p.map((o) => /* @__PURE__ */ l("div", { className: "panel", style: { padding: "10px 14px", display: "flex", gap: 12, alignItems: "baseline" }, children: [
|
|
||||||
/* @__PURE__ */ t("span", { className: "sans", style: { flex: 1, minWidth: 0, color: "var(--head)", fontSize: "0.88rem" }, children: o.name }),
|
|
||||||
/* @__PURE__ */ l("span", { className: "sans dim", style: { fontSize: "0.72rem" }, children: [
|
|
||||||
o.facet,
|
|
||||||
" · ",
|
|
||||||
o.detail
|
|
||||||
] }),
|
|
||||||
/* @__PURE__ */ t("span", { className: "sans dim", style: { fontSize: "0.66rem", letterSpacing: "0.06em", flex: "none" }, children: o.kind })
|
|
||||||
] }, o.key)) });
|
|
||||||
}
|
|
||||||
function K() {
|
|
||||||
var L, _, q;
|
|
||||||
const [e, a] = h("creatures"), [s, r] = h(""), [i, p] = h(""), [o, c] = h(""), n = k(() => y.atlas.meta());
|
|
||||||
I(() => {
|
|
||||||
const m = setTimeout(() => p(s.trim()), 250);
|
|
||||||
return () => clearTimeout(m);
|
|
||||||
}, [s]);
|
|
||||||
const d = ((L = n.data) == null ? void 0 : L.facets) || [], u = ((_ = n.data) == null ? void 0 : _.counts) || null, N = (q = n.data) != null && q.importedAt ? new Date(n.data.importedAt) : null;
|
|
||||||
return /* @__PURE__ */ t(F, { section: "website", children: /* @__PURE__ */ l("div", { className: "shell-narrow page-body", children: [
|
|
||||||
/* @__PURE__ */ t(
|
|
||||||
D,
|
|
||||||
{
|
|
||||||
eyebrow: "Bestiary",
|
|
||||||
title: "Spawn atlas",
|
|
||||||
lead: "Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
|
|
||||||
}
|
|
||||||
),
|
|
||||||
u && /* @__PURE__ */ l("p", { className: "sans dim", style: { fontSize: "0.76rem", margin: "-12px 0 18px" }, children: [
|
|
||||||
x(u.creatures),
|
|
||||||
" creatures across ",
|
|
||||||
x(u.points),
|
|
||||||
" spawners",
|
|
||||||
Number.isFinite(u.unresolvedPoints) && u.points ? ` · ${Math.round((u.points - u.unresolvedPoints) / u.points * 100)}% placed to a named region or landmark` : "",
|
|
||||||
N ? ` · parsed ${N.toLocaleDateString()}` : ""
|
|
||||||
] }),
|
|
||||||
/* @__PURE__ */ t("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }, children: Q.map((m) => /* @__PURE__ */ t(R, { active: e === m.key, onClick: () => a(m.key), children: m.label }, m.key)) }),
|
|
||||||
e !== "champions" && /* @__PURE__ */ t(
|
|
||||||
"input",
|
|
||||||
{
|
|
||||||
className: "input",
|
|
||||||
type: "search",
|
|
||||||
value: s,
|
|
||||||
onChange: (m) => r(m.target.value),
|
|
||||||
placeholder: e === "creatures" ? "Search creatures…" : "Search regions and landmarks…",
|
|
||||||
style: { width: "100%", marginBottom: 12 }
|
|
||||||
}
|
|
||||||
),
|
|
||||||
d.length > 0 && /* @__PURE__ */ l("div", { style: { display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 18 }, children: [
|
|
||||||
/* @__PURE__ */ t(R, { active: o === "", onClick: () => c(""), children: "All facets" }),
|
|
||||||
d.map((m) => /* @__PURE__ */ t(R, { active: o === m, onClick: () => c(m), children: m }, m))
|
|
||||||
] }),
|
|
||||||
n.error && /* @__PURE__ */ t(v, { message: "Could not load the atlas right now." }),
|
|
||||||
!n.error && !n.loading && !N && /* @__PURE__ */ t(S, { children: "The spawn atlas has not been imported yet." }),
|
|
||||||
!n.error && N && /* @__PURE__ */ l(A, { children: [
|
|
||||||
e === "creatures" && /* @__PURE__ */ t(V, { q: i, facet: o }),
|
|
||||||
e === "champions" && /* @__PURE__ */ t(X, { facet: o }),
|
|
||||||
e === "places" && /* @__PURE__ */ t(J, { q: i, facet: o })
|
|
||||||
] })
|
|
||||||
] }) });
|
|
||||||
}
|
|
||||||
const g = (e) => Number.isFinite(e) ? e.toLocaleString() : "—";
|
|
||||||
function Y(e, a) {
|
|
||||||
const s = (r) => r >= 60 ? `${Math.round(r / 60)}m` : `${r}s`;
|
|
||||||
return !Number.isFinite(e) || !Number.isFinite(a) ? null : e === a ? s(e) : `${s(e)}–${s(a)}`;
|
|
||||||
}
|
|
||||||
function P({ title: e, right: a, children: s }) {
|
|
||||||
return /* @__PURE__ */ l("section", { className: "panel", style: { padding: 18 }, children: [
|
|
||||||
/* @__PURE__ */ l("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 12 }, children: [
|
|
||||||
/* @__PURE__ */ t("h2", { className: "display", style: { margin: "0 0 12px", fontSize: "1.02rem", color: "var(--head)" }, children: e }),
|
|
||||||
a
|
|
||||||
] }),
|
|
||||||
s
|
|
||||||
] });
|
|
||||||
}
|
|
||||||
function Z({ places: e }) {
|
|
||||||
return e.length === 0 ? /* @__PURE__ */ t("p", { className: "sans dim", style: { margin: 0 }, children: "No placed spawners." }) : /* @__PURE__ */ t("div", { children: e.map((a) => /* @__PURE__ */ l(
|
|
||||||
"div",
|
|
||||||
{
|
|
||||||
className: "sans",
|
|
||||||
style: {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "baseline",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 12,
|
|
||||||
padding: "6px 0",
|
|
||||||
borderBottom: "1px solid var(--line)",
|
|
||||||
fontSize: "0.86rem"
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
/* @__PURE__ */ t("span", { style: { minWidth: 0, color: "var(--head)" }, children: a.label }),
|
|
||||||
/* @__PURE__ */ l("span", { className: "dim", style: { flex: "none" }, children: [
|
|
||||||
a.facet,
|
|
||||||
" · ",
|
|
||||||
g(a.spawners),
|
|
||||||
" spawner",
|
|
||||||
a.spawners === 1 ? "" : "s",
|
|
||||||
" · up to",
|
|
||||||
" ",
|
|
||||||
g(a.maxAlive),
|
|
||||||
" at once"
|
|
||||||
] })
|
|
||||||
]
|
|
||||||
},
|
|
||||||
`${a.facet}:${a.label}`
|
|
||||||
)) });
|
|
||||||
}
|
|
||||||
function ee({ spawners: e, truncated: a }) {
|
|
||||||
const [s, r] = h(!1);
|
|
||||||
return e.length === 0 ? null : /* @__PURE__ */ t(
|
|
||||||
P,
|
|
||||||
{
|
|
||||||
title: "Individual spawners",
|
|
||||||
right: /* @__PURE__ */ t(
|
|
||||||
"button",
|
|
||||||
{
|
|
||||||
type: "button",
|
|
||||||
className: "sans",
|
|
||||||
onClick: () => r((i) => !i),
|
|
||||||
style: { background: "none", border: "none", color: "var(--accent)", cursor: "pointer", fontSize: "0.78rem" },
|
|
||||||
children: s ? "Hide" : `Show ${g(e.length)}`
|
|
||||||
}
|
|
||||||
),
|
|
||||||
children: s && /* @__PURE__ */ l("div", { style: { overflowX: "auto" }, children: [
|
|
||||||
/* @__PURE__ */ l("table", { className: "sans", style: { width: "100%", borderCollapse: "collapse", fontSize: "0.8rem" }, children: [
|
|
||||||
/* @__PURE__ */ t("thead", { children: /* @__PURE__ */ l("tr", { style: { textAlign: "left", color: "var(--muted)" }, children: [
|
|
||||||
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Place" }),
|
|
||||||
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Facet" }),
|
|
||||||
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Coords" }),
|
|
||||||
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Max" }),
|
|
||||||
/* @__PURE__ */ t("th", { style: { padding: "4px 0 8px 0" }, children: "Respawn" })
|
|
||||||
] }) }),
|
|
||||||
/* @__PURE__ */ t("tbody", { children: e.map((i) => /* @__PURE__ */ l("tr", { style: { borderTop: "1px solid var(--line)" }, children: [
|
|
||||||
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0", color: "var(--head)" }, children: i.label }),
|
|
||||||
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: i.facet }),
|
|
||||||
/* @__PURE__ */ l("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: [
|
|
||||||
i.x,
|
|
||||||
", ",
|
|
||||||
i.y
|
|
||||||
] }),
|
|
||||||
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: g(i.maxCount) }),
|
|
||||||
/* @__PURE__ */ t("td", { style: { padding: "6px 0" }, className: "dim", children: Y(i.minDelay, i.maxDelay) || "—" })
|
|
||||||
] }, i.id)) })
|
|
||||||
] }),
|
|
||||||
a && /* @__PURE__ */ t("p", { className: "sans dim", style: { fontSize: "0.74rem", margin: "10px 0 0" }, children: "Only the largest spawners are listed." })
|
|
||||||
] })
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function te() {
|
|
||||||
var o;
|
|
||||||
const { slug: e } = M(), { loading: a, error: s, data: r } = k(() => y.atlas.creature(e), [e]), i = (s == null ? void 0 : s.status) === 404 || (s == null ? void 0 : s.message) === "Not Found", p = j(
|
|
||||||
() => Object.entries((r == null ? void 0 : r.facets) || {}).sort((c, n) => n[1] - c[1]),
|
|
||||||
[r]
|
|
||||||
);
|
|
||||||
return /* @__PURE__ */ t(F, { section: "website", children: /* @__PURE__ */ l("div", { className: "shell-narrow page-body", children: [
|
|
||||||
/* @__PURE__ */ t("p", { className: "sans", style: { marginBottom: 8 }, children: /* @__PURE__ */ t(z, { to: "/uo/atlas", style: { color: "var(--accent)", fontSize: "0.78rem" }, children: "← Spawn atlas" }) }),
|
|
||||||
a && /* @__PURE__ */ t(C, {}),
|
|
||||||
s && !i && /* @__PURE__ */ t(v, { message: "Could not load that creature right now." }),
|
|
||||||
i && /* @__PURE__ */ t(S, { children: "Nothing by that name spawns on this shard." }),
|
|
||||||
!a && !s && r && /* @__PURE__ */ l(A, { children: [
|
|
||||||
/* @__PURE__ */ t(
|
|
||||||
D,
|
|
||||||
{
|
|
||||||
eyebrow: "Bestiary",
|
|
||||||
title: r.name,
|
|
||||||
lead: `Up to ${g(r.total)} alive at once across ${g(r.points)} spawner${r.points === 1 ? "" : "s"}.`
|
|
||||||
}
|
|
||||||
),
|
|
||||||
/* @__PURE__ */ l("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [
|
|
||||||
/* @__PURE__ */ t(
|
|
||||||
P,
|
|
||||||
{
|
|
||||||
title: "Where it spawns",
|
|
||||||
right: /* @__PURE__ */ t("span", { className: "sans dim", style: { fontSize: "0.74rem" }, children: p.map(([c, n]) => `${c} (${n})`).join(" · ") }),
|
|
||||||
children: /* @__PURE__ */ t(Z, { places: r.places || [] })
|
|
||||||
}
|
|
||||||
),
|
|
||||||
/* @__PURE__ */ t(ee, { spawners: r.spawners || [], truncated: !!r.spawnersTruncated }),
|
|
||||||
((o = r.alsoHere) == null ? void 0 : o.length) > 0 && /* @__PURE__ */ t(P, { title: "Shares a spawner with", children: /* @__PURE__ */ t("div", { style: { display: "flex", flexWrap: "wrap", gap: 8 }, children: r.alsoHere.map((c) => /* @__PURE__ */ l(
|
|
||||||
z,
|
|
||||||
{
|
|
||||||
to: `/uo/atlas/${encodeURIComponent(c.slug)}`,
|
|
||||||
className: "sans",
|
|
||||||
style: {
|
|
||||||
fontSize: "0.78rem",
|
|
||||||
padding: "4px 11px",
|
|
||||||
borderRadius: 999,
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
color: "var(--muted)",
|
|
||||||
textDecoration: "none"
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
c.name,
|
|
||||||
" ",
|
|
||||||
/* @__PURE__ */ l("span", { className: "dim", children: [
|
|
||||||
"×",
|
|
||||||
g(c.shared)
|
|
||||||
] })
|
|
||||||
]
|
|
||||||
},
|
|
||||||
c.slug
|
|
||||||
)) }) })
|
|
||||||
] })
|
|
||||||
] })
|
|
||||||
] }) });
|
|
||||||
}
|
|
||||||
const $ = "uo", T = "^1.0.0";
|
|
||||||
function ae(e) {
|
|
||||||
const [a] = String(e || "").split(".");
|
|
||||||
return a === T.replace(/^\^/, "").split(".")[0];
|
|
||||||
}
|
|
||||||
const b = window.__rg;
|
|
||||||
b ? ae(b.version) ? (b.registry.registerRoutes($, {
|
|
||||||
// Paths are relative to the module's namespace; core prefixes them, so these
|
|
||||||
// render at /uo/atlas and /uo/atlas/:slug (MODULE_SYSTEM.md §2.8).
|
|
||||||
public: [
|
|
||||||
{ path: "atlas", element: /* @__PURE__ */ t(K, {}) },
|
|
||||||
{ path: "atlas/:slug", element: /* @__PURE__ */ t(te, {}) }
|
|
||||||
]
|
|
||||||
}), b.registry.registerNav($, {
|
|
||||||
area: "public",
|
|
||||||
items: [{ label: "Atlas", to: "/uo/atlas", feature: "atlas", order: 12 }]
|
|
||||||
})) : console.error(`[module-${$}] needs core API ${T}, this core is ${b.version} — not registering`) : console.error(`[module-${$}] window.__rg is missing — core did not publish its shared dependencies`);
|
|
||||||
1691
modules/uo/client/package-lock.json
generated
1691
modules/uo/client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "module-uo-client",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.1.0-spike",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"build": "vite build"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
|
||||||
"vite": "^5.4.8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
// module-uo's API bindings.
|
|
||||||
//
|
|
||||||
// These used to be `api.atlas` inside core's client/src/api/client.js — a module
|
|
||||||
// namespace living in core (MODULE_API.md §3.5). The module owns the paths
|
|
||||||
// because it owns the routes at the other end; core hands over only the request
|
|
||||||
// primitive: same-origin /api/v1, cookies included, JSON in/out, ApiError on a
|
|
||||||
// non-2xx.
|
|
||||||
const { request } = window.__rg.api
|
|
||||||
|
|
||||||
const withQs = (s) => (s ? `?${s}` : '')
|
|
||||||
|
|
||||||
export const atlas = {
|
|
||||||
creatures: (opts = {}) => {
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
if (opts.q) qs.set('q', opts.q)
|
|
||||||
if (opts.facet) qs.set('facet', opts.facet)
|
|
||||||
if (opts.limit) qs.set('limit', opts.limit)
|
|
||||||
if (opts.offset) qs.set('offset', opts.offset)
|
|
||||||
return request(`/public/atlas/creatures${withQs(qs.toString())}`)
|
|
||||||
},
|
|
||||||
creature: (slug, opts = {}) => {
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
if (opts.facet) qs.set('facet', opts.facet)
|
|
||||||
if (opts.points) qs.set('points', opts.points)
|
|
||||||
return request(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`)
|
|
||||||
},
|
|
||||||
regions: (opts = {}) => {
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
if (opts.facet) qs.set('facet', opts.facet)
|
|
||||||
if (opts.q) qs.set('q', opts.q)
|
|
||||||
return request(`/public/atlas/regions${withQs(qs.toString())}`)
|
|
||||||
},
|
|
||||||
landmarks: (opts = {}) => {
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
if (opts.facet) qs.set('facet', opts.facet)
|
|
||||||
if (opts.q) qs.set('q', opts.q)
|
|
||||||
return request(`/public/atlas/landmarks${withQs(qs.toString())}`)
|
|
||||||
},
|
|
||||||
champions: (opts = {}) => {
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
if (opts.facet) qs.set('facet', opts.facet)
|
|
||||||
return request(`/public/atlas/champions${withQs(qs.toString())}`)
|
|
||||||
},
|
|
||||||
meta: () => request('/public/atlas/meta'),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = { atlas }
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
// ── module-uo · client entry point ─────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// The prebuilt ESM chunk core loads as
|
|
||||||
// `<script type="module" src="/modules/uo/entry.js">`. Same-origin, so
|
|
||||||
// `script-src 'self'` admits it with no nonce and no inline — which is the
|
|
||||||
// entire reason the client half is shaped this way (MODULE_SYSTEM.md §1.14).
|
|
||||||
//
|
|
||||||
// It evaluates AFTER core's bundle (deferred module scripts run in document
|
|
||||||
// order) and BEFORE core renders (main.jsx waits for DOMContentLoaded), so
|
|
||||||
// registering synchronously here is enough — there is no loading state to
|
|
||||||
// coordinate and no re-render to trigger.
|
|
||||||
|
|
||||||
import Atlas from './pages/Atlas.jsx'
|
|
||||||
import AtlasCreature from './pages/AtlasCreature.jsx'
|
|
||||||
|
|
||||||
const ID = 'uo'
|
|
||||||
const CORE_API = '^1.0.0'
|
|
||||||
|
|
||||||
// The client-side twin of the server's coreApi check. A module built against a
|
|
||||||
// contract this core does not implement must refuse to register rather than
|
|
||||||
// half-work: a missing kit member is a blank page three clicks in, and the
|
|
||||||
// version is knowable now.
|
|
||||||
function compatible(version) {
|
|
||||||
const [major] = String(version || '').split('.')
|
|
||||||
return major === CORE_API.replace(/^\^/, '').split('.')[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
const rg = window.__rg
|
|
||||||
|
|
||||||
if (!rg) {
|
|
||||||
// Not an exception: throwing from a module script is an uncaught error in the
|
|
||||||
// page, and a module failing to load must never be the site failing to load.
|
|
||||||
console.error(`[module-${ID}] window.__rg is missing — core did not publish its shared dependencies`)
|
|
||||||
} else if (!compatible(rg.version)) {
|
|
||||||
console.error(`[module-${ID}] needs core API ${CORE_API}, this core is ${rg.version} — not registering`)
|
|
||||||
} else {
|
|
||||||
rg.registry.registerRoutes(ID, {
|
|
||||||
// Paths are relative to the module's namespace; core prefixes them, so these
|
|
||||||
// render at /uo/atlas and /uo/atlas/:slug (MODULE_SYSTEM.md §2.8).
|
|
||||||
public: [
|
|
||||||
{ path: 'atlas', element: <Atlas /> },
|
|
||||||
{ path: 'atlas/:slug', element: <AtlasCreature /> },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
// Interleaves into core's public nav rather than appending a "UO" group.
|
|
||||||
// `order: 12` puts it where the Atlas link already sat — after Wiki and the
|
|
||||||
// shard boards, before About. `feature` is resolved by the provider below.
|
|
||||||
rg.registry.registerNav(ID, {
|
|
||||||
area: 'public',
|
|
||||||
items: [{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas', order: 12 }],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync } from '../ui.js'
|
|
||||||
import { api } from '../api.js'
|
|
||||||
|
|
||||||
// ── The spawn atlas ─────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// What the shard CONTAINS, as opposed to what it is doing: which creatures
|
|
||||||
// spawn, where, and which champion altars are configured. There is no live feed
|
|
||||||
// here and no `connected` indicator, deliberately — this is parsed from the
|
|
||||||
// shard's own files and stays complete while the shard is down.
|
|
||||||
//
|
|
||||||
// Facet names come from the shard's data, never from a list in this file. A
|
|
||||||
// shard running custom maps gets its own names in the filter with no code
|
|
||||||
// change (docs/link/v3.md §6.1 R2).
|
|
||||||
|
|
||||||
const PAGE = 50
|
|
||||||
|
|
||||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
|
||||||
|
|
||||||
const TABS = [
|
|
||||||
{ key: 'creatures', label: 'Creatures' },
|
|
||||||
{ key: 'champions', label: 'Champion altars' },
|
|
||||||
{ key: 'places', label: 'Places' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function Chip({ active, onClick, children }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className="sans"
|
|
||||||
style={{
|
|
||||||
fontSize: '0.78rem',
|
|
||||||
padding: '5px 12px',
|
|
||||||
borderRadius: 999,
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: active ? 'var(--bg-deep)' : 'var(--muted)',
|
|
||||||
background: active ? 'var(--accent)' : 'transparent',
|
|
||||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreatureCard({ creature }) {
|
|
||||||
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={`/uo/atlas/${encodeURIComponent(creature.slug)}`}
|
|
||||||
className="panel"
|
|
||||||
style={{
|
|
||||||
padding: '13px 15px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 14,
|
|
||||||
textDecoration: 'none',
|
|
||||||
color: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
|
||||||
<div
|
|
||||||
className="display"
|
|
||||||
style={{
|
|
||||||
fontSize: '0.98rem',
|
|
||||||
color: 'var(--head)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{creature.name}
|
|
||||||
</div>
|
|
||||||
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
|
||||||
{facets.length === 0
|
|
||||||
? '—'
|
|
||||||
: facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
|
||||||
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(creature.total)}</div>
|
|
||||||
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>
|
|
||||||
{num(creature.points)} spawners
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The creature list owns its own paging rather than going through useAsync: a
|
|
||||||
// "load more" appends to what is already on screen, which a hook that resets to
|
|
||||||
// `{ loading: true, data: null }` on every dependency change cannot express.
|
|
||||||
function Creatures({ q, facet }) {
|
|
||||||
const [state, setState] = useState({ loading: true, error: null, items: [], total: 0 })
|
|
||||||
const [more, setMore] = useState(false)
|
|
||||||
|
|
||||||
const load = useCallback(
|
|
||||||
async (offset) => {
|
|
||||||
const page = await api.atlas.creatures({ q, facet, limit: PAGE, offset })
|
|
||||||
return page
|
|
||||||
},
|
|
||||||
[q, facet],
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true
|
|
||||||
setState({ loading: true, error: null, items: [], total: 0 })
|
|
||||||
load(0)
|
|
||||||
.then((page) => {
|
|
||||||
if (alive) setState({ loading: false, error: null, items: page.creatures || [], total: page.total || 0 })
|
|
||||||
})
|
|
||||||
.catch((error) => alive && setState({ loading: false, error, items: [], total: 0 }))
|
|
||||||
return () => {
|
|
||||||
alive = false
|
|
||||||
}
|
|
||||||
}, [load])
|
|
||||||
|
|
||||||
const loadMore = async () => {
|
|
||||||
setMore(true)
|
|
||||||
try {
|
|
||||||
const page = await load(state.items.length)
|
|
||||||
setState((s) => ({ ...s, items: [...s.items, ...(page.creatures || [])], total: page.total ?? s.total }))
|
|
||||||
} catch {
|
|
||||||
// A failed "load more" leaves what is already on screen alone; the button
|
|
||||||
// simply stays available to retry.
|
|
||||||
} finally {
|
|
||||||
setMore(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.loading) return <Loading />
|
|
||||||
if (state.error) return <ErrorState message="Could not load the bestiary right now." />
|
|
||||||
if (state.items.length === 0) {
|
|
||||||
return <EmptyState>Nothing in the atlas matches that.</EmptyState>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
|
||||||
Showing {num(state.items.length)} of {num(state.total)}
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{state.items.map((c) => (
|
|
||||||
<CreatureCard key={c.slug} creature={c} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{state.items.length < state.total && (
|
|
||||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
|
||||||
<button type="button" className="btn" onClick={loadMore} disabled={more}>
|
|
||||||
{more ? 'Loading…' : 'Load more'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The CONFIGURED altar roster — where the altars are and what each summons. The
|
|
||||||
// live board ("it is on level 3 right now") is a different page, /site/champs,
|
|
||||||
// fed by the sidecar. Both exist; they are not the same thing.
|
|
||||||
function Champions({ facet }) {
|
|
||||||
const { loading, error, data } = useAsync(() => api.atlas.champions(facet), [facet])
|
|
||||||
if (loading) return <Loading />
|
|
||||||
if (error) return <ErrorState message="Could not load the champion altars right now." />
|
|
||||||
if (!data || data.length === 0) return <EmptyState>No champion altars are configured.</EmptyState>
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{data.map((champ) => (
|
|
||||||
<div key={champ.slug} className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
|
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
|
||||||
<div className="display" style={{ fontSize: '0.98rem', color: 'var(--head)' }}>
|
|
||||||
{champ.label || champ.name}
|
|
||||||
</div>
|
|
||||||
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
|
||||||
{champ.facet}
|
|
||||||
{champ.group ? ` · ${champ.group}` : ''} · {champ.x}, {champ.y}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="sans" style={{ flex: 'none', fontSize: '0.76rem', color: 'var(--muted)' }}>
|
|
||||||
{champ.randomType ? 'Random champion' : champ.type || '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regions and landmarks together: both answer "where is that?", and splitting
|
|
||||||
// them into two tabs would make the visitor guess which list a name lives in.
|
|
||||||
function Places({ q, facet }) {
|
|
||||||
const { loading, error, data } = useAsync(
|
|
||||||
() => Promise.all([api.atlas.regions({ q, facet }), api.atlas.landmarks({ q, facet })]),
|
|
||||||
[q, facet],
|
|
||||||
)
|
|
||||||
const rows = useMemo(() => {
|
|
||||||
if (!data) return []
|
|
||||||
const [regions, landmarks] = data
|
|
||||||
return [
|
|
||||||
...regions.map((r) => ({ key: `r:${r.facet}:${r.name}`, name: r.name, facet: r.facet, detail: r.parent || r.type || 'Region', kind: 'Region' })),
|
|
||||||
...landmarks.map((l) => ({ key: `l:${l.facet}:${l.group || ''}:${l.name}:${l.x}:${l.y}`, name: l.group ? `${l.group} — ${l.name}` : l.name, facet: l.facet, detail: `${l.x}, ${l.y}`, kind: 'Landmark' })),
|
|
||||||
].sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
}, [data])
|
|
||||||
|
|
||||||
if (loading) return <Loading />
|
|
||||||
if (error) return <ErrorState message="Could not load places right now." />
|
|
||||||
if (rows.length === 0) return <EmptyState>No regions or landmarks match that.</EmptyState>
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
{rows.map((row) => (
|
|
||||||
<div key={row.key} className="panel" style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}>
|
|
||||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>{row.name}</span>
|
|
||||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>{row.facet} · {row.detail}</span>
|
|
||||||
<span className="sans dim" style={{ fontSize: '0.66rem', letterSpacing: '0.06em', flex: 'none' }}>{row.kind}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Atlas() {
|
|
||||||
const [tab, setTab] = useState('creatures')
|
|
||||||
const [input, setInput] = useState('')
|
|
||||||
const [q, setQ] = useState('')
|
|
||||||
const [facet, setFacet] = useState('')
|
|
||||||
const meta = useAsync(() => api.atlas.meta())
|
|
||||||
|
|
||||||
// Debounced: typing "lizardman" should be one request, not nine.
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => setQ(input.trim()), 250)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}, [input])
|
|
||||||
|
|
||||||
const facets = meta.data?.facets || []
|
|
||||||
const counts = meta.data?.counts || null
|
|
||||||
const imported = meta.data?.importedAt ? new Date(meta.data.importedAt) : null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PublicLayout section="website">
|
|
||||||
<div className="shell-narrow page-body">
|
|
||||||
<PageHeader
|
|
||||||
eyebrow="Bestiary"
|
|
||||||
title="Spawn atlas"
|
|
||||||
lead="Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* The atlas is only as good as its placement rate, so the page states
|
|
||||||
it rather than implying every spawner resolved to a named place. */}
|
|
||||||
{counts && (
|
|
||||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
|
|
||||||
{num(counts.creatures)} creatures across {num(counts.points)} spawners
|
|
||||||
{Number.isFinite(counts.unresolvedPoints) && counts.points
|
|
||||||
? ` · ${Math.round(((counts.points - counts.unresolvedPoints) / counts.points) * 100)}% placed to a named region or landmark`
|
|
||||||
: ''}
|
|
||||||
{imported ? ` · parsed ${imported.toLocaleDateString()}` : ''}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
|
||||||
{TABS.map((t) => (
|
|
||||||
<Chip key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
|
|
||||||
{t.label}
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab !== 'champions' && (
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="search"
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
placeholder={tab === 'creatures' ? 'Search creatures…' : 'Search regions and landmarks…'}
|
|
||||||
style={{ width: '100%', marginBottom: 12 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{facets.length > 0 && (
|
|
||||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 18 }}>
|
|
||||||
<Chip active={facet === ''} onClick={() => setFacet('')}>
|
|
||||||
All facets
|
|
||||||
</Chip>
|
|
||||||
{facets.map((f) => (
|
|
||||||
<Chip key={f} active={facet === f} onClick={() => setFacet(f)}>
|
|
||||||
{f}
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{meta.error && <ErrorState message="Could not load the atlas right now." />}
|
|
||||||
{!meta.error && !meta.loading && !imported && (
|
|
||||||
<EmptyState>The spawn atlas has not been imported yet.</EmptyState>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!meta.error && imported && (
|
|
||||||
<>
|
|
||||||
{tab === 'creatures' && <Creatures q={q} facet={facet} />}
|
|
||||||
{tab === 'champions' && <Champions facet={facet} />}
|
|
||||||
{tab === 'places' && <Places q={q} facet={facet} />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PublicLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { Link, useParams } from 'react-router-dom'
|
|
||||||
import { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync } from '../ui.js'
|
|
||||||
import { api } from '../api.js'
|
|
||||||
|
|
||||||
// One creature: where it spawns, and what spawns alongside it.
|
|
||||||
//
|
|
||||||
// `places` is the point of the page — the aggregate that turns 62 raw
|
|
||||||
// coordinates into "Shrines, Isamu-Jima, Yew". The individual spawners are
|
|
||||||
// available underneath for the reader who actually wants a coordinate, but they
|
|
||||||
// are secondary and collapsed by default.
|
|
||||||
|
|
||||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
|
||||||
|
|
||||||
// Spawn delays are stored in seconds. A raw "1200" tells the reader nothing.
|
|
||||||
function delay(min, max) {
|
|
||||||
const fmt = (s) => (s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`)
|
|
||||||
if (!Number.isFinite(min) || !Number.isFinite(max)) return null
|
|
||||||
if (min === max) return fmt(min)
|
|
||||||
return `${fmt(min)}–${fmt(max)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function Panel({ title, right, children }) {
|
|
||||||
return (
|
|
||||||
<section className="panel" style={{ padding: 18 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
|
||||||
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}>
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
{right}
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Places({ places }) {
|
|
||||||
if (places.length === 0) {
|
|
||||||
return <p className="sans dim" style={{ margin: 0 }}>No placed spawners.</p>
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{places.map((place) => (
|
|
||||||
<div
|
|
||||||
key={`${place.facet}:${place.label}`}
|
|
||||||
className="sans"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'baseline',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 12,
|
|
||||||
padding: '6px 0',
|
|
||||||
borderBottom: '1px solid var(--line)',
|
|
||||||
fontSize: '0.86rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ minWidth: 0, color: 'var(--head)' }}>{place.label}</span>
|
|
||||||
<span className="dim" style={{ flex: 'none' }}>
|
|
||||||
{place.facet} · {num(place.spawners)} spawner{place.spawners === 1 ? '' : 's'} · up to{' '}
|
|
||||||
{num(place.maxAlive)} at once
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Spawners({ spawners, truncated }) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
if (spawners.length === 0) return null
|
|
||||||
return (
|
|
||||||
<Panel
|
|
||||||
title="Individual spawners"
|
|
||||||
right={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sans"
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: '0.78rem' }}
|
|
||||||
>
|
|
||||||
{open ? 'Hide' : `Show ${num(spawners.length)}`}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{open && (
|
|
||||||
<div style={{ overflowX: 'auto' }}>
|
|
||||||
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ textAlign: 'left', color: 'var(--muted)' }}>
|
|
||||||
<th style={{ padding: '4px 8px 8px 0' }}>Place</th>
|
|
||||||
<th style={{ padding: '4px 8px 8px 0' }}>Facet</th>
|
|
||||||
<th style={{ padding: '4px 8px 8px 0' }}>Coords</th>
|
|
||||||
<th style={{ padding: '4px 8px 8px 0' }}>Max</th>
|
|
||||||
<th style={{ padding: '4px 0 8px 0' }}>Respawn</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{spawners.map((s) => (
|
|
||||||
<tr key={s.id} style={{ borderTop: '1px solid var(--line)' }}>
|
|
||||||
<td style={{ padding: '6px 8px 6px 0', color: 'var(--head)' }}>{s.label}</td>
|
|
||||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.facet}</td>
|
|
||||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.x}, {s.y}</td>
|
|
||||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{num(s.maxCount)}</td>
|
|
||||||
<td style={{ padding: '6px 0' }} className="dim">{delay(s.minDelay, s.maxDelay) || '—'}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{truncated && (
|
|
||||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
|
||||||
Only the largest spawners are listed.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AtlasCreature() {
|
|
||||||
const { slug } = useParams()
|
|
||||||
const { loading, error, data } = useAsync(() => api.atlas.creature(slug), [slug])
|
|
||||||
|
|
||||||
// A 404 here means "no such creature in this atlas", which is a real answer
|
|
||||||
// and not a failure — a visitor following a stale link deserves to be told
|
|
||||||
// that plainly rather than shown a generic error box.
|
|
||||||
const missing = error?.status === 404 || error?.message === 'Not Found'
|
|
||||||
|
|
||||||
const facets = useMemo(
|
|
||||||
() => Object.entries(data?.facets || {}).sort((a, b) => b[1] - a[1]),
|
|
||||||
[data],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PublicLayout section="website">
|
|
||||||
<div className="shell-narrow page-body">
|
|
||||||
<p className="sans" style={{ marginBottom: 8 }}>
|
|
||||||
<Link to="/uo/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
|
|
||||||
← Spawn atlas
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{loading && <Loading />}
|
|
||||||
{error && !missing && <ErrorState message="Could not load that creature right now." />}
|
|
||||||
{missing && <EmptyState>Nothing by that name spawns on this shard.</EmptyState>}
|
|
||||||
|
|
||||||
{!loading && !error && data && (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
eyebrow="Bestiary"
|
|
||||||
title={data.name}
|
|
||||||
lead={`Up to ${num(data.total)} alive at once across ${num(data.points)} spawner${data.points === 1 ? '' : 's'}.`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
<Panel
|
|
||||||
title="Where it spawns"
|
|
||||||
right={
|
|
||||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
|
||||||
{facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Places places={data.places || []} />
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Spawners spawners={data.spawners || []} truncated={!!data.spawnersTruncated} />
|
|
||||||
|
|
||||||
{data.alsoHere?.length > 0 && (
|
|
||||||
<Panel title="Shares a spawner with">
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
|
||||||
{data.alsoHere.map((other) => (
|
|
||||||
<Link
|
|
||||||
key={other.slug}
|
|
||||||
to={`/uo/atlas/${encodeURIComponent(other.slug)}`}
|
|
||||||
className="sans"
|
|
||||||
style={{
|
|
||||||
fontSize: '0.78rem',
|
|
||||||
padding: '4px 11px',
|
|
||||||
borderRadius: 999,
|
|
||||||
border: '1px solid var(--line)',
|
|
||||||
color: 'var(--muted)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{other.name} <span className="dim">×{num(other.shared)}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PublicLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
const reactDom = window.__rg.reactDom
|
|
||||||
export default reactDom
|
|
||||||
export const { createRoot, hydrateRoot } = reactDom
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// The automatic JSX runtime, from core's global. Every .jsx file in this module
|
|
||||||
// compiles to imports from here, so this is the single hottest path in the
|
|
||||||
// bundle — and the one that would silently produce a SECOND React if it resolved
|
|
||||||
// to a bundled copy instead.
|
|
||||||
const jsx = window.__rg.jsxRuntime
|
|
||||||
export default jsx
|
|
||||||
export const { jsx: jsxFn, jsxs, Fragment } = jsx
|
|
||||||
export { jsxFn as jsx }
|
|
||||||
10
modules/uo/client/src/shim/react-router-dom.js
vendored
10
modules/uo/client/src/shim/react-router-dom.js
vendored
@@ -1,10 +0,0 @@
|
|||||||
// react-router-dom from core's global. Same singleton argument as React, with a
|
|
||||||
// sharper edge: the router's context is created by whichever copy is loaded, so
|
|
||||||
// a second copy would give module pages an EMPTY router context — <Link> would
|
|
||||||
// throw and useParams() would return {} rather than the URL's params.
|
|
||||||
const router = window.__rg.router
|
|
||||||
export default router
|
|
||||||
export const {
|
|
||||||
Link, NavLink, Navigate, Outlet, Route, Routes, useParams, useNavigate,
|
|
||||||
useLocation, useSearchParams, createBrowserRouter, RouterProvider,
|
|
||||||
} = router
|
|
||||||
19
modules/uo/client/src/shim/react.js
vendored
19
modules/uo/client/src/shim/react.js
vendored
@@ -1,19 +0,0 @@
|
|||||||
// React, taken from core's global rather than bundled.
|
|
||||||
//
|
|
||||||
// There is exactly ONE React in the page and core owns it (MODULE_API.md §3.2).
|
|
||||||
// A module that bundled its own would get a second hook dispatcher and fail at
|
|
||||||
// the first useState — so `react` is declared external in vite.config.js and
|
|
||||||
// aliased here.
|
|
||||||
//
|
|
||||||
// Why an alias module rather than rollup's `output.globals`: `globals` only
|
|
||||||
// applies to iife/umd output, and this is an ES module. An alias is the ESM
|
|
||||||
// equivalent, and it also keeps named imports (`import { useState } from
|
|
||||||
// 'react'`) working unchanged in the page source.
|
|
||||||
const react = window.__rg.react
|
|
||||||
|
|
||||||
export default react
|
|
||||||
export const {
|
|
||||||
useState, useEffect, useMemo, useCallback, useRef, useContext, useReducer,
|
|
||||||
createElement, cloneElement, createContext, forwardRef, memo, Fragment,
|
|
||||||
Children, isValidElement, StrictMode, Suspense, lazy,
|
|
||||||
} = react
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
// Core's shared UI kit, from the global.
|
|
||||||
//
|
|
||||||
// This is the §3.4 kit: a curated, closed set — the layout chrome, the three
|
|
||||||
// page states, the async hook and the two read-only contexts. It exists because
|
|
||||||
// a module page that does not use core's layout is a module page that does not
|
|
||||||
// look like the site it is installed in, and drifts further every time core's
|
|
||||||
// chrome changes.
|
|
||||||
//
|
|
||||||
// Anything NOT in here, the module bundles itself.
|
|
||||||
const { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync, useAuth, useSite } =
|
|
||||||
window.__rg.ui
|
|
||||||
|
|
||||||
export { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync, useAuth, useSite }
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
// Library mode: one prebuilt ESM chunk, published by CI and dropped onto the
|
|
||||||
// operator's volume. The operator never builds anything (MODULE_SYSTEM.md §2.5).
|
|
||||||
//
|
|
||||||
// The four externals are the whole contract with core. Declaring them external
|
|
||||||
// alone is not enough, though: rollup would emit bare `import 'react'`
|
|
||||||
// specifiers, which a browser cannot resolve without an import map — and CSP
|
|
||||||
// forbids the inline <script type="importmap"> that would provide one. So each
|
|
||||||
// is ALIASED to a two-line shim that re-exports from window.__rg, and the
|
|
||||||
// external list then only has to stop Vite from following them into node_modules
|
|
||||||
// this package does not have.
|
|
||||||
const shim = (f) => path.resolve(import.meta.dirname, 'src/shim', f)
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
resolve: {
|
|
||||||
// EXACT matches, via the array form. Vite's object form does PREFIX
|
|
||||||
// replacement, so a plain `react` key also rewrote `react/jsx-runtime` into
|
|
||||||
// `src/shim/react.js/jsx-runtime` — a path that does not exist, and the
|
|
||||||
// first thing this build hit.
|
|
||||||
alias: [
|
|
||||||
{ find: /^react$/, replacement: shim('react.js') },
|
|
||||||
{ find: /^react\/jsx-runtime$/, replacement: shim('react-jsx-runtime.js') },
|
|
||||||
{ find: /^react-dom\/client$/, replacement: shim('react-dom-client.js') },
|
|
||||||
{ find: /^react-router-dom$/, replacement: shim('react-router-dom.js') },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
lib: {
|
|
||||||
entry: path.resolve(import.meta.dirname, 'src/entry.jsx'),
|
|
||||||
formats: ['es'],
|
|
||||||
fileName: () => 'entry.js',
|
|
||||||
},
|
|
||||||
outDir: 'dist',
|
|
||||||
emptyOutDir: true,
|
|
||||||
// Same reason as core's client: no inline bootstrap script for
|
|
||||||
// `script-src 'self'` to trip on.
|
|
||||||
modulePreload: { polyfill: false },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "uo",
|
|
||||||
"name": "Ultima Online",
|
|
||||||
"version": "0.1.0-spike",
|
|
||||||
"coreApi": "^1.0.0",
|
|
||||||
"server": "server/index.js",
|
|
||||||
"client": { "entry": "client/dist/entry.js" },
|
|
||||||
"schema": "server/db/schema.sql",
|
|
||||||
"purge": "server/db/purge.sql",
|
|
||||||
"mounts": {
|
|
||||||
"public": ["/atlas"]
|
|
||||||
},
|
|
||||||
"capabilities": ["atlas"]
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
// ── The module's single point of contact with core ─────────────────────────
|
|
||||||
//
|
|
||||||
// Every other file in this module imports THIS file instead of reaching into
|
|
||||||
// the website's tree. That is the whole mechanical trick behind the
|
|
||||||
// zero-internal-imports rule (docs/website/MODULE_API.md §5.1): the moved files
|
|
||||||
// changed by one `require` line each, and a CI grep for a relative path
|
|
||||||
// escaping the module root can then be an exact test rather than a heuristic.
|
|
||||||
//
|
|
||||||
// It exists because `ctx` arrives as an ARGUMENT to register(), while the files
|
|
||||||
// that need it are plain CommonJS modules that were written against top-level
|
|
||||||
// requires. Rather than thread ctx through nine constructors, register() parks
|
|
||||||
// it here once and everything else reads it lazily.
|
|
||||||
//
|
|
||||||
// Lazily is load-bearing: this file is required at module-require time, which is
|
|
||||||
// during app.js's own require, and reading `ctx.db` eagerly would rebuild the
|
|
||||||
// startup-time database dependency the loader is careful not to have.
|
|
||||||
|
|
||||||
let ctx = null
|
|
||||||
|
|
||||||
/** Called exactly once, by server/index.js, at the top of register(). */
|
|
||||||
function init(next) {
|
|
||||||
if (ctx) throw new Error('module-uo: core.init() called twice')
|
|
||||||
ctx = next
|
|
||||||
}
|
|
||||||
|
|
||||||
function require_() {
|
|
||||||
if (!ctx) throw new Error('module-uo: core used before register() ran')
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forwarders rather than re-exports: `const { query } = require('./core')`
|
|
||||||
// destructures at require time, which is before init(), so a plain re-export
|
|
||||||
// would capture undefined. Each of these resolves ctx at CALL time.
|
|
||||||
const query = (sql, params) => require_().db.query(sql, params)
|
|
||||||
|
|
||||||
const logger = (namespace) => require_().log(namespace)
|
|
||||||
|
|
||||||
const settings = {
|
|
||||||
get: (key) => require_().settings.get(key),
|
|
||||||
// `updatedBy` is the third parameter core's settings.model.set carries — the
|
|
||||||
// atlas path setter passes it (shardAtlas.model.js:60), so dropping it here
|
|
||||||
// would silently lose the audit attribution rather than fail.
|
|
||||||
set: (key, value, updatedBy) => require_().settings.set(key, value, updatedBy),
|
|
||||||
getInstanceName: () => require_().settings.getInstanceName(),
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = {
|
|
||||||
getUserFromRequest: (req) => require_().auth.getUserFromRequest(req),
|
|
||||||
}
|
|
||||||
|
|
||||||
const middleware = {
|
|
||||||
siteMode: (req, res, next) => require_().middleware.siteMode(req, res, next),
|
|
||||||
validate: (req, res, next) => require_().middleware.validate(req, res, next),
|
|
||||||
requireAuth: (req, res, next) => require_().middleware.requireAuth(req, res, next),
|
|
||||||
noindex: (req, res, next) => require_().middleware.noindex(req, res, next),
|
|
||||||
requireRole: (...roles) => {
|
|
||||||
// requireRole is a FACTORY, so it must be resolved at call time and the
|
|
||||||
// resulting middleware kept — resolving it per request would build a new
|
|
||||||
// closure on every hit.
|
|
||||||
let built = null
|
|
||||||
return (req, res, next) => {
|
|
||||||
built = built || require_().middleware.requireRole(...roles)
|
|
||||||
return built(req, res, next)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
init,
|
|
||||||
// Shared server dependencies, taken from core rather than required directly.
|
|
||||||
// A module lives outside server/, so `require('express')` from here does not
|
|
||||||
// resolve at all — and even where it did, a second express in the process
|
|
||||||
// would be a second Router prototype. Same rule as React on the client.
|
|
||||||
get express() { return require_().express },
|
|
||||||
get validator() { return require_().validator },
|
|
||||||
query,
|
|
||||||
logger,
|
|
||||||
settings,
|
|
||||||
auth,
|
|
||||||
middleware,
|
|
||||||
get pool() { return require_().db.pool },
|
|
||||||
get secretBox() { return require_().secretBox },
|
|
||||||
get push() { return require_().push },
|
|
||||||
get uploads() { return require_().uploads },
|
|
||||||
get posts() { return require_().posts },
|
|
||||||
get paths() { return require_().paths },
|
|
||||||
get moduleId() { return require_().moduleId },
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
-- ── module-uo · purge ──────────────────────────────────────────────────────
|
|
||||||
--
|
|
||||||
-- DESTRUCTIVE. Run ONLY by the explicit admin purge action, never by uninstall
|
|
||||||
-- (docs/website/MODULE_API.md §2.6) — uninstalling a module removes its code and
|
|
||||||
-- retains its data, and an operator who wants the data gone has to say so.
|
|
||||||
--
|
|
||||||
-- Required because this module declares a schema fragment: a module that can
|
|
||||||
-- create tables and cannot drop them leaves an operator with orphaned data and
|
|
||||||
-- no supported way to remove it.
|
|
||||||
--
|
|
||||||
-- Dropped children-first even though these tables carry no foreign keys, so the
|
|
||||||
-- order stays correct if Phase 3 adds one.
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS shard_atlas_pending;
|
|
||||||
DROP TABLE IF EXISTS shard_atlas_meta;
|
|
||||||
DROP TABLE IF EXISTS shard_champion_spawns;
|
|
||||||
DROP TABLE IF EXISTS shard_landmarks;
|
|
||||||
DROP TABLE IF EXISTS shard_regions;
|
|
||||||
DROP TABLE IF EXISTS shard_spawn_point_types;
|
|
||||||
DROP TABLE IF EXISTS shard_spawn_points;
|
|
||||||
DROP TABLE IF EXISTS shard_spawn_creatures;
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
-- ── module-uo · schema fragment ────────────────────────────────────────────
|
|
||||||
--
|
|
||||||
-- Replayed by core's ensureSchema() immediately after core's own schema.sql,
|
|
||||||
-- statement by statement, split the same way (docs/website/MODULE_API.md §2.6).
|
|
||||||
-- It inherits core's rules because it goes through core's splitter: idempotent
|
|
||||||
-- CREATE/ALTER only, no DROP, and no `--` inside a string literal.
|
|
||||||
--
|
|
||||||
-- SPIKE SCOPE: the eight spawn-atlas tables, lifted verbatim out of
|
|
||||||
-- server/db/schema.sql. Phase 3 brings the other nineteen.
|
|
||||||
--
|
|
||||||
-- These names are NOT `uo_`-prefixed, which the contract otherwise requires of a
|
|
||||||
-- module's tables. module-uo is grandfathered by an explicit allowlist in the
|
|
||||||
-- loader: renaming twenty-seven live tables is a data migration this workstream
|
|
||||||
-- deliberately does not do, and the prefix rule holds for every module written
|
|
||||||
-- after this one.
|
|
||||||
|
|
||||||
-- ── Spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
|
|
||||||
-- Static shard CONTENT, not live shard state: what spawns where, which regions
|
|
||||||
-- and landmarks exist, and which champion altars are configured. Nothing here
|
|
||||||
-- comes from the sidecar — it is imported from a committed artifact built off a
|
|
||||||
-- ServUO tree by `npm run atlas:build` (see docs/website/SPAWN_ATLAS.md), so
|
|
||||||
-- these tables stay populated whether the shard is up or not.
|
|
||||||
--
|
|
||||||
-- Every table is import-owned: `npm run atlas:import` TRUNCATEs and reloads them
|
|
||||||
-- in one transaction. Nothing else may write here, and nothing else may hold a
|
|
||||||
-- foreign key to them. No FKs at all, consistent with every other shard_* table.
|
|
||||||
|
|
||||||
-- One row per spawnable type, aggregated across the world. `total` is the sum of
|
|
||||||
-- each type's own MX across every point that spawns it (how many exist at once);
|
|
||||||
-- `facets` is a per-facet point count, so the facet filter and "where does this
|
|
||||||
-- live" both answer without touching shard_spawn_points.
|
|
||||||
CREATE TABLE IF NOT EXISTS shard_spawn_creatures (
|
|
||||||
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- slugified class name; the /atlas/:slug key
|
|
||||||
name VARCHAR(120) NOT NULL, -- display spelling chosen by the build
|
|
||||||
total INT NOT NULL DEFAULT 0,
|
|
||||||
points INT NOT NULL DEFAULT 0,
|
|
||||||
facets JSON NULL, -- { "Felucca": 171, "Trammel": 160, ... }
|
|
||||||
-- Operator-supplied artwork, always NULL on a fresh import. The repo ships no
|
|
||||||
-- creature art: sprites live in the operator's own client .mul/.uop files and
|
|
||||||
-- are theirs to extract and place under uploads/atlas/. The UI renders without
|
|
||||||
-- art when this is NULL, which is the normal case.
|
|
||||||
art VARCHAR(255) NULL,
|
|
||||||
-- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free,
|
|
||||||
-- and FULLTEXT's min-token-length would break searches for names like "orc".
|
|
||||||
INDEX idx_shard_spawn_creatures_name (name)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
-- One row per spawner. `region`/`landmark` are the resolved place name — the
|
|
||||||
-- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is
|
|
||||||
-- the resolved display string (region, else landmark, else 'Wilderness').
|
|
||||||
CREATE TABLE IF NOT EXISTS shard_spawn_points (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
facet VARCHAR(40) NOT NULL,
|
|
||||||
name VARCHAR(120) NULL, -- the ServUO spawner's own name
|
|
||||||
x INT NOT NULL,
|
|
||||||
y INT NOT NULL,
|
|
||||||
width INT NOT NULL DEFAULT 0,
|
|
||||||
height INT NOT NULL DEFAULT 0,
|
|
||||||
spawn_range INT NOT NULL DEFAULT 0, -- `range` is reserved in MariaDB
|
|
||||||
max_count INT NOT NULL DEFAULT 0,
|
|
||||||
min_delay INT NOT NULL DEFAULT 0,
|
|
||||||
max_delay INT NOT NULL DEFAULT 0,
|
|
||||||
tod_start INT NOT NULL DEFAULT 0, -- meaningless unless tod_mode <> 0
|
|
||||||
tod_end INT NOT NULL DEFAULT 0,
|
|
||||||
tod_mode INT NOT NULL DEFAULT 0,
|
|
||||||
region VARCHAR(120) NULL,
|
|
||||||
landmark VARCHAR(120) NULL,
|
|
||||||
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
|
|
||||||
INDEX idx_shard_spawn_points_facet (facet),
|
|
||||||
INDEX idx_shard_spawn_points_label (label)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
-- The many-to-many between the two above: one spawner commonly carries several
|
|
||||||
-- types (a single Trammel point spawns six), each with its own max. This is how
|
|
||||||
-- /atlas/creatures/:slug finds the places a creature appears.
|
|
||||||
CREATE TABLE IF NOT EXISTS shard_spawn_point_types (
|
|
||||||
point_id INT NOT NULL,
|
|
||||||
slug VARCHAR(120) NOT NULL, -- → shard_spawn_creatures.slug (no FK)
|
|
||||||
max_count INT NOT NULL DEFAULT 1,
|
|
||||||
PRIMARY KEY (point_id, slug),
|
|
||||||
INDEX idx_shard_spawn_point_types_slug (slug)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
-- Named regions from Data/Regions.xml, flattened out of their nesting. `rects`
|
|
||||||
-- holds the region's rectangles; `priority` and rect area are what resolved each
|
|
||||||
-- spawn point at build time, kept here so the admin drift check can re-derive.
|
|
||||||
CREATE TABLE IF NOT EXISTS shard_regions (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
facet VARCHAR(40) NOT NULL,
|
|
||||||
name VARCHAR(120) NOT NULL,
|
|
||||||
type VARCHAR(80) NULL, -- ServUO region class
|
|
||||||
priority INT NOT NULL DEFAULT 0,
|
|
||||||
parent VARCHAR(120) NULL, -- enclosing named region, if any
|
|
||||||
rects JSON NULL,
|
|
||||||
INDEX idx_shard_regions_facet (facet),
|
|
||||||
INDEX idx_shard_regions_name (name)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
-- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing
|
|
||||||
-- parent ("Covetous"), which is the label worth showing — "Covetous" reads
|
|
||||||
-- better than the individual marker "Level 1". (`group` is reserved in SQL.)
|
|
||||||
CREATE TABLE IF NOT EXISTS shard_landmarks (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
facet VARCHAR(40) NOT NULL,
|
|
||||||
name VARCHAR(120) NOT NULL,
|
|
||||||
grp VARCHAR(120) NULL,
|
|
||||||
x INT NOT NULL,
|
|
||||||
y INT NOT NULL,
|
|
||||||
z INT NOT NULL DEFAULT 0,
|
|
||||||
INDEX idx_shard_landmarks_facet (facet),
|
|
||||||
INDEX idx_shard_landmarks_name (name)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
|
|
||||||
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
|
|
||||||
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
|
|
||||||
CREATE TABLE IF NOT EXISTS shard_champion_spawns (
|
|
||||||
slug VARCHAR(160) NOT NULL PRIMARY KEY, -- facet-name, e.g. "felucca-deceit"
|
|
||||||
name VARCHAR(120) NOT NULL,
|
|
||||||
grp VARCHAR(80) NULL, -- spawn group; one active per group
|
|
||||||
type VARCHAR(80) NULL, -- '' when randomised per activation
|
|
||||||
random_type TINYINT(1) NOT NULL DEFAULT 0,
|
|
||||||
facet VARCHAR(40) NOT NULL,
|
|
||||||
x INT NOT NULL,
|
|
||||||
y INT NOT NULL,
|
|
||||||
z INT NOT NULL DEFAULT 0,
|
|
||||||
radius INT NOT NULL DEFAULT 0,
|
|
||||||
label VARCHAR(120) NULL, -- resolved place name
|
|
||||||
INDEX idx_shard_champion_spawns_facet (facet)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
|
|
||||||
-- Singleton (id = 1) describing the artifact currently loaded: when it was
|
|
||||||
-- built, its counts, and a sha256 per ServUO source file. The admin drift check
|
|
||||||
-- compares this against db/data/spawnAtlas.meta.json to report when the database
|
|
||||||
-- is behind the committed artifact.
|
|
||||||
CREATE TABLE IF NOT EXISTS shard_atlas_meta (
|
|
||||||
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
|
||||||
payload JSON NOT NULL,
|
|
||||||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT chk_shard_atlas_meta_singleton CHECK (id = 1)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
-- Singleton (id = 1) holding an atlas refresh that was parsed but deliberately
|
|
||||||
-- NOT applied, because it would remove a facet the site currently serves.
|
|
||||||
--
|
|
||||||
-- Losing a facet is the signature of a half-copied or mid-update ServUO tree as
|
|
||||||
-- much as of a real map change, and boot cannot tell the two apart — so the
|
|
||||||
-- refresh is staged here for a human instead of being applied. Startup is never
|
|
||||||
-- blocked by it: the site comes up serving the atlas it already had.
|
|
||||||
--
|
|
||||||
-- Only the DECISION is stored, not the parsed world: `payload` holds the source
|
|
||||||
-- hashes and the facet diff (a few KB), and approving re-parses the tree. That
|
|
||||||
-- keeps a multi-megabyte blob out of the database and guarantees the applied
|
|
||||||
-- atlas matches the tree as it is at approval time, not as it was at boot.
|
|
||||||
--
|
|
||||||
-- `rejected` is remembered against those exact source hashes so a declined
|
|
||||||
-- refresh does not re-prompt on every restart; changing the tree changes the
|
|
||||||
-- hashes and asks again.
|
|
||||||
CREATE TABLE IF NOT EXISTS shard_atlas_pending (
|
|
||||||
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
|
||||||
status ENUM('pending','rejected') NOT NULL DEFAULT 'pending',
|
|
||||||
payload JSON NOT NULL, -- source hashes + facet diff
|
|
||||||
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user